├── .github └── workflows │ ├── build-container.yml │ ├── build.yml │ └── release.yml ├── .gitignore ├── .kamal ├── hooks │ ├── docker-setup.sample │ ├── post-deploy.sample │ ├── post-proxy-reboot.sample │ ├── pre-build.sample │ ├── pre-connect.sample │ ├── pre-deploy.sample │ └── pre-proxy-reboot.sample └── secrets ├── MyApp.ServiceInterface ├── Data │ ├── ApplicationDbContext.cs │ ├── ApplicationUser.cs │ └── CustomUserSession.cs ├── EmailServices.cs ├── MyApp.ServiceInterface.csproj ├── MyServices.cs └── TodosServices.cs ├── MyApp.ServiceModel ├── Bookings.cs ├── Emails.cs ├── Hello.cs ├── MyApp.ServiceModel.csproj ├── Roles.cs ├── Todos.cs ├── User.cs └── types │ └── README.md ├── MyApp.Tests ├── IntegrationTest.cs ├── MigrationTasks.cs ├── MyApp.Tests.csproj └── UnitTest.cs ├── MyApp.sln ├── MyApp ├── AppComponentBase.cs ├── App_Data │ └── README.md ├── Components │ ├── Account │ │ ├── IdentityComponentsEndpointRouteBuilderExtensions.cs │ │ ├── IdentityNoOpEmailSender.cs │ │ ├── IdentityRedirectManager.cs │ │ ├── IdentityRevalidatingAuthenticationStateProvider.cs │ │ ├── IdentityUserAccessor.cs │ │ ├── Pages │ │ │ ├── AccessDenied.razor │ │ │ ├── ConfirmEmail.razor │ │ │ ├── ConfirmEmailChange.razor │ │ │ ├── ExternalLogin.razor │ │ │ ├── ForgotPassword.razor │ │ │ ├── ForgotPasswordConfirmation.razor │ │ │ ├── InvalidPasswordReset.razor │ │ │ ├── InvalidUser.razor │ │ │ ├── Lockout.razor │ │ │ ├── Login.razor │ │ │ ├── LoginWith2fa.razor │ │ │ ├── LoginWithRecoveryCode.razor │ │ │ ├── Logout.razor │ │ │ ├── Manage │ │ │ │ ├── ApiKeys.razor │ │ │ │ ├── ChangePassword.razor │ │ │ │ ├── DeletePersonalData.razor │ │ │ │ ├── Disable2fa.razor │ │ │ │ ├── Email.razor │ │ │ │ ├── EnableAuthenticator.razor │ │ │ │ ├── ExternalLogins.razor │ │ │ │ ├── GenerateRecoveryCodes.razor │ │ │ │ ├── Index.razor │ │ │ │ ├── ManageNavPages.cs │ │ │ │ ├── PersonalData.razor │ │ │ │ ├── ResetAuthenticator.razor │ │ │ │ ├── SetPassword.razor │ │ │ │ ├── TwoFactorAuthentication.razor │ │ │ │ └── _Imports.razor │ │ │ ├── Register.razor │ │ │ ├── RegisterConfirmation.razor │ │ │ ├── ResendEmailConfirmation.razor │ │ │ ├── ResetPassword.razor │ │ │ ├── ResetPasswordConfirmation.razor │ │ │ └── _Imports.razor │ │ └── Shared │ │ │ ├── AccountLayout.razor │ │ │ ├── ExternalLoginPicker.razor │ │ │ ├── ManageLayout.razor │ │ │ ├── ManageNavMenu.razor │ │ │ ├── RedirectToLogin.razor │ │ │ ├── ShowRecoveryCodes.razor │ │ │ └── StatusMessage.razor │ ├── App.razor │ ├── Layout │ │ └── MainLayout.razor │ ├── Pages │ │ ├── Admin │ │ │ ├── Bookings.razor │ │ │ ├── Coupons.razor │ │ │ └── Index.razor │ │ ├── Counter.razor │ │ ├── Docs.razor │ │ ├── Error.razor │ │ ├── Home.razor │ │ ├── Profile.razor │ │ ├── Secure │ │ │ ├── Bookings.razor │ │ │ └── Coupons.razor │ │ ├── TodoMvc.razor │ │ └── Weather.razor │ ├── Routes.razor │ ├── Shared │ │ ├── FormLoading.razor │ │ ├── GettingStarted.razor │ │ ├── Header.razor │ │ ├── ShellCommand.razor │ │ └── Sidebar.razor │ └── _Imports.razor ├── Configure.AppHost.cs ├── Configure.Auth.cs ├── Configure.AutoQuery.cs ├── Configure.BackgroundJobs.cs ├── Configure.Db.Migrations.cs ├── Configure.Db.cs ├── Configure.HealthChecks.cs ├── Configure.Markdown.cs ├── Configure.OpenApi.cs ├── Configure.RequestLogs.cs ├── Markdown.Pages.cs ├── Markdown.Videos.cs ├── MarkdownPagesBase.cs ├── Migrations │ ├── 20240301000000_CreateIdentitySchema.Designer.cs │ ├── 20240301000000_CreateIdentitySchema.cs │ ├── ApplicationDbContextModelSnapshot.cs │ └── Migration1000.cs ├── MyApp.csproj ├── Program.cs ├── Properties │ └── launchSettings.json ├── _pages │ ├── about.md │ ├── deploy.md │ └── privacy.md ├── _videos │ └── blazor │ │ ├── admin.md │ │ ├── components.md │ │ ├── darkmode.md │ │ ├── tailwind.md │ │ └── universal.md ├── appsettings.Development.json ├── appsettings.json ├── package.json ├── postinstall.js ├── tailwind.config.js ├── tailwind.input.css └── wwwroot │ ├── app.css │ ├── css │ ├── app.css │ ├── highlight.css │ └── typography.css │ ├── favicon.png │ ├── img │ ├── blazor.svg │ ├── nav │ │ ├── bookings.svg │ │ ├── counter.svg │ │ ├── coupon.svg │ │ ├── home.svg │ │ ├── profile.svg │ │ ├── todomvc.svg │ │ └── weather.svg │ └── profiles │ │ ├── user1.svg │ │ ├── user2.svg │ │ └── user3.svg │ ├── lib │ ├── js │ │ ├── highlight.js │ │ └── qrcode.min.js │ └── mjs │ │ ├── servicestack-client.min.mjs │ │ └── servicestack-client.mjs │ ├── mjs │ ├── app.mjs │ └── components.mjs │ ├── pages │ └── Account │ │ └── Manage │ │ ├── EnableAuthenticator.mjs │ │ ├── ManageUserApiKeys.mjs │ │ └── dtos.mjs │ └── tailwind │ ├── README.txt │ └── ServiceStack.Blazor.html ├── README.md └── config └── deploy.yml /.github/workflows/build-container.yml: -------------------------------------------------------------------------------- 1 | name: Build Container 2 | permissions: 3 | packages: write 4 | contents: write 5 | on: 6 | workflow_run: 7 | workflows: ["Build"] 8 | types: 9 | - completed 10 | branches: 11 | - main 12 | - master 13 | workflow_dispatch: 14 | 15 | # Only update envs here if you need to change them for this workflow 16 | env: 17 | DOCKER_BUILDKIT: 1 18 | KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} 19 | KAMAL_REGISTRY_USERNAME: ${{ github.actor }} 20 | 21 | jobs: 22 | build-container: 23 | runs-on: ubuntu-latest 24 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | 29 | - name: Set up environment variables 30 | run: | 31 | echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 32 | echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV 33 | echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 34 | echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV 35 | 36 | # This step is for the deployment of the templates only, safe to delete 37 | - name: Modify csproj for template deploy 38 | env: 39 | KAMAL_DEPLOY_IP: ${{ secrets.KAMAL_DEPLOY_IP }} 40 | if: env.KAMAL_DEPLOY_IP != null 41 | run: | 42 | sed -i 's###g' MyApp/MyApp.csproj 43 | 44 | - name: Check for Client directory and package.json 45 | id: check_client 46 | run: | 47 | if [ -d "MyApp.Client" ] && [ -f "MyApp.Client/package.json" ]; then 48 | echo "requires_npm=true" >> $GITHUB_OUTPUT 49 | fi 50 | 51 | - name: Setup Node.js 52 | if: steps.check_client.outputs.requires_npm == 'true' 53 | uses: actions/setup-node@v3 54 | with: 55 | node-version: 22 56 | 57 | - name: Install npm dependencies 58 | if: steps.check_client.outputs.requires_npm == 'true' 59 | working-directory: ./MyApp.Client 60 | run: npm install 61 | 62 | - name: Install tailwindcss 63 | run: | 64 | mkdir -p /home/runner/.local/bin 65 | curl -o "/home/runner/.local/bin/tailwindcss" -L "https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64" 66 | chmod +x /home/runner/.local/bin/tailwindcss 67 | 68 | - name: Install x tool 69 | run: dotnet tool install -g x 70 | 71 | - name: Apply Production AppSettings 72 | env: 73 | APPSETTINGS_PATCH: ${{ secrets.APPSETTINGS_PATCH }} 74 | if: env.APPSETTINGS_PATCH != null 75 | working-directory: ./MyApp 76 | run: | 77 | cat <> appsettings.json.patch 78 | ${{ secrets.APPSETTINGS_PATCH }} 79 | EOF 80 | x patch appsettings.json.patch 81 | 82 | - name: Login to GitHub Container Registry 83 | uses: docker/login-action@v3 84 | with: 85 | registry: ghcr.io 86 | username: ${{ env.KAMAL_REGISTRY_USERNAME }} 87 | password: ${{ env.KAMAL_REGISTRY_PASSWORD }} 88 | 89 | - name: Setup .NET 90 | uses: actions/setup-dotnet@v3 91 | with: 92 | dotnet-version: '8.0' 93 | 94 | - name: Build and push Docker image 95 | run: | 96 | dotnet publish --os linux --arch x64 -c Release -p:ContainerRepository=${{ env.image_repository_name }} -p:ContainerRegistry=ghcr.io -p:ContainerImageTags=latest -p:ContainerPort=80 97 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: {} 5 | push: 6 | branches: 7 | - '**' # matches every branch 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup dotnet 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: '8.*' 20 | 21 | - name: build 22 | run: dotnet build 23 | working-directory: . 24 | 25 | - name: test 26 | run: | 27 | dotnet test 28 | if [ $? -eq 0 ]; then 29 | echo TESTS PASSED 30 | else 31 | echo TESTS FAILED 32 | exit 1 33 | fi 34 | working-directory: ./MyApp.Tests 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | permissions: 3 | packages: write 4 | contents: write 5 | on: 6 | workflow_run: 7 | workflows: ["Build Container"] 8 | types: 9 | - completed 10 | branches: 11 | - main 12 | - master 13 | workflow_dispatch: 14 | 15 | env: 16 | DOCKER_BUILDKIT: 1 17 | KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} 18 | KAMAL_REGISTRY_USERNAME: ${{ github.actor }} 19 | 20 | jobs: 21 | release: 22 | runs-on: ubuntu-latest 23 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v3 27 | 28 | - name: Set up environment variables 29 | run: | 30 | echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 31 | echo "repository_name=$(echo ${{ github.repository }} | cut -d '/' -f 2)" >> $GITHUB_ENV 32 | echo "repository_name_lower=$(echo ${{ github.repository }} | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 33 | echo "org_name=$(echo ${{ github.repository }} | cut -d '/' -f 1)" >> $GITHUB_ENV 34 | if find . -maxdepth 2 -type f -name "Configure.Db.Migrations.cs" | grep -q .; then 35 | echo "HAS_MIGRATIONS=true" >> $GITHUB_ENV 36 | else 37 | echo "HAS_MIGRATIONS=false" >> $GITHUB_ENV 38 | fi 39 | 40 | # This step is for the deployment of the templates only, safe to delete 41 | - name: Modify deploy.yml 42 | env: 43 | KAMAL_DEPLOY_IP: ${{ secrets.KAMAL_DEPLOY_IP }} 44 | if: env.KAMAL_DEPLOY_IP != null 45 | run: | 46 | sed -i "s/service: my-app/service: ${{ env.repository_name_lower }}/g" config/deploy.yml 47 | sed -i "s#image: my-user/myapp#image: ${{ env.image_repository_name }}#g" config/deploy.yml 48 | sed -i "s/- 192.168.0.1/- ${{ secrets.KAMAL_DEPLOY_IP }}/g" config/deploy.yml 49 | sed -i "s/host: my-app.example.com/host: ${{ secrets.KAMAL_DEPLOY_HOST }}/g" config/deploy.yml 50 | sed -i "s/MyApp/${{ env.repository_name }}/g" config/deploy.yml 51 | 52 | - name: Login to GitHub Container Registry 53 | uses: docker/login-action@v3 54 | with: 55 | registry: ghcr.io 56 | username: ${{ env.KAMAL_REGISTRY_USERNAME }} 57 | password: ${{ env.KAMAL_REGISTRY_PASSWORD }} 58 | 59 | - name: Set up SSH key 60 | uses: webfactory/ssh-agent@v0.9.0 61 | with: 62 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 63 | 64 | - name: Setup Ruby 65 | uses: ruby/setup-ruby@v1 66 | with: 67 | ruby-version: 3.3.0 68 | bundler-cache: true 69 | 70 | - name: Install Kamal 71 | run: gem install kamal -v 2.3.0 72 | 73 | - name: Set up Docker Buildx 74 | uses: docker/setup-buildx-action@v3 75 | with: 76 | driver-opts: image=moby/buildkit:master 77 | 78 | - name: Kamal bootstrap 79 | run: kamal server bootstrap 80 | 81 | - name: Check if first run and execute kamal app boot if necessary 82 | run: | 83 | FIRST_RUN_FILE=".${{ env.repository_name }}" 84 | if ! kamal server exec --no-interactive -q "test -f $FIRST_RUN_FILE"; then 85 | kamal server exec --no-interactive -q "touch $FIRST_RUN_FILE" || true 86 | kamal deploy -q -P --version latest || true 87 | else 88 | echo "Not first run, skipping kamal app boot" 89 | fi 90 | 91 | - name: Ensure file permissions 92 | run: | 93 | kamal server exec --no-interactive "mkdir -p /opt/docker/${{ env.repository_name }}/App_Data && chown -R 1654:1654 /opt/docker/${{ env.repository_name }}" 94 | 95 | - name: Migration 96 | if: env.HAS_MIGRATIONS == 'true' 97 | run: | 98 | kamal server exec --no-interactive 'echo "${{ env.KAMAL_REGISTRY_PASSWORD }}" | docker login ghcr.io -u ${{ env.KAMAL_REGISTRY_USERNAME }} --password-stdin' 99 | kamal server exec --no-interactive "docker pull ghcr.io/${{ env.image_repository_name }}:latest || true" 100 | kamal app exec --no-reuse --no-interactive --version=latest "--AppTasks=migrate" 101 | 102 | - name: Deploy with Kamal 103 | run: | 104 | kamal lock release -v 105 | kamal deploy -P --version latest -------------------------------------------------------------------------------- /.kamal/hooks/docker-setup.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Docker set up on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /.kamal/hooks/post-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample post-deploy hook 4 | # 5 | # These environment variables are available: 6 | # KAMAL_RECORDED_AT 7 | # KAMAL_PERFORMER 8 | # KAMAL_VERSION 9 | # KAMAL_HOSTS 10 | # KAMAL_ROLE (if set) 11 | # KAMAL_DESTINATION (if set) 12 | # KAMAL_RUNTIME 13 | 14 | echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" 15 | -------------------------------------------------------------------------------- /.kamal/hooks/post-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooted kamal-proxy on $KAMAL_HOSTS" 4 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-build.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample pre-build hook 4 | # 5 | # Checks: 6 | # 1. We have a clean checkout 7 | # 2. A remote is configured 8 | # 3. The branch has been pushed to the remote 9 | # 4. The version we are deploying matches the remote 10 | # 11 | # These environment variables are available: 12 | # KAMAL_RECORDED_AT 13 | # KAMAL_PERFORMER 14 | # KAMAL_VERSION 15 | # KAMAL_HOSTS 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | if [ -n "$(git status --porcelain)" ]; then 20 | echo "Git checkout is not clean, aborting..." >&2 21 | git status --porcelain >&2 22 | exit 1 23 | fi 24 | 25 | first_remote=$(git remote) 26 | 27 | if [ -z "$first_remote" ]; then 28 | echo "No git remote set, aborting..." >&2 29 | exit 1 30 | fi 31 | 32 | current_branch=$(git branch --show-current) 33 | 34 | if [ -z "$current_branch" ]; then 35 | echo "Not on a git branch, aborting..." >&2 36 | exit 1 37 | fi 38 | 39 | remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) 40 | 41 | if [ -z "$remote_head" ]; then 42 | echo "Branch not pushed to remote, aborting..." >&2 43 | exit 1 44 | fi 45 | 46 | if [ "$KAMAL_VERSION" != "$remote_head" ]; then 47 | echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 48 | exit 1 49 | fi 50 | 51 | exit 0 52 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-connect.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-connect check 4 | # 5 | # Warms DNS before connecting to hosts in parallel 6 | # 7 | # These environment variables are available: 8 | # KAMAL_RECORDED_AT 9 | # KAMAL_PERFORMER 10 | # KAMAL_VERSION 11 | # KAMAL_HOSTS 12 | # KAMAL_ROLE (if set) 13 | # KAMAL_DESTINATION (if set) 14 | # KAMAL_RUNTIME 15 | 16 | hosts = ENV["KAMAL_HOSTS"].split(",") 17 | results = nil 18 | max = 3 19 | 20 | elapsed = Benchmark.realtime do 21 | results = hosts.map do |host| 22 | Thread.new do 23 | tries = 1 24 | 25 | begin 26 | Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) 27 | rescue SocketError 28 | if tries < max 29 | puts "Retrying DNS warmup: #{host}" 30 | tries += 1 31 | sleep rand 32 | retry 33 | else 34 | puts "DNS warmup failed: #{host}" 35 | host 36 | end 37 | end 38 | 39 | tries 40 | end 41 | end.map(&:value) 42 | end 43 | 44 | retries = results.sum - hosts.size 45 | nopes = results.count { |r| r == max } 46 | 47 | puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] 48 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-deploy hook 4 | # 5 | # Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. 6 | # 7 | # Fails unless the combined status is "success" 8 | # 9 | # These environment variables are available: 10 | # KAMAL_RECORDED_AT 11 | # KAMAL_PERFORMER 12 | # KAMAL_VERSION 13 | # KAMAL_HOSTS 14 | # KAMAL_COMMAND 15 | # KAMAL_SUBCOMMAND 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | # Only check the build status for production deployments 20 | if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" 21 | exit 0 22 | end 23 | 24 | require "bundler/inline" 25 | 26 | # true = install gems so this is fast on repeat invocations 27 | gemfile(true, quiet: true) do 28 | source "https://rubygems.org" 29 | 30 | gem "octokit" 31 | gem "faraday-retry" 32 | end 33 | 34 | MAX_ATTEMPTS = 72 35 | ATTEMPTS_GAP = 10 36 | 37 | def exit_with_error(message) 38 | $stderr.puts message 39 | exit 1 40 | end 41 | 42 | class GithubStatusChecks 43 | attr_reader :remote_url, :git_sha, :github_client, :combined_status 44 | 45 | def initialize 46 | @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") 47 | @git_sha = `git rev-parse HEAD`.strip 48 | @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) 49 | refresh! 50 | end 51 | 52 | def refresh! 53 | @combined_status = github_client.combined_status(remote_url, git_sha) 54 | end 55 | 56 | def state 57 | combined_status[:state] 58 | end 59 | 60 | def first_status_url 61 | first_status = combined_status[:statuses].find { |status| status[:state] == state } 62 | first_status && first_status[:target_url] 63 | end 64 | 65 | def complete_count 66 | combined_status[:statuses].count { |status| status[:state] != "pending"} 67 | end 68 | 69 | def total_count 70 | combined_status[:statuses].count 71 | end 72 | 73 | def current_status 74 | if total_count > 0 75 | "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." 76 | else 77 | "Build not started..." 78 | end 79 | end 80 | end 81 | 82 | 83 | $stdout.sync = true 84 | 85 | puts "Checking build status..." 86 | attempts = 0 87 | checks = GithubStatusChecks.new 88 | 89 | begin 90 | loop do 91 | case checks.state 92 | when "success" 93 | puts "Checks passed, see #{checks.first_status_url}" 94 | exit 0 95 | when "failure" 96 | exit_with_error "Checks failed, see #{checks.first_status_url}" 97 | when "pending" 98 | attempts += 1 99 | end 100 | 101 | exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS 102 | 103 | puts checks.current_status 104 | sleep(ATTEMPTS_GAP) 105 | checks.refresh! 106 | end 107 | rescue Octokit::NotFound 108 | exit_with_error "Build status could not be found" 109 | end 110 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /.kamal/secrets: -------------------------------------------------------------------------------- 1 | # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, 2 | # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either 3 | # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. 4 | 5 | # Option 1: Read secrets from the environment 6 | KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD 7 | KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME 8 | 9 | # Option 2: Read secrets via a command 10 | # RAILS_MASTER_KEY=$(cat config/master.key) 11 | 12 | # Option 3: Read secrets via kamal secrets helpers 13 | # These will handle logging in and fetching the secrets in as few calls as possible 14 | # There are adapters for 1Password, LastPass + Bitwarden 15 | # 16 | # SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) 17 | # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) 18 | # RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) 19 | -------------------------------------------------------------------------------- /MyApp.ServiceInterface/Data/ApplicationDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace MyApp.Data; 5 | 6 | public class ApplicationDbContext(DbContextOptions options) 7 | : IdentityDbContext(options) 8 | { 9 | } -------------------------------------------------------------------------------- /MyApp.ServiceInterface/Data/ApplicationUser.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace MyApp.Data; 4 | 5 | // Add profile data for application users by adding properties to the ApplicationUser class 6 | public class ApplicationUser : IdentityUser 7 | { 8 | public string? FirstName { get; set; } 9 | public string? LastName { get; set; } 10 | public string? DisplayName { get; set; } 11 | public string? ProfileUrl { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /MyApp.ServiceInterface/Data/CustomUserSession.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.Extensions.Options; 4 | using ServiceStack; 5 | using ServiceStack.Web; 6 | 7 | namespace MyApp.Data; 8 | 9 | public class CustomUserSession : AuthUserSession 10 | { 11 | public override void PopulateFromClaims(IRequest httpReq, ClaimsPrincipal principal) 12 | { 13 | // Populate Session with data from Identity Auth Claims 14 | ProfileUrl = principal.FindFirstValue(JwtClaimTypes.Picture); 15 | } 16 | } 17 | 18 | /// 19 | /// Add additional claims to the Identity Auth Cookie 20 | /// 21 | public class AdditionalUserClaimsPrincipalFactory( 22 | UserManager userManager, 23 | RoleManager roleManager, 24 | IOptions optionsAccessor) 25 | : UserClaimsPrincipalFactory(userManager, roleManager, optionsAccessor) 26 | { 27 | public override async Task CreateAsync(ApplicationUser user) 28 | { 29 | var principal = await base.CreateAsync(user); 30 | var identity = (ClaimsIdentity)principal.Identity!; 31 | 32 | var claims = new List(); 33 | // Add additional claims here 34 | if (user.ProfileUrl != null) 35 | { 36 | claims.Add(new Claim(JwtClaimTypes.Picture, user.ProfileUrl)); 37 | } 38 | 39 | identity.AddClaims(claims); 40 | return principal; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /MyApp.ServiceInterface/EmailServices.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Mail; 2 | using Microsoft.Extensions.Logging; 3 | using ServiceStack; 4 | using ServiceStack.Jobs; 5 | 6 | namespace MyApp.ServiceInterface; 7 | 8 | /// 9 | /// Configuration for sending emails using SMTP servers in EmailServices 10 | /// E.g. for managed services like Amazon (SES): https://aws.amazon.com/ses/ or https://mailtrap.io 11 | /// 12 | public class SmtpConfig 13 | { 14 | /// 15 | /// Username of the SMTP Server account 16 | /// 17 | public string Username { get; set; } 18 | /// 19 | /// Password of the SMTP Server account 20 | /// 21 | public string Password { get; set; } 22 | /// 23 | /// Hostname of the SMTP Server 24 | /// 25 | public string Host { get; set; } 26 | /// 27 | /// Port of the SMTP Server 28 | /// 29 | public int Port { get; set; } = 587; 30 | /// 31 | /// Which email address to send emails from 32 | /// 33 | public string FromEmail { get; set; } 34 | /// 35 | /// The name of the Email Sender 36 | /// 37 | public string? FromName { get; set; } 38 | /// 39 | /// Prevent emails from being sent to real users during development by sending to this Dev email instead 40 | /// 41 | public string? DevToEmail { get; set; } 42 | /// 43 | /// Keep a copy of all emails sent by BCC'ing a copy to this email address 44 | /// 45 | public string? Bcc { get; set; } 46 | } 47 | 48 | public class SendEmail 49 | { 50 | public string To { get; set; } 51 | public string? ToName { get; set; } 52 | public string Subject { get; set; } 53 | public string? BodyText { get; set; } 54 | public string? BodyHtml { get; set; } 55 | } 56 | 57 | [Worker("smtp")] 58 | public class SendEmailCommand(ILogger logger, IBackgroundJobs jobs, SmtpConfig config) 59 | : SyncCommand 60 | { 61 | private static long count = 0; 62 | protected override void Run(SendEmail request) 63 | { 64 | Interlocked.Increment(ref count); 65 | var log = Request.CreateJobLogger(jobs, logger); 66 | log.LogInformation("Sending {Count} email to {Email} with subject {Subject}", 67 | count, request.To, request.Subject); 68 | 69 | using var client = new SmtpClient(config.Host, config.Port); 70 | client.Credentials = new System.Net.NetworkCredential(config.Username, config.Password); 71 | client.EnableSsl = true; 72 | 73 | // If DevToEmail is set, send all emails to that address instead 74 | var emailTo = config.DevToEmail != null 75 | ? new MailAddress(config.DevToEmail) 76 | : new MailAddress(request.To, request.ToName); 77 | 78 | var emailFrom = new MailAddress(config.FromEmail, config.FromName); 79 | 80 | var msg = new MailMessage(emailFrom, emailTo) 81 | { 82 | Subject = request.Subject, 83 | Body = request.BodyHtml ?? request.BodyText, 84 | IsBodyHtml = request.BodyHtml != null, 85 | }; 86 | 87 | if (config.Bcc != null) 88 | { 89 | msg.Bcc.Add(new MailAddress(config.Bcc)); 90 | } 91 | 92 | client.Send(msg); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /MyApp.ServiceInterface/MyApp.ServiceInterface.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /MyApp.ServiceInterface/MyServices.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack; 2 | using MyApp.ServiceModel; 3 | 4 | namespace MyApp.ServiceInterface; 5 | 6 | public class MyServices : Service 7 | { 8 | public object Any(Hello request) 9 | { 10 | return new HelloResponse { Result = $"Hello, {request.Name}!" }; 11 | } 12 | } -------------------------------------------------------------------------------- /MyApp.ServiceInterface/TodosServices.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack; 2 | using MyApp.ServiceModel; 3 | 4 | namespace MyApp.ServiceInterface; 5 | 6 | public class TodosServices(IAutoQueryData autoQuery) : Service 7 | { 8 | static readonly PocoDataSource Todos = PocoDataSource.Create(new Todo[] 9 | { 10 | new () { Id = 1, Text = "Learn" }, 11 | new () { Id = 2, Text = "Blazor", IsFinished = true }, 12 | }, nextId: x => x.Select(e => e.Id).Max()); 13 | 14 | public object Get(QueryTodos query) 15 | { 16 | var db = Todos.ToDataSource(query, Request); 17 | return autoQuery.Execute(query, autoQuery.CreateQuery(query, Request, db), db); 18 | } 19 | 20 | public Todo Post(CreateTodo request) 21 | { 22 | var newTodo = new Todo { Id = Todos.NextId(), Text = request.Text }; 23 | Todos.Add(newTodo); 24 | return newTodo; 25 | } 26 | 27 | public Todo Put(UpdateTodo request) 28 | { 29 | var todo = request.ConvertTo(); 30 | Todos.TryUpdateById(todo, todo.Id); 31 | return todo; 32 | } 33 | 34 | public void Delete(DeleteTodos request) => Todos.TryDeleteByIds(request.Ids); 35 | } 36 | -------------------------------------------------------------------------------- /MyApp.ServiceModel/Emails.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack; 2 | using ServiceStack.DataAnnotations; 3 | 4 | namespace MyApp.ServiceModel; 5 | 6 | [ExcludeMetadata] 7 | [Restrict(InternalOnly = true)] 8 | public class SendEmail : IReturn 9 | { 10 | public string To { get; set; } 11 | public string? ToName { get; set; } 12 | public string Subject { get; set; } 13 | public string? BodyText { get; set; } 14 | public string? BodyHtml { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /MyApp.ServiceModel/Hello.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack; 2 | 3 | namespace MyApp.ServiceModel; 4 | 5 | [Route("/hello/{Name}")] 6 | public class Hello : IGet, IReturn 7 | { 8 | public required string Name { get; set; } 9 | } 10 | 11 | public class HelloResponse 12 | { 13 | public required string Result { get; set; } 14 | } 15 | -------------------------------------------------------------------------------- /MyApp.ServiceModel/MyApp.ServiceModel.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /MyApp.ServiceModel/Roles.cs: -------------------------------------------------------------------------------- 1 | namespace MyApp.ServiceModel; 2 | 3 | public class Roles 4 | { 5 | public const string Admin = nameof(Admin); 6 | public const string Manager = nameof(Manager); 7 | public const string Employee = nameof(Employee); 8 | } 9 | -------------------------------------------------------------------------------- /MyApp.ServiceModel/Todos.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack; 2 | using ServiceStack.Model; 3 | 4 | namespace MyApp.ServiceModel; 5 | 6 | [Tag("todos")] 7 | [Route("/todos", "GET")] 8 | public class QueryTodos : QueryData 9 | { 10 | public int? Id { get; set; } 11 | public List? Ids { get; set; } 12 | public string? TextContains { get; set; } 13 | } 14 | 15 | [Tag("todos")] 16 | [Route("/todos", "POST")] 17 | public class CreateTodo : IPost, IReturn 18 | { 19 | [ValidateNotEmpty] 20 | public string Text { get; set; } 21 | } 22 | 23 | [Tag("todos")] 24 | [Route("/todos/{Id}", "PUT")] 25 | public class UpdateTodo : IPut, IReturn 26 | { 27 | public long Id { get; set; } 28 | [ValidateNotEmpty] 29 | public string Text { get; set; } 30 | public bool IsFinished { get; set; } 31 | } 32 | 33 | [Tag("todos")] 34 | [Route("/todos", "DELETE")] 35 | public class DeleteTodos : IDelete, IReturnVoid 36 | { 37 | public List Ids { get; set; } 38 | } 39 | 40 | public class Todo : IHasId 41 | { 42 | public long Id { get; set; } 43 | public string Text { get; set; } 44 | public bool IsFinished { get; set; } 45 | } 46 | -------------------------------------------------------------------------------- /MyApp.ServiceModel/User.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack; 2 | using ServiceStack.DataAnnotations; 3 | 4 | namespace MyApp.ServiceModel; 5 | 6 | /// 7 | /// Public User DTO 8 | /// 9 | [Icon(Svg = "")] 10 | [Alias("AspNetUsers")] 11 | public class User 12 | { 13 | public string Id { get; set; } 14 | public string UserName { get; set; } 15 | public string? FirstName { get; set; } 16 | public string? LastName { get; set; } 17 | public string? DisplayName { get; set; } 18 | public string? ProfileUrl { get; set; } 19 | } 20 | 21 | [ValidateIsAdmin] 22 | public class QueryUsers : QueryDb 23 | { 24 | public string? Id { get; set; } 25 | } 26 | -------------------------------------------------------------------------------- /MyApp.ServiceModel/types/README.md: -------------------------------------------------------------------------------- 1 | As part of our [Physical Project Structure](https://docs.servicestack.net/physical-project-structure) convention 2 | we recommend maintaining any shared non Request/Response DTOs in the `ServiceModel.Types` namespace. -------------------------------------------------------------------------------- /MyApp.Tests/IntegrationTest.cs: -------------------------------------------------------------------------------- 1 | using Funq; 2 | using ServiceStack; 3 | using NUnit.Framework; 4 | using MyApp.ServiceInterface; 5 | using MyApp.ServiceModel; 6 | 7 | namespace MyApp.Tests; 8 | 9 | public class IntegrationTest 10 | { 11 | const string BaseUri = "http://localhost:2000/"; 12 | private readonly ServiceStackHost appHost; 13 | 14 | class AppHost : AppSelfHostBase 15 | { 16 | public AppHost() : base(nameof(IntegrationTest), typeof(MyServices).Assembly) { } 17 | 18 | public override void Configure(Container container) 19 | { 20 | } 21 | } 22 | 23 | public IntegrationTest() 24 | { 25 | appHost = new AppHost() 26 | .Init() 27 | .Start(BaseUri); 28 | } 29 | 30 | [OneTimeTearDown] 31 | public void OneTimeTearDown() => appHost.Dispose(); 32 | 33 | public IServiceClient CreateClient() => new JsonServiceClient(BaseUri); 34 | 35 | [Test] 36 | public void Can_call_Hello_Service() 37 | { 38 | var client = CreateClient(); 39 | 40 | var response = client.Get(new Hello { Name = "World" }); 41 | 42 | Assert.That(response.Result, Is.EqualTo("Hello, World!")); 43 | } 44 | } -------------------------------------------------------------------------------- /MyApp.Tests/MigrationTasks.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using ServiceStack.OrmLite; 3 | using MyApp.Migrations; 4 | using ServiceStack; 5 | using ServiceStack.Data; 6 | 7 | namespace MyApp.Tests; 8 | 9 | [TestFixture, Explicit, Category(nameof(MigrationTasks))] 10 | public class MigrationTasks 11 | { 12 | IDbConnectionFactory ResolveDbFactory() => new ConfigureDb().ConfigureAndResolve(); 13 | Migrator CreateMigrator() => new(ResolveDbFactory(), typeof(Migration1000).Assembly); 14 | 15 | [Test] 16 | public void Migrate() 17 | { 18 | var migrator = CreateMigrator(); 19 | var result = migrator.Run(); 20 | Assert.That(result.Succeeded); 21 | } 22 | 23 | [Test] 24 | public void Revert_All() 25 | { 26 | var migrator = CreateMigrator(); 27 | var result = migrator.Revert(Migrator.All); 28 | Assert.That(result.Succeeded); 29 | } 30 | 31 | [Test] 32 | public void Revert_Last() 33 | { 34 | var migrator = CreateMigrator(); 35 | var result = migrator.Revert(Migrator.Last); 36 | Assert.That(result.Succeeded); 37 | } 38 | 39 | [Test] 40 | public void Rerun_Last_Migration() 41 | { 42 | Revert_Last(); 43 | Migrate(); 44 | } 45 | } -------------------------------------------------------------------------------- /MyApp.Tests/MyApp.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | portable 8 | Library 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /MyApp.Tests/UnitTest.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using ServiceStack; 3 | using ServiceStack.Testing; 4 | using MyApp.ServiceInterface; 5 | using MyApp.ServiceModel; 6 | 7 | namespace MyApp.Tests; 8 | 9 | public class UnitTest 10 | { 11 | private readonly ServiceStackHost appHost; 12 | 13 | public UnitTest() 14 | { 15 | appHost = new BasicAppHost().Init(); 16 | appHost.Container.AddTransient(); 17 | } 18 | 19 | [OneTimeTearDown] 20 | public void OneTimeTearDown() => appHost.Dispose(); 21 | 22 | [Test] 23 | public void Can_call_MyServices() 24 | { 25 | var service = appHost.Container.Resolve(); 26 | 27 | var response = (HelloResponse)service.Any(new Hello { Name = "World" }); 28 | 29 | Assert.That(response.Result, Is.EqualTo("Hello, World!")); 30 | } 31 | } -------------------------------------------------------------------------------- /MyApp.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.8.34205.153 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyApp", "MyApp\MyApp.csproj", "{BE57A405-8C57-4A63-8445-890EFDFA36A0}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyApp.ServiceModel", "MyApp.ServiceModel\MyApp.ServiceModel.csproj", "{5BB51282-00F7-4DD0-8C78-4BFA39751109}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyApp.ServiceInterface", "MyApp.ServiceInterface\MyApp.ServiceInterface.csproj", "{73AEC747-0622-4760-8D53-D61A42B74B87}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyApp.Tests", "MyApp.Tests\MyApp.Tests.csproj", "{809BBACF-CA46-482D-8206-AC05F293AE55}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {BE57A405-8C57-4A63-8445-890EFDFA36A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {BE57A405-8C57-4A63-8445-890EFDFA36A0}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {BE57A405-8C57-4A63-8445-890EFDFA36A0}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {BE57A405-8C57-4A63-8445-890EFDFA36A0}.Release|Any CPU.Build.0 = Release|Any CPU 24 | {5BB51282-00F7-4DD0-8C78-4BFA39751109}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {5BB51282-00F7-4DD0-8C78-4BFA39751109}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {5BB51282-00F7-4DD0-8C78-4BFA39751109}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {5BB51282-00F7-4DD0-8C78-4BFA39751109}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {73AEC747-0622-4760-8D53-D61A42B74B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {73AEC747-0622-4760-8D53-D61A42B74B87}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {73AEC747-0622-4760-8D53-D61A42B74B87}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {73AEC747-0622-4760-8D53-D61A42B74B87}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {809BBACF-CA46-482D-8206-AC05F293AE55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {809BBACF-CA46-482D-8206-AC05F293AE55}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {809BBACF-CA46-482D-8206-AC05F293AE55}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {809BBACF-CA46-482D-8206-AC05F293AE55}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {EFF66C5D-93D3-497A-BFD4-AFBAD9695AB2} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /MyApp/AppComponentBase.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack; 2 | using ServiceStack.Blazor; 3 | 4 | namespace MyApp; 5 | 6 | /// 7 | /// For Pages and Components that make use of ServiceStack functionality, e.g. Client 8 | /// 9 | public abstract class AppComponentBase : ServiceStack.Blazor.BlazorComponentBase, IHasJsonApiClient 10 | { 11 | } 12 | 13 | /// 14 | /// For Pages and Components requiring Authentication 15 | /// 16 | public abstract class AppAuthComponentBase : AuthBlazorComponentBase 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /MyApp/App_Data/README.md: -------------------------------------------------------------------------------- 1 | ## App Writable Folder 2 | 3 | This directory is designated for: 4 | 5 | - **Embedded Databases**: Such as SQLite. 6 | - **Writable Files**: Files that the application might need to modify during its operation. 7 | 8 | For applications running in **Docker**, it's a common practice to mount this directory as an external volume. This ensures: 9 | 10 | - **Data Persistence**: App data is preserved across deployments. 11 | - **Easy Replication**: Facilitates seamless data replication for backup or migration purposes. 12 | -------------------------------------------------------------------------------- /MyApp/Components/Account/IdentityNoOpEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.AspNetCore.Identity.UI.Services; 3 | using MyApp.Data; 4 | 5 | namespace MyApp.Components.Account; 6 | 7 | // Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. 8 | internal sealed class IdentityNoOpEmailSender : IEmailSender 9 | { 10 | private readonly IEmailSender emailSender = new NoOpEmailSender(); 11 | 12 | public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => 13 | emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); 14 | 15 | public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => 16 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); 17 | 18 | public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => 19 | emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); 20 | } 21 | -------------------------------------------------------------------------------- /MyApp/Components/Account/IdentityRedirectManager.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace MyApp.Components.Account; 5 | 6 | internal sealed class IdentityRedirectManager(NavigationManager navigationManager) 7 | { 8 | public const string StatusCookieName = "Identity.StatusMessage"; 9 | 10 | private static readonly CookieBuilder StatusCookieBuilder = new() 11 | { 12 | SameSite = SameSiteMode.Strict, 13 | HttpOnly = true, 14 | IsEssential = true, 15 | MaxAge = TimeSpan.FromSeconds(5), 16 | }; 17 | 18 | [DoesNotReturn] 19 | public void RedirectTo(string? uri) 20 | { 21 | uri ??= ""; 22 | 23 | // Prevent open redirects. 24 | if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) 25 | { 26 | uri = navigationManager.ToBaseRelativePath(uri); 27 | } 28 | 29 | // During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect. 30 | // So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown. 31 | navigationManager.NavigateTo(uri); 32 | throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering."); 33 | } 34 | 35 | [DoesNotReturn] 36 | public void RedirectTo(string uri, Dictionary queryParameters) 37 | { 38 | var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); 39 | var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); 40 | RedirectTo(newUri); 41 | } 42 | 43 | [DoesNotReturn] 44 | public void RedirectToWithStatus(string uri, string message, HttpContext context) 45 | { 46 | context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context)); 47 | RedirectTo(uri); 48 | } 49 | 50 | private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path); 51 | 52 | [DoesNotReturn] 53 | public void RedirectToCurrentPage() => RedirectTo(CurrentPath); 54 | 55 | [DoesNotReturn] 56 | public void RedirectToCurrentPageWithStatus(string message, HttpContext context) 57 | => RedirectToWithStatus(CurrentPath, message, context); 58 | } 59 | -------------------------------------------------------------------------------- /MyApp/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.Authorization; 2 | using Microsoft.AspNetCore.Components.Server; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.Extensions.Options; 5 | using MyApp.Data; 6 | using System.Security.Claims; 7 | 8 | namespace MyApp.Components.Account; 9 | 10 | // This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user 11 | // every 30 minutes an interactive circuit is connected. 12 | internal sealed class IdentityRevalidatingAuthenticationStateProvider( 13 | ILoggerFactory loggerFactory, 14 | IServiceScopeFactory scopeFactory, 15 | IOptions options) 16 | : RevalidatingServerAuthenticationStateProvider(loggerFactory) 17 | { 18 | protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); 19 | 20 | protected override async Task ValidateAuthenticationStateAsync( 21 | AuthenticationState authenticationState, CancellationToken cancellationToken) 22 | { 23 | // Get the user manager from a new scope to ensure it fetches fresh data 24 | await using var scope = scopeFactory.CreateAsyncScope(); 25 | var userManager = scope.ServiceProvider.GetRequiredService>(); 26 | return await ValidateSecurityStampAsync(userManager, authenticationState.User); 27 | } 28 | 29 | private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) 30 | { 31 | var user = await userManager.GetUserAsync(principal); 32 | if (user is null) 33 | { 34 | return false; 35 | } 36 | else if (!userManager.SupportsUserSecurityStamp) 37 | { 38 | return true; 39 | } 40 | else 41 | { 42 | var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); 43 | var userStamp = await userManager.GetSecurityStampAsync(user); 44 | return principalStamp == userStamp; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /MyApp/Components/Account/IdentityUserAccessor.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using MyApp.Data; 3 | 4 | namespace MyApp.Components.Account; 5 | 6 | internal sealed class IdentityUserAccessor(UserManager userManager, IdentityRedirectManager redirectManager) 7 | { 8 | public async Task GetRequiredUserAsync(HttpContext context) 9 | { 10 | var user = await userManager.GetUserAsync(context.User); 11 | 12 | if (user is null) 13 | { 14 | redirectManager.RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context); 15 | } 16 | 17 | return user; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/AccessDenied.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/AccessDenied" 2 | 3 | 4 | Access Denied 5 | 6 |
7 |

Access Denied

8 | 9 |

10 | You do not have access to this resource. 11 |

12 |
13 | 14 | @code { 15 | [Parameter] public string ReturnUrl { get; set; } 16 | } -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/ConfirmEmail.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ConfirmEmail" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | @using MyApp.Data 7 | 8 | @inject UserManager UserManager 9 | @inject IdentityRedirectManager RedirectManager 10 | 11 | Confirm email 12 | 13 |
14 | Confirm email 15 | 16 | 17 |
18 | 19 | @code { 20 | private string? statusMessage; 21 | 22 | [CascadingParameter] 23 | private HttpContext HttpContext { get; set; } = default!; 24 | 25 | [SupplyParameterFromQuery] 26 | private string? UserId { get; set; } 27 | 28 | [SupplyParameterFromQuery] 29 | private string? Code { get; set; } 30 | 31 | protected override async Task OnInitializedAsync() 32 | { 33 | if (UserId is null || Code is null) 34 | { 35 | RedirectManager.RedirectTo(""); 36 | } 37 | 38 | var user = await UserManager.FindByIdAsync(UserId); 39 | if (user is null) 40 | { 41 | HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; 42 | statusMessage = $"Error loading user with ID {UserId}"; 43 | } 44 | else 45 | { 46 | var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 47 | var result = await UserManager.ConfirmEmailAsync(user, code); 48 | statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email."; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/ConfirmEmailChange.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ConfirmEmailChange" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | @using MyApp.Data 7 | 8 | @inject UserManager UserManager 9 | @inject SignInManager SignInManager 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Confirm email change 13 | 14 | Confirm email change 15 | 16 |
17 | 18 |
19 | 20 | @code { 21 | private string? message; 22 | 23 | [CascadingParameter] 24 | private HttpContext HttpContext { get; set; } = default!; 25 | 26 | [SupplyParameterFromQuery] 27 | private string? UserId { get; set; } 28 | 29 | [SupplyParameterFromQuery] 30 | private string? Email { get; set; } 31 | 32 | [SupplyParameterFromQuery] 33 | private string? Code { get; set; } 34 | 35 | protected override async Task OnInitializedAsync() 36 | { 37 | if (UserId is null || Email is null || Code is null) 38 | { 39 | RedirectManager.RedirectToWithStatus( 40 | "Account/Login", "Error: Invalid email change confirmation link.", HttpContext); 41 | } 42 | 43 | var user = await UserManager.FindByIdAsync(UserId); 44 | if (user is null) 45 | { 46 | message = "Unable to find user with Id '{userId}'"; 47 | return; 48 | } 49 | 50 | var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 51 | var result = await UserManager.ChangeEmailAsync(user, Email, code); 52 | if (!result.Succeeded) 53 | { 54 | message = "Error changing email."; 55 | return; 56 | } 57 | 58 | // In our UI email and user name are one and the same, so when we update the email 59 | // we need to update the user name. 60 | var setUserNameResult = await UserManager.SetUserNameAsync(user, Email); 61 | if (!setUserNameResult.Succeeded) 62 | { 63 | message = "Error changing user name."; 64 | return; 65 | } 66 | 67 | await SignInManager.RefreshSignInAsync(user); 68 | message = "Thank you for confirming your email change."; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/ForgotPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ForgotPassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using System.Text.Encodings.Web 6 | @using Microsoft.AspNetCore.Identity 7 | @using Microsoft.AspNetCore.WebUtilities 8 | @using MyApp.Data 9 | 10 | @inject UserManager UserManager 11 | @inject IEmailSender EmailSender 12 | @inject NavigationManager NavigationManager 13 | @inject IdentityRedirectManager RedirectManager 14 | 15 | Forgot your password? 16 | 17 |
18 | Forgot your password? 19 | Enter your email. 20 | 21 | 22 | 23 | 24 |
25 |
26 | 27 |
28 | 29 |
30 | 31 |
32 |
33 | Reset password 34 |
35 |
36 |
37 |
38 | 39 | @code { 40 | [SupplyParameterFromForm] 41 | private InputModel Input { get; set; } = new(); 42 | 43 | private async Task OnValidSubmitAsync() 44 | { 45 | var user = await UserManager.FindByEmailAsync(Input.Email); 46 | if (user is null || !(await UserManager.IsEmailConfirmedAsync(user))) 47 | { 48 | // Don't reveal that the user does not exist or is not confirmed 49 | RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); 50 | } 51 | 52 | // For more information on how to enable account confirmation and password reset please 53 | // visit https://go.microsoft.com/fwlink/?LinkID=532713 54 | var code = await UserManager.GeneratePasswordResetTokenAsync(user); 55 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 56 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 57 | NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri, 58 | new Dictionary { ["code"] = code }); 59 | 60 | await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); 61 | 62 | RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); 63 | } 64 | 65 | private sealed class InputModel 66 | { 67 | [Required] 68 | [EmailAddress] 69 | public string Email { get; set; } = ""; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/ForgotPasswordConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ForgotPasswordConfirmation" 2 | 3 | Forgot password confirmation 4 | 5 |
6 | Forgot password confirmation 7 | 8 |

9 | Please check your email to reset your password. 10 |

11 |
12 | 13 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/InvalidPasswordReset.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/InvalidPasswordReset" 2 | 3 | Invalid password reset 4 | 5 |
6 | 7 | Invalid password reset 8 | 9 |

10 | The password reset link is invalid. 11 |

12 | 13 |
14 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/InvalidUser.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/InvalidUser" 2 | 3 | Invalid user 4 | 5 |
6 | 7 | Invalid user 8 | 9 |
10 | 11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Lockout.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Lockout" 2 | 3 | Locked out 4 | 5 |
6 | 7 |
8 | Locked out 9 |

This account has been locked out, please try again later.

10 |
11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/LoginWith2fa.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/LoginWith2fa" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using MyApp.Data 6 | 7 | @inject SignInManager SignInManager 8 | @inject UserManager UserManager 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject ILogger Logger 11 | 12 | Two-factor authentication 13 | 14 |
15 | Two-factor authentication 16 | 17 |

Your login is protected with an authenticator app. Enter your authenticator code below.

18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 |
36 |
37 | 38 | 41 |
42 |
43 |
44 | Log in 45 |
46 |
47 |
48 |
49 |
50 |
51 |

52 | Don't have access to your authenticator device? You can 53 | log in with a recovery code. 54 |

55 | 56 | @code { 57 | private string? message; 58 | private ApplicationUser user = default!; 59 | 60 | [SupplyParameterFromForm] 61 | private InputModel Input { get; set; } = new(); 62 | 63 | [SupplyParameterFromQuery] 64 | private string? ReturnUrl { get; set; } 65 | 66 | [SupplyParameterFromQuery] 67 | private bool RememberMe { get; set; } 68 | 69 | protected override async Task OnInitializedAsync() 70 | { 71 | // Ensure the user has gone through the username & password screen first 72 | user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? 73 | throw new InvalidOperationException("Unable to load two-factor authentication user."); 74 | } 75 | 76 | private async Task OnValidSubmitAsync() 77 | { 78 | var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); 79 | var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine); 80 | var userId = await UserManager.GetUserIdAsync(user); 81 | 82 | if (result.Succeeded) 83 | { 84 | Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); 85 | RedirectManager.RedirectTo(ReturnUrl); 86 | } 87 | else if (result.IsLockedOut) 88 | { 89 | Logger.LogWarning("User with ID '{UserId}' account locked out.", userId); 90 | RedirectManager.RedirectTo("Account/Lockout"); 91 | } 92 | else 93 | { 94 | Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId); 95 | message = "Error: Invalid authenticator code."; 96 | } 97 | } 98 | 99 | private sealed class InputModel 100 | { 101 | [Required] 102 | [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 103 | [DataType(DataType.Text)] 104 | [Display(Name = "Authenticator code")] 105 | public string? TwoFactorCode { get; set; } 106 | 107 | [Display(Name = "Remember this machine")] 108 | public bool RememberMachine { get; set; } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/LoginWithRecoveryCode.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/LoginWithRecoveryCode" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using MyApp.Data 6 | 7 | @inject SignInManager SignInManager 8 | @inject UserManager UserManager 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject ILogger Logger 11 | 12 | Recovery code verification 13 | 14 |
15 | Recovery code verification 16 | 17 | 18 |

19 | You have requested to log in with a recovery code. This login will not be remembered until you provide 20 | an authenticator app code at log in or disable 2FA and log in again. 21 |

22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 | 30 |
31 | 32 |
33 | 34 |
35 |
36 | Log in 37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | @code { 45 | private string? message; 46 | private ApplicationUser user = default!; 47 | 48 | [SupplyParameterFromForm] 49 | private InputModel Input { get; set; } = new(); 50 | 51 | [SupplyParameterFromQuery] 52 | private string? ReturnUrl { get; set; } 53 | 54 | protected override async Task OnInitializedAsync() 55 | { 56 | // Ensure the user has gone through the username & password screen first 57 | user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? 58 | throw new InvalidOperationException("Unable to load two-factor authentication user."); 59 | } 60 | 61 | private async Task OnValidSubmitAsync() 62 | { 63 | var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty); 64 | 65 | var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); 66 | 67 | var userId = await UserManager.GetUserIdAsync(user); 68 | 69 | if (result.Succeeded) 70 | { 71 | Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId); 72 | RedirectManager.RedirectTo(ReturnUrl); 73 | } 74 | else if (result.IsLockedOut) 75 | { 76 | Logger.LogWarning("User account locked out."); 77 | RedirectManager.RedirectTo("Account/Lockout"); 78 | } 79 | else 80 | { 81 | Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId); 82 | message = "Error: Invalid recovery code entered."; 83 | } 84 | } 85 | 86 | private sealed class InputModel 87 | { 88 | [Required] 89 | [DataType(DataType.Text)] 90 | [Display(Name = "Recovery Code")] 91 | public string RecoveryCode { get; set; } = ""; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Logout.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Logout" 2 | 3 | Signed out 4 | 5 |
6 |
7 | Signed out 8 |

You have successfully signed out.

9 |
10 |
-------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/ApiKeys.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/ApiKeys" 2 | 3 | Manage API Keys 4 | 5 |
6 | Manage API Keys 7 |
8 |
9 | 10 | @code { 11 | public MarkupString ToProps() => BlazorHtml.RawJson(new { 12 | info = HostContext.AppHost.GetPlugin()?.GetApiKeyInfo(), 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/ChangePassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/ChangePassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using MyApp.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | @inject ILogger Logger 12 | 13 | Change password 14 | 15 | Change password 16 | 17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 |
35 | 36 |
37 | 38 |
39 | 40 |
41 |
42 | 43 |
44 | 45 |
46 | 47 |
48 |
49 | Update password 50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 | @code { 58 | private string? message; 59 | private ApplicationUser user = default!; 60 | private bool hasPassword; 61 | 62 | [CascadingParameter] 63 | private HttpContext HttpContext { get; set; } = default!; 64 | 65 | [SupplyParameterFromForm] 66 | private InputModel Input { get; set; } = new(); 67 | 68 | protected override async Task OnInitializedAsync() 69 | { 70 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 71 | hasPassword = await UserManager.HasPasswordAsync(user); 72 | if (!hasPassword) 73 | { 74 | RedirectManager.RedirectTo("Account/Manage/SetPassword"); 75 | } 76 | } 77 | 78 | private async Task OnValidSubmitAsync() 79 | { 80 | var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword); 81 | if (!changePasswordResult.Succeeded) 82 | { 83 | message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}"; 84 | return; 85 | } 86 | 87 | await SignInManager.RefreshSignInAsync(user); 88 | Logger.LogInformation("User changed their password successfully."); 89 | 90 | RedirectManager.RedirectToCurrentPageWithStatus("Your password has been changed", HttpContext); 91 | } 92 | 93 | private sealed class InputModel 94 | { 95 | [Required] 96 | [DataType(DataType.Password)] 97 | [Display(Name = "Current password")] 98 | public string OldPassword { get; set; } = ""; 99 | 100 | [Required] 101 | [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 102 | [DataType(DataType.Password)] 103 | [Display(Name = "New password")] 104 | public string NewPassword { get; set; } = ""; 105 | 106 | [DataType(DataType.Password)] 107 | [Display(Name = "Confirm new password")] 108 | [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] 109 | public string ConfirmPassword { get; set; } = ""; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/DeletePersonalData.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/DeletePersonalData" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using MyApp.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | @inject ILogger Logger 12 | 13 | Delete Personal Data 14 | 15 | Delete Personal Data 16 | 17 |
18 | 19 | Deleting this data permanently removes your account, and cannot be recovered. 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | @if (requirePassword) 32 | { 33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 | } 41 | Delete data and close my account 42 |
43 |
44 |
45 |
46 |
47 | 48 | @code { 49 | private string? message; 50 | private ApplicationUser user = default!; 51 | private bool requirePassword; 52 | 53 | [CascadingParameter] 54 | private HttpContext HttpContext { get; set; } = default!; 55 | 56 | [SupplyParameterFromForm] 57 | private InputModel Input { get; set; } = new(); 58 | 59 | protected override async Task OnInitializedAsync() 60 | { 61 | Input ??= new(); 62 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 63 | requirePassword = await UserManager.HasPasswordAsync(user); 64 | } 65 | 66 | private async Task OnValidSubmitAsync() 67 | { 68 | if (requirePassword && !await UserManager.CheckPasswordAsync(user, Input.Password)) 69 | { 70 | message = "Error: Incorrect password."; 71 | return; 72 | } 73 | 74 | var result = await UserManager.DeleteAsync(user); 75 | if (!result.Succeeded) 76 | { 77 | throw new InvalidOperationException("Unexpected error occurred deleting user."); 78 | } 79 | 80 | await SignInManager.SignOutAsync(); 81 | 82 | var userId = await UserManager.GetUserIdAsync(user); 83 | Logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId); 84 | 85 | RedirectManager.RedirectToCurrentPage(); 86 | } 87 | 88 | private sealed class InputModel 89 | { 90 | [DataType(DataType.Password)] 91 | public string Password { get; set; } = ""; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/Disable2fa.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/Disable2fa" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | @using MyApp.Data 5 | 6 | @inject UserManager UserManager 7 | @inject IdentityUserAccessor UserAccessor 8 | @inject IdentityRedirectManager RedirectManager 9 | @inject ILogger Logger 10 | 11 | Disable two-factor authentication (2FA) 12 | 13 | Disable two-factor authentication (2FA) 14 | 15 |
16 | 17 | 18 | 19 |

20 | This action only disables 2FA. 21 |

22 |

23 | Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key 24 | used in an authenticator app you should reset your authenticator keys. 25 |

26 |
27 | 28 |
29 |
30 | 31 | Disable 2FA 32 | 33 |
34 |
35 | 36 | @code { 37 | private ApplicationUser user = default!; 38 | 39 | [CascadingParameter] 40 | private HttpContext HttpContext { get; set; } = default!; 41 | 42 | protected override async Task OnInitializedAsync() 43 | { 44 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 45 | 46 | if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user)) 47 | { 48 | throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); 49 | } 50 | } 51 | 52 | private async Task OnSubmitAsync() 53 | { 54 | var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false); 55 | if (!disable2faResult.Succeeded) 56 | { 57 | throw new InvalidOperationException("Unexpected error occurred disabling 2FA."); 58 | } 59 | 60 | var userId = await UserManager.GetUserIdAsync(user); 61 | Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId); 62 | RedirectManager.RedirectToWithStatus( 63 | "Account/Manage/TwoFactorAuthentication", 64 | "2fa has been disabled. You can reenable 2fa when you setup an authenticator app", 65 | HttpContext); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/GenerateRecoveryCodes" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | @using MyApp.Data 5 | 6 | @inject UserManager UserManager 7 | @inject IdentityUserAccessor UserAccessor 8 | @inject IdentityRedirectManager RedirectManager 9 | @inject ILogger Logger 10 | 11 | Generate two-factor authentication (2FA) recovery codes 12 | 13 |
14 | @if (recoveryCodes is not null) 15 | { 16 | 17 | } 18 | else 19 | { 20 | Generate two-factor authentication (2FA) recovery codes 21 | 22 |

23 | Put these codes in a safe place. 24 |

25 |

26 | If you lose your device and don't have the recovery codes you will lose access to your account. 27 |

28 |

29 | Generating new recovery codes does not change the keys used in authenticator apps. 30 | If you wish to change the key used in an authenticator app you should 31 | reset your authenticator keys. 32 |

33 |
34 |
35 |
36 | 37 | Generate Recovery Codes 38 | 39 |
40 | } 41 |
42 | 43 | @code { 44 | private string? message; 45 | private ApplicationUser user = default!; 46 | private IEnumerable? recoveryCodes; 47 | 48 | [CascadingParameter] 49 | private HttpContext HttpContext { get; set; } = default!; 50 | 51 | protected override async Task OnInitializedAsync() 52 | { 53 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 54 | 55 | var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user); 56 | if (!isTwoFactorEnabled) 57 | { 58 | throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled."); 59 | } 60 | } 61 | 62 | private async Task OnSubmitAsync() 63 | { 64 | var userId = await UserManager.GetUserIdAsync(user); 65 | recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); 66 | message = "You have generated new recovery codes."; 67 | 68 | Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using MyApp.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Profile 13 | 14 | Profile 15 | 16 | 17 |
18 |
19 | 21 | 22 | 23 | 24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 | 33 |
34 | 35 |
36 | 37 |
38 |
39 | Save 40 |
41 |
42 |
43 |
44 |
45 | 46 | @code { 47 | private ApplicationUser user = default!; 48 | private string? username; 49 | private string? phoneNumber; 50 | 51 | [CascadingParameter] 52 | private HttpContext HttpContext { get; set; } = default!; 53 | 54 | [SupplyParameterFromForm] 55 | private InputModel Input { get; set; } = new(); 56 | 57 | protected override async Task OnInitializedAsync() 58 | { 59 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 60 | username = await UserManager.GetUserNameAsync(user); 61 | phoneNumber = await UserManager.GetPhoneNumberAsync(user); 62 | 63 | Input.PhoneNumber ??= phoneNumber; 64 | } 65 | 66 | private async Task OnValidSubmitAsync() 67 | { 68 | if (Input.PhoneNumber != phoneNumber) 69 | { 70 | var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber); 71 | if (!setPhoneResult.Succeeded) 72 | { 73 | RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext); 74 | } 75 | } 76 | 77 | await SignInManager.RefreshSignInAsync(user); 78 | RedirectManager.RedirectToCurrentPageWithStatus("Your profile has been updated", HttpContext); 79 | } 80 | 81 | private sealed class InputModel 82 | { 83 | [Phone] 84 | [Display(Name = "Phone number")] 85 | public string? PhoneNumber { get; set; } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/ManageNavPages.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components; 2 | using Microsoft.AspNetCore.Mvc.Rendering; 3 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 4 | using ServiceStack; 5 | 6 | namespace MyApp.Components.Pages.Account.Manage; 7 | 8 | public static class ManageNavPages 9 | { 10 | const string NavItemClass = "text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-50 group flex items-center px-4 py-2 text-base font-medium rounded-md mr-2 whitespace-nowrap"; 11 | 12 | const string ActiveClass = "bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-50"; 13 | public static string Index => "Account/Manage"; 14 | public static string Email => "Account/Manage/Email"; 15 | 16 | public static string ChangePassword => "Account/Manage/ChangePassword"; 17 | 18 | public static string ExternalLogins => "Account/Manage/ExternalLogins"; 19 | 20 | public static string TwoFactorAuthentication => "Account/Manage/TwoFactorAuthentication"; 21 | public static string PersonalData => "Account/Manage/PersonalData"; 22 | 23 | public static string? IndexNavClass(NavigationManager navigationManager) => PageNavClass(navigationManager, Index); 24 | public static string? EmailNavClass(NavigationManager navigationManager) => PageNavClass(navigationManager, Email); 25 | 26 | public static string? ChangePasswordNavClass(NavigationManager navigationManager) => PageNavClass(navigationManager, ChangePassword); 27 | 28 | public static string? ExternalLoginsNavClass(NavigationManager navigationManager) => PageNavClass(navigationManager, ExternalLogins); 29 | 30 | public static string? TwoFactorAuthenticationNavClass(NavigationManager navigationManager) => PageNavClass(navigationManager, TwoFactorAuthentication); 31 | 32 | public static string? PersonalDataNavClass(NavigationManager navigationManager) => PageNavClass(navigationManager, PersonalData); 33 | public static string? PageNavClass(NavigationManager navigationManager, string page) 34 | { 35 | return navigationManager.Uri.EndsWith(page, StringComparison.OrdinalIgnoreCase) 36 | ? CssUtils.ClassNames(NavItemClass, ActiveClass) 37 | : NavItemClass; 38 | } 39 | 40 | public static string ActivePageKey => "ActivePage"; 41 | public static void AddActivePage(this ViewDataDictionary viewData, string activePage) => viewData[ActivePageKey] = activePage; 42 | } 43 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/PersonalData.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/PersonalData" 2 | 3 | @inject IdentityUserAccessor UserAccessor 4 | 5 | Personal Data 6 | 7 | Personal Data 8 | 9 |
10 | 11 | 12 |
13 |

Your account contains personal data that you have given us. This page allows you to download or delete that data.

14 |

15 | Deleting this data will permanently remove your account, and this cannot be recovered. 16 |

17 |
18 | 19 | Download 20 | 21 |

22 | Delete 23 |

24 |
25 |
26 | 27 | @code { 28 | [CascadingParameter] 29 | private HttpContext HttpContext { get; set; } = default!; 30 | 31 | protected override async Task OnInitializedAsync() 32 | { 33 | _ = await UserAccessor.GetRequiredUserAsync(HttpContext); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/ResetAuthenticator.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/ResetAuthenticator" 2 | 3 | @using Microsoft.AspNetCore.Identity 4 | @using MyApp.Data 5 | 6 | @inject UserManager UserManager 7 | @inject SignInManager SignInManager 8 | @inject IdentityUserAccessor UserAccessor 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject ILogger Logger 11 | 12 | Reset authenticator key 13 | 14 | Reset authenticator key 15 | 16 |
17 | 18 | 19 | 20 |

21 | If you reset your authenticator key your authenticator app will not work until you reconfigure it. 22 |

23 |

24 | This process disables 2FA until you verify your authenticator app. 25 | If you do not complete your authenticator app configuration you may lose access to your account. 26 |

27 |
28 | 29 |
30 |
31 | 32 | Reset authenticator key 33 | 34 |
35 |
36 | 37 | @code { 38 | [CascadingParameter] 39 | private HttpContext HttpContext { get; set; } = default!; 40 | 41 | private async Task OnSubmitAsync() 42 | { 43 | var user = await UserAccessor.GetRequiredUserAsync(HttpContext); 44 | await UserManager.SetTwoFactorEnabledAsync(user, false); 45 | await UserManager.ResetAuthenticatorKeyAsync(user); 46 | var userId = await UserManager.GetUserIdAsync(user); 47 | Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId); 48 | 49 | await SignInManager.RefreshSignInAsync(user); 50 | 51 | RedirectManager.RedirectToWithStatus( 52 | "Account/Manage/EnableAuthenticator", 53 | "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.", 54 | HttpContext); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/SetPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/SetPassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using Microsoft.AspNetCore.Identity 5 | @using MyApp.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Set password 13 | 14 | Set your password 15 | 16 |
17 | 18 | 19 |

20 | You do not have a local username/password for this site. Add a local 21 | account so you can log in without an external login. 22 |

23 | 24 |
25 |
26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 | 35 |
36 | 37 |
38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | Set password 47 |
48 |
49 |
50 |
51 |
52 |
53 | 54 | @code { 55 | private string? message; 56 | private ApplicationUser user = default!; 57 | 58 | [CascadingParameter] 59 | private HttpContext HttpContext { get; set; } = default!; 60 | 61 | [SupplyParameterFromForm] 62 | private InputModel Input { get; set; } = new(); 63 | 64 | protected override async Task OnInitializedAsync() 65 | { 66 | user = await UserAccessor.GetRequiredUserAsync(HttpContext); 67 | 68 | var hasPassword = await UserManager.HasPasswordAsync(user); 69 | if (hasPassword) 70 | { 71 | RedirectManager.RedirectTo("Account/Manage/ChangePassword"); 72 | } 73 | } 74 | 75 | private async Task OnValidSubmitAsync() 76 | { 77 | var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!); 78 | if (!addPasswordResult.Succeeded) 79 | { 80 | message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}"; 81 | return; 82 | } 83 | 84 | await SignInManager.RefreshSignInAsync(user); 85 | RedirectManager.RedirectToCurrentPageWithStatus("Your password has been set.", HttpContext); 86 | } 87 | 88 | private sealed class InputModel 89 | { 90 | [Required] 91 | [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 92 | [DataType(DataType.Password)] 93 | [Display(Name = "New password")] 94 | public string? NewPassword { get; set; } 95 | 96 | [DataType(DataType.Password)] 97 | [Display(Name = "Confirm new password")] 98 | [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] 99 | public string? ConfirmPassword { get; set; } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/TwoFactorAuthentication.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/Manage/TwoFactorAuthentication" 2 | 3 | @using Microsoft.AspNetCore.Http.Features 4 | @using Microsoft.AspNetCore.Identity 5 | @using MyApp.Data 6 | 7 | @inject UserManager UserManager 8 | @inject SignInManager SignInManager 9 | @inject IdentityUserAccessor UserAccessor 10 | @inject IdentityRedirectManager RedirectManager 11 | 12 | Two-factor authentication (2FA) 13 | 14 | Two-factor authentication (2FA) 15 | 16 |
17 | 18 | 19 | @if (canTrack) 20 | { 21 | if (is2faEnabled) 22 | { 23 | if (recoveryCodesLeft == 0) 24 | { 25 | 26 | You have no recovery codes left. 27 |

You must generate a new set of recovery codes before you can log in with a recovery code.

28 |
29 | } 30 | else if (recoveryCodesLeft == 1) 31 | { 32 | 33 | You have 1 recovery code left. 34 |

You can generate a new set of recovery codes.

35 |
36 | } 37 | else if (recoveryCodesLeft <= 3) 38 | { 39 | 40 | You have @recoveryCodesLeft recovery codes left. 41 |

You should generate a new set of recovery codes.

42 |
43 | } 44 | 45 | if (isMachineRemembered) 46 | { 47 |
48 | Forget this browser 49 |
50 | } 51 | 52 | Disable 2FA 53 | Reset recovery codes 54 | } 55 | 56 |
57 | Authenticator app 58 | @if (!hasAuthenticator) 59 | { 60 | Add authenticator app 61 | } 62 | else 63 | { 64 | Set up authenticator app 65 | Reset authenticator app 66 | } 67 |
68 | } 69 | else 70 | { 71 | 72 | Privacy and cookie policy have not been accepted. 73 |

You must accept the policy before you can enable two factor authentication.

74 |
75 | } 76 | 77 |
78 | 79 | @code { 80 | private bool canTrack; 81 | private bool hasAuthenticator; 82 | private int recoveryCodesLeft; 83 | private bool is2faEnabled; 84 | private bool isMachineRemembered; 85 | 86 | [CascadingParameter] 87 | private HttpContext HttpContext { get; set; } = default!; 88 | 89 | protected override async Task OnInitializedAsync() 90 | { 91 | var user = await UserAccessor.GetRequiredUserAsync(HttpContext); 92 | canTrack = HttpContext.Features.Get()?.CanTrack ?? true; 93 | hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null; 94 | is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user); 95 | isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user); 96 | recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user); 97 | } 98 | 99 | private async Task OnSubmitForgetBrowserAsync() 100 | { 101 | await SignInManager.ForgetTwoFactorClientAsync(); 102 | 103 | RedirectManager.RedirectToCurrentPageWithStatus( 104 | "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.", 105 | HttpContext); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/Manage/_Imports.razor: -------------------------------------------------------------------------------- 1 | @layout ManageLayout 2 | @attribute [Microsoft.AspNetCore.Authorization.Authorize] 3 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/RegisterConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/RegisterConfirmation" 2 | 3 | @using System.Text 4 | @using Microsoft.AspNetCore.Identity 5 | @using Microsoft.AspNetCore.WebUtilities 6 | @using MyApp.Data 7 | 8 | @inject UserManager UserManager 9 | @inject IEmailSender EmailSender 10 | @inject NavigationManager NavigationManager 11 | @inject IdentityRedirectManager RedirectManager 12 | 13 | Register confirmation 14 | 15 |
16 | Register confirmation 17 | 18 | 19 | 20 | @if (emailConfirmationLink is not null) 21 | { 22 |

23 | This app does not currently have a real email sender registered, see 24 | these docs for how to configure a real email sender. 25 |

26 |

27 | Normally this would be emailed: 28 | Click here to confirm your account 29 |

30 | } 31 | else 32 | { 33 |

Please check your email to confirm your account.

34 | } 35 |
36 | 37 | @code { 38 | private string? emailConfirmationLink; 39 | private string? statusMessage; 40 | 41 | [CascadingParameter] 42 | private HttpContext HttpContext { get; set; } = default!; 43 | 44 | [SupplyParameterFromQuery] 45 | private string? Email { get; set; } 46 | 47 | [SupplyParameterFromQuery] 48 | private string? ReturnUrl { get; set; } 49 | 50 | protected override async Task OnInitializedAsync() 51 | { 52 | if (Email is null) 53 | { 54 | RedirectManager.RedirectTo(""); 55 | } 56 | 57 | var user = await UserManager.FindByEmailAsync(Email); 58 | if (user is null) 59 | { 60 | HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; 61 | statusMessage = "Error finding user for unspecified email"; 62 | } 63 | else if (EmailSender is IdentityNoOpEmailSender) 64 | { 65 | // Once you add a real email sender, you should remove this code that lets you confirm the account 66 | var userId = await UserManager.GetUserIdAsync(user); 67 | var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 68 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 69 | emailConfirmationLink = NavigationManager.GetUriWithQueryParameters( 70 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, 71 | new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl }); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/ResendEmailConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResendEmailConfirmation" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using System.Text.Encodings.Web 6 | @using Microsoft.AspNetCore.Identity 7 | @using Microsoft.AspNetCore.WebUtilities 8 | @using MyApp.Data 9 | 10 | @inject UserManager UserManager 11 | @inject IEmailSender EmailSender 12 | @inject NavigationManager NavigationManager 13 | @inject IdentityRedirectManager RedirectManager 14 | 15 | Resend email confirmation 16 | 17 |
18 | Resend email confirmation 19 | Enter your email. 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | 28 |
29 | 30 |
31 | 32 |
33 |
34 | Resend 35 |
36 |
37 |
38 |
39 | 40 | @code { 41 | private string? message; 42 | 43 | [SupplyParameterFromForm] 44 | private InputModel Input { get; set; } = new(); 45 | 46 | private async Task OnValidSubmitAsync() 47 | { 48 | var user = await UserManager.FindByEmailAsync(Input.Email!); 49 | if (user is null) 50 | { 51 | message = "Verification email sent. Please check your email."; 52 | return; 53 | } 54 | 55 | var userId = await UserManager.GetUserIdAsync(user); 56 | var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 57 | code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); 58 | var callbackUrl = NavigationManager.GetUriWithQueryParameters( 59 | NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, 60 | new Dictionary { ["userId"] = userId, ["code"] = code }); 61 | await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); 62 | 63 | message = "Verification email sent. Please check your email."; 64 | } 65 | 66 | private sealed class InputModel 67 | { 68 | [Required] 69 | [EmailAddress] 70 | public string Email { get; set; } = ""; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/ResetPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResetPassword" 2 | 3 | @using System.ComponentModel.DataAnnotations 4 | @using System.Text 5 | @using Microsoft.AspNetCore.Identity 6 | @using Microsoft.AspNetCore.WebUtilities 7 | @using MyApp.Data 8 | 9 | @inject IdentityRedirectManager RedirectManager 10 | @inject UserManager UserManager 11 | 12 | Reset password 13 | 14 |
15 | Reset password 16 | Reset your password. 17 | 18 | 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 |
35 | 36 |
37 | 38 |
39 | 40 |
41 |
42 | 43 |
44 | 45 |
46 | 47 |
48 |
49 | Reset 50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 | @code { 58 | private IEnumerable? identityErrors; 59 | 60 | [SupplyParameterFromForm] 61 | private InputModel Input { get; set; } = new(); 62 | 63 | [SupplyParameterFromQuery] 64 | private string? Code { get; set; } 65 | 66 | private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; 67 | 68 | protected override void OnInitialized() 69 | { 70 | if (Code is null) 71 | { 72 | RedirectManager.RedirectTo("Account/InvalidPasswordReset"); 73 | } 74 | 75 | Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); 76 | } 77 | 78 | private async Task OnValidSubmitAsync() 79 | { 80 | var user = await UserManager.FindByEmailAsync(Input.Email); 81 | if (user is null) 82 | { 83 | // Don't reveal that the user does not exist 84 | RedirectManager.RedirectTo("Account/ResetPasswordConfirmation"); 85 | } 86 | 87 | var result = await UserManager.ResetPasswordAsync(user, Input.Code, Input.Password); 88 | if (result.Succeeded) 89 | { 90 | RedirectManager.RedirectTo("Account/ResetPasswordConfirmation"); 91 | } 92 | 93 | identityErrors = result.Errors; 94 | } 95 | 96 | private sealed class InputModel 97 | { 98 | [Required] 99 | [EmailAddress] 100 | public string Email { get; set; } = ""; 101 | 102 | [Required] 103 | [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 104 | [DataType(DataType.Password)] 105 | public string Password { get; set; } = ""; 106 | 107 | [DataType(DataType.Password)] 108 | [Display(Name = "Confirm password")] 109 | [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] 110 | public string ConfirmPassword { get; set; } = ""; 111 | 112 | [Required] 113 | public string Code { get; set; } = ""; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/ResetPasswordConfirmation.razor: -------------------------------------------------------------------------------- 1 | @page "/Account/ResetPasswordConfirmation" 2 | Reset password confirmation 3 | 4 |
5 | 6 | Reset password confirmation 7 | 8 |

9 | Your password has been reset. Please click here to log in. 10 |

11 | 12 |
13 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Pages/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using MyApp.Components.Account.Shared 2 | @layout AccountLayout 3 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Shared/AccountLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @layout MyApp.Components.Layout.MainLayout 3 | @inject NavigationManager NavigationManager 4 | 5 | @if (HttpContext is null) 6 | { 7 |

Loading...

8 | } 9 | else 10 | { 11 | @Body 12 | } 13 | 14 | @code { 15 | [CascadingParameter] 16 | private HttpContext? HttpContext { get; set; } 17 | 18 | protected override void OnParametersSet() 19 | { 20 | if (HttpContext is null) 21 | { 22 | // If this code runs, we're currently rendering in interactive mode, so there is no HttpContext. 23 | // The identity pages need to set cookies, so they require an HttpContext. To achieve this we 24 | // must transition back from interactive mode to a server-rendered page. 25 | NavigationManager.Refresh(forceReload: true); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Shared/ExternalLoginPicker.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Authentication 2 | @using Microsoft.AspNetCore.Identity 3 | @using MyApp.Data 4 | 5 | @inject SignInManager SignInManager 6 | @inject IdentityRedirectManager RedirectManager 7 | 8 | @if (externalLogins.Length == 0) 9 | { 10 |
11 |

12 | There are no external authentication services configured. 13 | See this article 14 | about setting up this ASP.NET application to support logging in via external services. 15 |

16 |
17 | } 18 | else 19 | { 20 |
21 |
22 | 23 | 24 |
25 | @foreach (var provider in externalLogins) 26 | { 27 | 28 | } 29 |
30 |
31 |
32 | } 33 | 34 | @code { 35 | private AuthenticationScheme[] externalLogins = []; 36 | 37 | [SupplyParameterFromQuery] 38 | private string? ReturnUrl { get; set; } 39 | 40 | protected override async Task OnInitializedAsync() 41 | { 42 | externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Shared/ManageLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @layout AccountLayout 3 | 4 |
5 | Manage your account 6 | Change your account settings 7 |
8 |
9 |
10 | 11 |
12 |
13 | @Body 14 |
15 |
16 |
17 | 18 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Shared/ManageNavMenu.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Identity; 2 | @using MyApp.Components.Pages.Account.Manage 3 | @using MyApp.Data; 4 | 5 | @inject NavigationManager NavigationManager 6 | @inject SignInManager SignInManager 7 | 8 |
    9 |
  • 10 | Profile 12 |
  • 13 |
  • 14 | Email 16 |
  • 17 |
  • 18 | Password 20 |
  • 21 | @if (hasExternalLogins) 22 | { 23 |
  • 24 | External logins 26 |
  • 27 | } 28 |
  • 29 | Two-factor authentication 31 |
  • 32 |
  • 33 | Personal data 35 |
  • 36 | @if (HostContext.AppHost.HasPlugin()) 37 | { 38 |
  • 39 | API Keys 41 |
  • 42 | } 43 |
44 | 45 | @code { 46 | private bool hasExternalLogins; 47 | 48 | protected override async Task OnInitializedAsync() 49 | { 50 | hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Shared/RedirectToLogin.razor: -------------------------------------------------------------------------------- 1 | @inject NavigationManager NavigationManager 2 | 3 | @code { 4 | protected override void OnInitialized() 5 | { 6 | NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Shared/ShowRecoveryCodes.razor: -------------------------------------------------------------------------------- 1 | Recovery codes 2 |
3 | 4 | 5 | 6 |

7 | Put these codes in a safe place. 8 |

9 |

10 | If you lose your device and don't have the recovery codes you will lose access to your account. 11 |

12 |
13 | 14 |
15 | @for (var row = 0; row < RecoveryCodes.Length; row += 2) 16 | { 17 | @RecoveryCodes[row] 18 | 19 |   20 | 21 | @RecoveryCodes[row + 1] 22 | 23 |
24 | } 25 |
26 |
27 | 28 | @code { 29 | [Parameter] 30 | public string[] RecoveryCodes { get; set; } = default!; 31 | 32 | [Parameter] 33 | public string StatusMessage { get; set; } = default!; 34 | } 35 | -------------------------------------------------------------------------------- /MyApp/Components/Account/Shared/StatusMessage.razor: -------------------------------------------------------------------------------- 1 | @if (!string.IsNullOrEmpty(DisplayMessage)) 2 | { 3 |
4 | @if (DisplayMessage.StartsWith("Error")) 5 | { 6 |
7 |
8 |
9 | 12 |
13 |
14 |

@DisplayMessage

15 |
16 |
17 |
18 | } 19 | else 20 | { 21 |
22 |
23 |
24 | 27 |
28 |
29 |

@DisplayMessage

30 |
31 |
32 |
33 | 39 |
40 |
41 |
42 |
43 | } 44 |
45 | } 46 | 47 | @code { 48 | private string? messageFromCookie; 49 | 50 | [Parameter] 51 | public string? @class { get; set; } 52 | 53 | [Parameter] 54 | public string? Message { get; set; } 55 | 56 | [CascadingParameter] 57 | private HttpContext HttpContext { get; set; } = default!; 58 | 59 | private string? DisplayMessage => Message ?? messageFromCookie; 60 | 61 | protected override void OnInitialized() 62 | { 63 | messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName]; 64 | 65 | if (messageFromCookie is not null) 66 | { 67 | HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /MyApp/Components/App.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | @BlazorHtml.ImportMap(new() 14 | { 15 | ["app.mjs"] = ("/mjs/app.mjs", "/mjs/app.mjs"), 16 | ["@servicestack/client"] = ("/lib/mjs/servicestack-client.mjs", "/lib/mjs/servicestack-client.min.mjs"), 17 | ["vue"] = ("/js/vue.mjs", "/js/vue.min.mjs"), 18 | ["@servicestack/vue"] = ("/js/servicestack-vue.mjs", "/js/servicestack-vue.min.mjs"), 19 | }) 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /MyApp/Components/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @inject NavigationManager NavigationManager; 3 | 4 |
5 |
6 | 7 | 8 | 9 |
10 |
11 | 18 |
19 |
20 |
21 |
22 | @Body 23 |
24 |
25 |
26 |
27 | 28 |
29 | 30 | 34 | 35 | 69 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Admin/Bookings.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/bookings" 2 | @attribute [Authorize(Roles = "Admin")] 3 | @rendermode RenderMode.InteractiveServer 4 | 5 | 6 | 7 | Bookings 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Admin/Coupons.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/coupons" 2 | @attribute [Authorize(Roles = "Admin")] 3 | @rendermode RenderMode.InteractiveServer 4 | 5 | 6 | 7 | Coupons 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Admin/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/admin" 2 | @attribute [Authorize(Roles = "Admin")] 3 | 4 |
5 |
6 |
7 | Blazor MyApp 8 |
9 |
10 |
11 |

12 | MyApp Admin 13 |

14 |

15 | Manage App Database 16 |

17 |
18 |
19 | 20 | 21 | 22 | Create and Manage Bookings 23 | 24 | 25 | Create and Manage Coupons 26 | 27 | 28 | 29 |

30 | View all Database Tables 31 |

32 |
33 |
34 |
35 |
-------------------------------------------------------------------------------- /MyApp/Components/Pages/Counter.razor: -------------------------------------------------------------------------------- 1 | @page "/counter" 2 | @rendermode RenderMode.InteractiveServer 3 | 4 | Counter 5 | 6 | Counter 7 | 8 |

Current count: @currentCount

9 | 10 | Click me 11 | 12 | @code { 13 | private int currentCount = 0; 14 | 15 | private void IncrementCount() 16 | { 17 | currentCount++; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Docs.razor: -------------------------------------------------------------------------------- 1 | @page "/{Slug:regex(^[a-z-_]+$)}" 2 | @inherits AppComponentBase 3 | @inject MarkdownPages Markdown 4 | 5 | @if (doc != null) 6 | { 7 | @doc.Title 8 | 9 |
10 |
11 |

12 | @doc.Title 13 |

14 |
15 |
16 | @BlazorHtml.Raw(doc.Preview) 17 |
18 |
19 | } 20 | else 21 | { 22 |
23 | @if (error != null) 24 | { 25 | 26 | } 27 | else 28 | { 29 | 30 | } 31 |
32 | } 33 | 34 | @code { 35 | [Parameter] 36 | public required string Slug { get; set; } 37 | 38 | MarkdownFileInfo? doc; 39 | ResponseStatus? error; 40 | 41 | void load() 42 | { 43 | doc = Markdown.GetBySlug(Slug); 44 | if (doc == null) 45 | { 46 | error = new() { Message = $"_pages/{Slug}.md was not found" }; 47 | return; 48 | } 49 | } 50 | 51 | protected override void OnInitialized() => load(); 52 | 53 | protected override void OnParametersSet() => load(); 54 | } 55 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Error.razor: -------------------------------------------------------------------------------- 1 | @page "/Error" 2 | @using System.Diagnostics 3 | 4 | Error 5 | 6 |
7 |
8 |

9 | Error. 10 |

11 | An error occurred while processing your request. 12 |
13 |
14 | @if (ShowRequestId) 15 | { 16 |

17 | Request ID: @RequestId 18 |

19 | } 20 | 21 |

Development Mode

22 |

23 | Swapping to Development environment will display more detailed information about the error that occurred. 24 |

25 |

26 | The Development environment shouldn't be enabled for deployed applications. 27 | It can result in displaying sensitive information from exceptions to end users. 28 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 29 | and restarting the app. 30 |

31 |
32 |
33 | 34 | @code { 35 | [CascadingParameter] public HttpContext? HttpContext { get; set; } 36 | 37 | public string? RequestId { get; set; } 38 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 39 | 40 | protected override void OnInitialized() => 41 | RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; 42 | } 43 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Home.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | 3 | Home 4 | 5 |
6 |
7 |

8 | Welcome to 9 | ServiceStack.Blazor 10 |

11 |

12 | Welcome to your new Blazor App, checkout links below to get started: 13 |

14 |
15 | 20 | 25 |
26 |
27 |
28 | 29 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Profile.razor: -------------------------------------------------------------------------------- 1 | @page "/profile" 2 | @attribute [Authorize] 3 | @inherits AppAuthComponentBase 4 | 5 | @inject NavigationManager NavigationManager 6 | 7 |
8 |

Profile Overview

9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |

Welcome back,

17 |

@User.GetDisplayName()

18 | @if (User.GetRoles().Length > 0) 19 | { 20 |
21 | @foreach (var role in User.GetRoles()) 22 | { 23 | 25 | @role 26 | 27 | } 28 |
29 | } 30 | @if (User.GetPermissions().Length > 0) 31 | { 32 |
33 | @foreach (var perm in User.GetPermissions()) 34 | { 35 | 37 | @perm 38 | 39 | } 40 |
41 | } 42 |
43 |
44 |
45 |
46 | 47 | 48 | 49 | Sign Out 50 | 51 | 52 |
53 |
54 |
55 |
56 | 57 | @code { 58 | private string? currentUrl; 59 | 60 | protected override void OnInitialized() 61 | { 62 | currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Secure/Bookings.razor: -------------------------------------------------------------------------------- 1 | @page "/secure/bookings" 2 | @attribute [Authorize(Roles = "Employee")] 3 | @rendermode RenderMode.InteractiveServer 4 | @inject IJSRuntime JS 5 | 6 | Bookings 7 | 8 | Bookings 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | Type 22 |
23 |
24 | 25 | 26 |
27 | No 28 |
29 |
30 | 31 | 32 | 33 | 34 |
35 | Start 36 |
37 |
38 | 39 | 40 |
41 | End 42 |
43 | 46 |
47 | 48 | 49 | 57 | 58 |
59 |
60 | 61 | @code { 62 | string FormatDate(object o) => o is DateTime d ? d.ToShortDateString() : ""; 63 | 64 | // Handle when table header is selected 65 | public async Task OnSelectedHeader(Column item) 66 | { 67 | await JS.Log(item.Name); 68 | } 69 | 70 | // Handle when table row is selected 71 | public async Task OnSelectedRow(Booking? x) 72 | { 73 | var wasDeselected = x == null; 74 | if (!wasDeselected) await JS.Log($"{x!.Name}"); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Secure/Coupons.razor: -------------------------------------------------------------------------------- 1 | @page "/secure/coupons" 2 | @attribute [Authorize(Roles = "Employee")] 3 | @rendermode RenderMode.InteractiveServer 4 | 5 | Coupons 6 | 7 | Coupons 8 | 9 | 10 | -------------------------------------------------------------------------------- /MyApp/Components/Pages/Weather.razor: -------------------------------------------------------------------------------- 1 | @page "/weather" 2 | @attribute [StreamRendering(true)] 3 | 4 | Weather 5 | 6 | Weather 7 | 8 |

This component demonstrates showing data.

9 | 10 | @if (forecasts == null) 11 | { 12 | 13 | } 14 | else 15 | { 16 | 17 | 18 | 19 | 20 | 21 | 22 | } 23 | 24 | @code { 25 | private WeatherForecast[]? forecasts; 26 | 27 | protected override async Task OnInitializedAsync() 28 | { 29 | // Simulate asynchronous loading to demonstrate streaming rendering 30 | await Task.Delay(500); 31 | 32 | var startDate = DateOnly.FromDateTime(DateTime.Now); 33 | var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; 34 | forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast 35 | { 36 | Date = startDate.AddDays(index), 37 | TemperatureC = Random.Shared.Next(-20, 55), 38 | Summary = summaries[Random.Shared.Next(summaries.Length)] 39 | }).ToArray(); 40 | } 41 | 42 | public class WeatherForecast 43 | { 44 | public DateOnly Date { get; set; } 45 | public int TemperatureC { get; set; } 46 | public string? Summary { get; set; } 47 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /MyApp/Components/Routes.razor: -------------------------------------------------------------------------------- 1 | @using MyApp.Components.Account.Shared 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /MyApp/Components/Shared/FormLoading.razor: -------------------------------------------------------------------------------- 1 | @using ServiceStack 2 | @inject IJSRuntime JsRuntime 3 | @inject NavigationManager NavigationManager 4 | 5 | @if (Loading) 6 | { 7 |
8 | @if (Icon) 9 | { 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | } 28 | @Text 29 |
30 | } 31 | 32 | @code { 33 | [Parameter] 34 | public bool Loading { get; set; } 35 | 36 | [Parameter] 37 | public bool Icon { get; set; } = true; 38 | 39 | [Parameter] 40 | public string Text { get; set; } = "loading..."; 41 | 42 | [Parameter] 43 | public string? @class { get; set; } 44 | } 45 | -------------------------------------------------------------------------------- /MyApp/Components/Shared/ShellCommand.razor: -------------------------------------------------------------------------------- 1 | @inject IJSRuntime JS 2 | 3 |
4 |
5 | 6 | sh 7 |
8 | @if (SuccessText != string.Empty) 9 | { 10 |
11 |
12 |
13 | 16 |
17 |
18 |

19 | @SuccessText 20 |

21 |
22 |
23 |
24 | } 25 |
26 | 27 | @code { 28 | [Parameter] 29 | public string? @class { get; set; } 30 | 31 | [Parameter] 32 | public RenderFragment? ChildContent { get; set; } 33 | 34 | string SuccessText { get; set; } = string.Empty; 35 | 36 | private ElementReference elCmd; 37 | 38 | async Task copyCommand(MouseEventArgs e) 39 | { 40 | SuccessText = "copied"; 41 | var text = await JS.InvokeAsync("JS.invoke", elCmd, "innerText"); 42 | await JS.InvokeVoidAsync("navigator.clipboard.writeText", text); 43 | await Task.Delay(3_000); 44 | SuccessText = string.Empty; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /MyApp/Components/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Authorization 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Routing 6 | @using Microsoft.AspNetCore.Components.Web 7 | @using Microsoft.AspNetCore.Authorization 8 | @using static Microsoft.AspNetCore.Components.Web.RenderMode 9 | @using Microsoft.AspNetCore.Components.Web.Virtualization 10 | @using Microsoft.JSInterop 11 | @using ServiceStack 12 | @using ServiceStack.Web 13 | @using ServiceStack.Html 14 | @using ServiceStack.Blazor 15 | @using ServiceStack.Blazor.Components 16 | @using ServiceStack.Blazor.Components.Tailwind 17 | @using MyApp 18 | @using MyApp.Components 19 | @using MyApp.Components.Shared 20 | @using MyApp.ServiceModel 21 | -------------------------------------------------------------------------------- /MyApp/Configure.AppHost.cs: -------------------------------------------------------------------------------- 1 | [assembly: HostingStartup(typeof(MyApp.AppHost))] 2 | 3 | namespace MyApp; 4 | 5 | public class AppHost() : AppHostBase("MyApp"), IHostingStartup 6 | { 7 | public void Configure(IWebHostBuilder builder) => builder 8 | .ConfigureServices((context, services) => 9 | { 10 | // Configure ASP.NET Core IOC Dependencies 11 | }); 12 | 13 | // Configure your AppHost with the necessary configuration and dependencies your App needs 14 | public override void Configure() 15 | { 16 | SetConfig(new HostConfig { 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MyApp/Configure.Auth.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack.Auth; 2 | using MyApp.Data; 3 | 4 | [assembly: HostingStartup(typeof(MyApp.ConfigureAuth))] 5 | 6 | namespace MyApp; 7 | 8 | public class ConfigureAuth : IHostingStartup 9 | { 10 | public void Configure(IWebHostBuilder builder) => builder 11 | .ConfigureServices(services => 12 | { 13 | services.AddPlugin(new AuthFeature(IdentityAuth.For(options => { 14 | options.SessionFactory = () => new CustomUserSession(); 15 | options.CredentialsAuth(); 16 | options.AdminUsersFeature(); 17 | }))); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /MyApp/Configure.AutoQuery.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack.Data; 2 | 3 | [assembly: HostingStartup(typeof(MyApp.ConfigureAutoQuery))] 4 | 5 | namespace MyApp; 6 | 7 | public class ConfigureAutoQuery : IHostingStartup 8 | { 9 | public void Configure(IWebHostBuilder builder) => builder 10 | .ConfigureServices(services => { 11 | // Enable Audit History 12 | services.AddSingleton(c => 13 | new OrmLiteCrudEvents(c.GetRequiredService())); 14 | 15 | // For TodosService 16 | services.AddPlugin(new AutoQueryDataFeature()); 17 | 18 | // For Bookings https://docs.servicestack.net/autoquery-crud-bookings 19 | services.AddPlugin(new AutoQueryFeature { 20 | MaxLimit = 1000, 21 | //IncludeTotal = true, 22 | }); 23 | }) 24 | .ConfigureAppHost(appHost => { 25 | appHost.Resolve().InitSchema(); 26 | }); 27 | } -------------------------------------------------------------------------------- /MyApp/Configure.BackgroundJobs.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using ServiceStack.Jobs; 3 | using MyApp.Data; 4 | using MyApp.ServiceInterface; 5 | 6 | [assembly: HostingStartup(typeof(MyApp.ConfigureBackgroundJobs))] 7 | 8 | namespace MyApp; 9 | 10 | public class ConfigureBackgroundJobs : IHostingStartup 11 | { 12 | public void Configure(IWebHostBuilder builder) => builder 13 | .ConfigureServices((context,services) => { 14 | var smtpConfig = context.Configuration.GetSection(nameof(SmtpConfig))?.Get(); 15 | if (smtpConfig is not null) 16 | { 17 | services.AddSingleton(smtpConfig); 18 | } 19 | // Lazily register SendEmailCommand to allow SmtpConfig to only be required if used 20 | services.AddTransient(c => new SendEmailCommand( 21 | c.GetRequiredService>(), 22 | c.GetRequiredService(), 23 | c.GetRequiredService())); 24 | 25 | services.AddPlugin(new CommandsFeature()); 26 | services.AddPlugin(new BackgroundsJobFeature()); 27 | services.AddHostedService(); 28 | }).ConfigureAppHost(afterAppHostInit: appHost => { 29 | var services = appHost.GetApplicationServices(); 30 | 31 | // Log if EmailSender is enabled and SmtpConfig missing 32 | var log = services.GetRequiredService>(); 33 | var emailSender = services.GetRequiredService>(); 34 | if (emailSender is EmailSender) 35 | { 36 | var smtpConfig = services.GetService(); 37 | if (smtpConfig is null) 38 | { 39 | log.LogWarning("SMTP is not configured, please configure SMTP to enable sending emails"); 40 | } 41 | else 42 | { 43 | log.LogWarning("SMTP is configured with <{FromEmail}> {FromName}", smtpConfig.FromEmail, smtpConfig.FromName); 44 | } 45 | } 46 | 47 | var jobs = services.GetRequiredService(); 48 | // Example of registering a Recurring Job to run Every Hour 49 | //jobs.RecurringCommand(Schedule.Hourly); 50 | }); 51 | } 52 | 53 | public class JobsHostedService(ILogger log, IBackgroundJobs jobs) : BackgroundService 54 | { 55 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 56 | { 57 | await jobs.StartAsync(stoppingToken); 58 | 59 | using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); 60 | while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) 61 | { 62 | await jobs.TickAsync(); 63 | } 64 | } 65 | } 66 | 67 | /// 68 | /// Sends emails by executing SendEmailCommand in a background job where it's serially processed by 'smtp' worker 69 | /// 70 | public class EmailSender(IBackgroundJobs jobs) : IEmailSender 71 | { 72 | public Task SendEmailAsync(string email, string subject, string htmlMessage) 73 | { 74 | jobs.EnqueueCommand(new SendEmail { 75 | To = email, 76 | Subject = subject, 77 | BodyHtml = htmlMessage, 78 | }); 79 | return Task.CompletedTask; 80 | } 81 | 82 | public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => 83 | SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); 84 | 85 | public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => 86 | SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); 87 | 88 | public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => 89 | SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); 90 | } 91 | -------------------------------------------------------------------------------- /MyApp/Configure.Db.Migrations.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.EntityFrameworkCore; 3 | using MyApp.Data; 4 | using MyApp.Migrations; 5 | using MyApp.ServiceModel; 6 | using ServiceStack; 7 | using ServiceStack.Data; 8 | using ServiceStack.OrmLite; 9 | 10 | [assembly: HostingStartup(typeof(MyApp.ConfigureDbMigrations))] 11 | 12 | namespace MyApp; 13 | 14 | // Code-First DB Migrations: https://docs.servicestack.net/ormlite/db-migrations 15 | public class ConfigureDbMigrations : IHostingStartup 16 | { 17 | public void Configure(IWebHostBuilder builder) => builder 18 | .ConfigureAppHost(appHost => { 19 | var migrator = new Migrator(appHost.Resolve(), typeof(Migration1000).Assembly); 20 | AppTasks.Register("migrate", _ => 21 | { 22 | var log = appHost.GetApplicationServices().GetRequiredService>(); 23 | 24 | log.LogInformation("Running EF Migrations..."); 25 | var scopeFactory = appHost.GetApplicationServices().GetRequiredService(); 26 | using (var scope = scopeFactory.CreateScope()) 27 | { 28 | using var db = scope.ServiceProvider.GetRequiredService(); 29 | db.Database.EnsureCreated(); 30 | db.Database.Migrate(); 31 | 32 | // Only seed users if DB was just created 33 | if (!db.Users.Any()) 34 | { 35 | log.LogInformation("Adding Seed Users..."); 36 | AddSeedUsers(scope.ServiceProvider).Wait(); 37 | } 38 | } 39 | 40 | log.LogInformation("Running OrmLite Migrations..."); 41 | migrator.Run(); 42 | }); 43 | AppTasks.Register("migrate.revert", args => migrator.Revert(args[0])); 44 | AppTasks.Register("migrate.rerun", args => migrator.Rerun(args[0])); 45 | AppTasks.Run(); 46 | }); 47 | 48 | private async Task AddSeedUsers(IServiceProvider services) 49 | { 50 | //initializing custom roles 51 | var roleManager = services.GetRequiredService>(); 52 | var userManager = services.GetRequiredService>(); 53 | string[] allRoles = [Roles.Admin, Roles.Manager, Roles.Employee]; 54 | 55 | void assertResult(IdentityResult result) 56 | { 57 | if (!result.Succeeded) 58 | throw new Exception(result.Errors.First().Description); 59 | } 60 | 61 | async Task EnsureUserAsync(ApplicationUser user, string password, string[]? roles = null) 62 | { 63 | var existingUser = await userManager.FindByEmailAsync(user.Email!); 64 | if (existingUser != null) return; 65 | 66 | await userManager!.CreateAsync(user, password); 67 | if (roles?.Length > 0) 68 | { 69 | var newUser = await userManager.FindByEmailAsync(user.Email!); 70 | assertResult(await userManager.AddToRolesAsync(user, roles)); 71 | } 72 | } 73 | 74 | foreach (var roleName in allRoles) 75 | { 76 | var roleExist = await roleManager.RoleExistsAsync(roleName); 77 | if (!roleExist) 78 | { 79 | //Create the roles and seed them to the database 80 | assertResult(await roleManager.CreateAsync(new IdentityRole(roleName))); 81 | } 82 | } 83 | 84 | await EnsureUserAsync(new ApplicationUser 85 | { 86 | DisplayName = "Test User", 87 | Email = "test@email.com", 88 | UserName = "test@email.com", 89 | FirstName = "Test", 90 | LastName = "User", 91 | EmailConfirmed = true, 92 | ProfileUrl = "/img/profiles/user1.svg", 93 | }, "p@55wOrd"); 94 | 95 | await EnsureUserAsync(new ApplicationUser 96 | { 97 | DisplayName = "Test Employee", 98 | Email = "employee@email.com", 99 | UserName = "employee@email.com", 100 | FirstName = "Test", 101 | LastName = "Employee", 102 | EmailConfirmed = true, 103 | ProfileUrl = "/img/profiles/user2.svg", 104 | }, "p@55wOrd", [Roles.Employee]); 105 | 106 | await EnsureUserAsync(new ApplicationUser 107 | { 108 | DisplayName = "Test Manager", 109 | Email = "manager@email.com", 110 | UserName = "manager@email.com", 111 | FirstName = "Test", 112 | LastName = "Manager", 113 | EmailConfirmed = true, 114 | ProfileUrl = "/img/profiles/user3.svg", 115 | }, "p@55wOrd", [Roles.Manager, Roles.Employee]); 116 | 117 | await EnsureUserAsync(new ApplicationUser 118 | { 119 | DisplayName = "Admin User", 120 | Email = "admin@email.com", 121 | UserName = "admin@email.com", 122 | FirstName = "Admin", 123 | LastName = "User", 124 | EmailConfirmed = true, 125 | }, "p@55wOrd", allRoles); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /MyApp/Configure.Db.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using ServiceStack.Data; 3 | using ServiceStack.OrmLite; 4 | using MyApp.Data; 5 | 6 | [assembly: HostingStartup(typeof(MyApp.ConfigureDb))] 7 | 8 | namespace MyApp; 9 | 10 | public class ConfigureDb : IHostingStartup 11 | { 12 | public void Configure(IWebHostBuilder builder) => builder 13 | .ConfigureServices((context, services) => { 14 | var connectionString = context.Configuration.GetConnectionString("DefaultConnection") 15 | ?? "DataSource=App_Data/app.db;Cache=Shared"; 16 | 17 | services.AddSingleton(new OrmLiteConnectionFactory( 18 | connectionString, SqliteDialect.Provider)); 19 | 20 | // $ dotnet ef migrations add CreateIdentitySchema 21 | // $ dotnet ef database update 22 | services.AddDbContext(options => 23 | options.UseSqlite(connectionString, b => b.MigrationsAssembly(nameof(MyApp)))); 24 | 25 | // Enable built-in Database Admin UI at /admin-ui/database 26 | services.AddPlugin(new AdminDatabaseFeature()); 27 | }); 28 | } -------------------------------------------------------------------------------- /MyApp/Configure.HealthChecks.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Diagnostics.HealthChecks; 2 | 3 | [assembly: HostingStartup(typeof(MyApp.HealthChecks))] 4 | 5 | namespace MyApp; 6 | 7 | public class HealthChecks : IHostingStartup 8 | { 9 | public class HealthCheck : IHealthCheck 10 | { 11 | public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken token = default) 12 | { 13 | // Perform health check logic here 14 | return HealthCheckResult.Healthy(); 15 | } 16 | } 17 | 18 | public void Configure(IWebHostBuilder builder) 19 | { 20 | builder.ConfigureServices(services => 21 | { 22 | services.AddHealthChecks() 23 | .AddCheck("HealthCheck"); 24 | 25 | services.AddTransient(); 26 | }); 27 | } 28 | 29 | public class StartupFilter : IStartupFilter 30 | { 31 | public Action Configure(Action next) 32 | => app => { 33 | app.UseHealthChecks("/up"); 34 | next(app); 35 | }; 36 | } 37 | } -------------------------------------------------------------------------------- /MyApp/Configure.Markdown.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Rendering; 2 | using ServiceStack; 3 | using ServiceStack.IO; 4 | 5 | [assembly: HostingStartup(typeof(MyApp.ConfigureMarkdown))] 6 | 7 | namespace MyApp; 8 | 9 | public class ConfigureMarkdown : IHostingStartup 10 | { 11 | public void Configure(IWebHostBuilder builder) => builder 12 | .ConfigureServices((context, services) => 13 | { 14 | context.Configuration.GetSection(nameof(AppConfig)).Bind(AppConfig.Instance); 15 | services.AddSingleton(AppConfig.Instance); 16 | services.AddSingleton(); 17 | services.AddSingleton(); 18 | }) 19 | .ConfigureAppHost( 20 | afterPluginsLoaded: appHost => 21 | { 22 | var pages = appHost.Resolve(); 23 | var videos = appHost.Resolve(); 24 | 25 | pages.LoadFrom("_pages"); 26 | videos.LoadFrom("_videos"); 27 | AppConfig.Instance.GitPagesBaseUrl ??= ResolveGitBlobBaseUrl(appHost.ContentRootDirectory); 28 | }); 29 | 30 | private string? ResolveGitBlobBaseUrl(IVirtualDirectory contentDir) 31 | { 32 | var srcDir = new DirectoryInfo(contentDir.RealPath); 33 | var gitConfig = new FileInfo(Path.Combine(srcDir.Parent!.FullName, ".git", "config")); 34 | if (gitConfig.Exists) 35 | { 36 | var txt = gitConfig.ReadAllText(); 37 | var pos = txt.IndexOf("url = ", StringComparison.Ordinal); 38 | if (pos >= 0) 39 | { 40 | var url = txt[(pos + "url = ".Length)..].LeftPart(".git").LeftPart('\n').Trim(); 41 | var gitBaseUrl = url.CombineWith($"blob/main/{srcDir.Name}"); 42 | return gitBaseUrl; 43 | } 44 | } 45 | return null; 46 | } 47 | } 48 | 49 | public class AppConfig 50 | { 51 | public static AppConfig Instance { get; } = new(); 52 | public string LocalBaseUrl { get; set; } 53 | public string PublicBaseUrl { get; set; } 54 | public string? GitPagesBaseUrl { get; set; } 55 | } 56 | 57 | // Add additional frontmatter info to include 58 | public class MarkdownFileInfo : MarkdownFileBase 59 | { 60 | } 61 | 62 | public static class HtmlHelpers 63 | { 64 | public static string ToAbsoluteContentUrl(string? relativePath) => HostContext.DebugMode 65 | ? AppConfig.Instance.LocalBaseUrl.CombineWith(relativePath) 66 | : AppConfig.Instance.PublicBaseUrl.CombineWith(relativePath); 67 | public static string ToAbsoluteApiUrl(string? relativePath) => HostContext.DebugMode 68 | ? AppConfig.Instance.LocalBaseUrl.CombineWith(relativePath) 69 | : AppConfig.Instance.PublicBaseUrl.CombineWith(relativePath); 70 | 71 | 72 | public static string ContentUrl(this IHtmlHelper html, string? relativePath) => ToAbsoluteContentUrl(relativePath); 73 | public static string ApiUrl(this IHtmlHelper html, string? relativePath) => ToAbsoluteApiUrl(relativePath); 74 | } 75 | -------------------------------------------------------------------------------- /MyApp/Configure.OpenApi.cs: -------------------------------------------------------------------------------- 1 | [assembly: HostingStartup(typeof(MyApp.ConfigureOpenApi))] 2 | 3 | namespace MyApp; 4 | 5 | public class ConfigureOpenApi : IHostingStartup 6 | { 7 | public void Configure(IWebHostBuilder builder) => builder 8 | .ConfigureServices((context, services) => 9 | { 10 | if (context.HostingEnvironment.IsDevelopment()) 11 | { 12 | services.AddEndpointsApiExplorer(); 13 | services.AddSwaggerGen(); 14 | 15 | services.AddServiceStackSwagger(); 16 | services.AddBasicAuth(); 17 | //services.AddJwtAuth(); 18 | 19 | services.AddTransient(); 20 | } 21 | }); 22 | 23 | public class StartupFilter : IStartupFilter 24 | { 25 | public Action Configure(Action next) => app => 26 | { 27 | app.UseSwagger(); 28 | app.UseSwaggerUI(); 29 | next(app); 30 | }; 31 | } 32 | } -------------------------------------------------------------------------------- /MyApp/Configure.RequestLogs.cs: -------------------------------------------------------------------------------- 1 | using ServiceStack.Jobs; 2 | using ServiceStack.Web; 3 | 4 | [assembly: HostingStartup(typeof(MyApp.ConfigureRequestLogs))] 5 | 6 | namespace MyApp; 7 | 8 | public class ConfigureRequestLogs : IHostingStartup 9 | { 10 | public void Configure(IWebHostBuilder builder) => builder 11 | .ConfigureServices((context, services) => { 12 | 13 | services.AddPlugin(new RequestLogsFeature { 14 | RequestLogger = new SqliteRequestLogger(), 15 | // EnableResponseTracking = true, 16 | EnableRequestBodyTracking = true, 17 | EnableErrorTracking = true 18 | }); 19 | services.AddHostedService(); 20 | }); 21 | } 22 | 23 | public class RequestLogsHostedService(ILogger log, IRequestLogger requestLogger) : BackgroundService 24 | { 25 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 26 | { 27 | var dbRequestLogger = (SqliteRequestLogger)requestLogger; 28 | using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); 29 | while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) 30 | { 31 | dbRequestLogger.Tick(log); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /MyApp/Markdown.Videos.cs: -------------------------------------------------------------------------------- 1 | // BSD License: https://docs.servicestack.net/BSD-LICENSE.txt 2 | // run node postinstall.js to update to latest version 3 | using ServiceStack.IO; 4 | 5 | namespace MyApp; 6 | 7 | public class MarkdownVideos(ILogger log, IWebHostEnvironment env, IVirtualFiles fs) 8 | : MarkdownPagesBase(log, env, fs) 9 | { 10 | public override string Id => "videos"; 11 | public Dictionary> Groups { get; set; } = new(); 12 | 13 | public List GetVideos(string group) 14 | { 15 | return Groups.TryGetValue(group, out var docs) 16 | ? Fresh(docs.Where(IsVisible).OrderBy(x => x.Order).ThenBy(x => x.FileName).ToList()) 17 | : []; 18 | } 19 | 20 | public void LoadFrom(string fromDirectory) 21 | { 22 | Groups.Clear(); 23 | var dirs = VirtualFiles.GetDirectory(fromDirectory).GetDirectories().ToList(); 24 | log.LogInformation("Found {Count} video directories", dirs.Count); 25 | 26 | var pipeline = CreatePipeline(); 27 | 28 | foreach (var dir in dirs) 29 | { 30 | var group = dir.Name; 31 | 32 | foreach (var file in dir.GetFiles().OrderBy(x => x.Name)) 33 | { 34 | try 35 | { 36 | var doc = Load(file.VirtualPath, pipeline); 37 | if (doc == null) 38 | continue; 39 | 40 | doc.Group = group; 41 | var groupVideos = Groups.GetOrAdd(group, v => new List()); 42 | groupVideos.Add(doc); 43 | } 44 | catch (Exception e) 45 | { 46 | log.LogError(e, "Couldn't load {VirtualPath}: {Message}", file.VirtualPath, e.Message); 47 | } 48 | } 49 | } 50 | } 51 | 52 | public override List GetAll() 53 | { 54 | var to = new List(); 55 | foreach (var entry in Groups) 56 | { 57 | to.AddRange(entry.Value.Where(IsVisible).Map(doc => ToMetaDoc(doc, x => x.Content = StripFrontmatter(doc.Content)))); 58 | } 59 | return to; 60 | } 61 | } -------------------------------------------------------------------------------- /MyApp/Migrations/Migration1000.cs: -------------------------------------------------------------------------------- 1 | using System.Data; 2 | using ServiceStack; 3 | using ServiceStack.DataAnnotations; 4 | using ServiceStack.OrmLite; 5 | 6 | namespace MyApp.Migrations; 7 | 8 | public class Migration1000 : MigrationBase 9 | { 10 | public class Booking : AuditBase 11 | { 12 | [AutoIncrement] 13 | public int Id { get; set; } 14 | public string Name { get; set; } = default!; 15 | public RoomType RoomType { get; set; } 16 | public int RoomNumber { get; set; } 17 | public DateTime BookingStartDate { get; set; } 18 | public DateTime? BookingEndDate { get; set; } 19 | public decimal Cost { get; set; } 20 | public string? Notes { get; set; } 21 | public bool? Cancelled { get; set; } 22 | 23 | [References(typeof(Coupon))] 24 | public string? CouponId { get; set; } 25 | } 26 | 27 | public class Coupon 28 | { 29 | public string Id { get; set; } = default!; 30 | public string Description { get; set; } = default!; 31 | public int Discount { get; set; } 32 | public DateTime ExpiryDate { get; set; } 33 | } 34 | 35 | public enum RoomType 36 | { 37 | Queen, 38 | Double, 39 | Suite, 40 | } 41 | 42 | public override void Up() 43 | { 44 | Db.CreateTable(); 45 | Db.CreateTable(); 46 | 47 | new[] { 5, 10, 15, 20, 25, 30, 40, 50, 60, 70, }.Each(percent => { 48 | Db.Insert(new Coupon 49 | { 50 | Id = $"BOOK{percent}", 51 | Description = $"{percent}% off", 52 | Discount = percent, 53 | ExpiryDate = DateTime.UtcNow.AddDays(30) 54 | }); 55 | }); 56 | 57 | CreateBooking(Db, "First Booking!", RoomType.Queen, 10, 100, "BOOK10", "employee@email.com"); 58 | CreateBooking(Db, "Booking 2", RoomType.Double, 12, 120, "BOOK25", "manager@email.com"); 59 | CreateBooking(Db, "Booking the 3rd", RoomType.Suite, 13, 130, null, "employee@email.com"); 60 | } 61 | 62 | public void CreateBooking(IDbConnection? db, 63 | string name, RoomType type, int roomNo, decimal cost, string? couponId, string by) => 64 | db.Insert(new Booking 65 | { 66 | Name = name, 67 | RoomType = type, 68 | RoomNumber = roomNo, 69 | Cost = cost, 70 | BookingStartDate = DateTime.UtcNow.AddDays(roomNo), 71 | BookingEndDate = DateTime.UtcNow.AddDays(roomNo + 7), 72 | CouponId = couponId, 73 | CreatedBy = by, 74 | CreatedDate = DateTime.UtcNow, 75 | ModifiedBy = by, 76 | ModifiedDate = DateTime.UtcNow, 77 | }); 78 | 79 | public override void Down() 80 | { 81 | Db.DropTable(); 82 | Db.DropTable(); 83 | } 84 | } -------------------------------------------------------------------------------- /MyApp/MyApp.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | aspnet-MyApp-7b2ab71a-0b50-423f-969d-e35a9402b1b5 8 | true 9 | DefaultContainer 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /MyApp/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Microsoft.AspNetCore.Components.Authorization; 3 | using Microsoft.AspNetCore.DataProtection; 4 | using Microsoft.AspNetCore.Identity; 5 | using Microsoft.EntityFrameworkCore; 6 | using ServiceStack.Blazor; 7 | using MyApp.Data; 8 | using MyApp.Components; 9 | using MyApp.Components.Account; 10 | using MyApp.ServiceInterface; 11 | 12 | var builder = WebApplication.CreateBuilder(args); 13 | 14 | var services = builder.Services; 15 | var config = builder.Configuration; 16 | 17 | // Add services to the container. 18 | services.AddRazorComponents() 19 | .AddInteractiveServerComponents(); 20 | 21 | services.AddCascadingAuthenticationState(); 22 | services.AddScoped(); 23 | services.AddScoped(); 24 | services.AddScoped(); 25 | 26 | services.AddAuthentication(options => 27 | { 28 | options.DefaultScheme = IdentityConstants.ApplicationScheme; 29 | options.DefaultSignInScheme = IdentityConstants.ExternalScheme; 30 | }) 31 | .AddIdentityCookies(options => options.DisableRedirectsForApis()); 32 | services.AddDataProtection() 33 | .PersistKeysToFileSystem(new DirectoryInfo("App_Data")); 34 | 35 | services.AddDatabaseDeveloperPageExceptionFilter(); 36 | 37 | services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) 38 | .AddRoles() 39 | .AddEntityFrameworkStores() 40 | .AddSignInManager() 41 | .AddDefaultTokenProviders(); 42 | 43 | services.AddSingleton, IdentityNoOpEmailSender>(); 44 | // Uncomment to send emails with SMTP, configure SMTP with "SmtpConfig" in appsettings.json 45 | // services.AddSingleton, EmailSender>(); 46 | // services.AddScoped, AdditionalUserClaimsPrincipalFactory>(); 47 | services.AddScoped, AdditionalUserClaimsPrincipalFactory>(); 48 | 49 | var baseUrl = builder.Configuration["ApiBaseUrl"] ?? 50 | (builder.Environment.IsDevelopment() ? "https://localhost:5001" : "http://" + IPAddress.Loopback); 51 | services.AddScoped(c => new HttpClient { BaseAddress = new Uri(baseUrl) }); 52 | services.AddBlazorServerIdentityApiClient(baseUrl); 53 | services.AddLocalStorage(); 54 | 55 | // Register all services 56 | services.AddServiceStack(typeof(MyServices).Assembly); 57 | 58 | var app = builder.Build(); 59 | 60 | // Configure the HTTP request pipeline. 61 | if (app.Environment.IsDevelopment()) 62 | { 63 | app.UseMigrationsEndPoint(); 64 | } 65 | else 66 | { 67 | app.UseExceptionHandler("/Error", createScopeForErrors: true); 68 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 69 | app.UseHsts(); 70 | } 71 | 72 | app.UseHttpsRedirection(); 73 | 74 | app.UseStaticFiles(); 75 | app.UseAntiforgery(); 76 | 77 | app.MapRazorComponents() 78 | .AddInteractiveServerRenderMode(); 79 | 80 | // Add additional endpoints required by the Identity /Account Razor components. 81 | app.MapAdditionalIdentityEndpoints(); 82 | 83 | app.UseServiceStack(new AppHost(), options => { 84 | options.MapEndpoints(); 85 | }); 86 | 87 | BlazorConfig.Set(new() 88 | { 89 | Services = app.Services, 90 | JSParseObject = JS.ParseObject, 91 | IsDevelopment = app.Environment.IsDevelopment(), 92 | EnableLogging = app.Environment.IsDevelopment(), 93 | EnableVerboseLogging = app.Environment.IsDevelopment(), 94 | }); 95 | 96 | app.Run(); 97 | -------------------------------------------------------------------------------- /MyApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:8080", 8 | "sslPort": 44300 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "applicationUrl": "https://localhost:5001", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | }, 30 | "IIS Express": { 31 | "commandName": "IISExpress", 32 | "launchBrowser": true, 33 | "environmentVariables": { 34 | "ASPNETCORE_ENVIRONMENT": "Development" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /MyApp/_pages/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About this Blazor App 3 | --- 4 | 5 | ## .NET 8 Blazor App Templates 6 | 7 | ServiceStack's new .NET 8 Blazor templates enhances the default ASP.NET Blazor App templates with several modern, high-productivity features, including: 8 | 9 | - [Tailwind CSS](https://tailwindcss.com) - Style your Blazor Apps with the modern popular utility-first CSS framework for creating beautiful, maintainable responsive UIs with DarkMode support 10 | - [ServiceStack.Blazor Components](https://blazor-gallery.jamstacks.net) - Rapidly develop beautiful Blazor Apps integrated with Rich high-productivity UI Tailwind Components like [AutoQueryGrid](https://blazor-gallery.servicestack.net/gallery/autoquerygrid) and [AutoForms](https://blazor-gallery.servicestack.net/gallery/autoform) 11 | - [ASP .NET Identity Auth](https://learn.microsoft.com/en-us/aspnet/core/blazor/security/) - Use the same ASP .NET Identity Auth used in ASP.NET's .NET 8 Blazor Apps, with all Identity Pages upgraded with beautiful Tailwind CSS styling 12 | - [Entity Framework](https://learn.microsoft.com/ef/) & [OrmLite](https://docs.servicestack.net/ormlite/) - Choose the Best ORM to build each App feature, with a unified solution that sees [OrmLite's Code-First DB Migrations](https://docs.servicestack.net/ormlite/db-migrations) run both EF and OrmLite migrations, inc. Seed Data with a single command at Development or Deployment 13 | - [AutoQuery](https://docs.servicestack.net/autoquery/) - Rapidly developing data-driven APIs, UIs and CRUD Apps 14 | - [Auto Admin Pages](https://www.youtube.com/watch?v=tt0ytzVVjEY) - Quickly develop your back-office CRUD Admin UIs to manage your App's Database tables at [/admin](/admin) 15 | - [Markdown](https://docs.servicestack.net/razor-press/syntax) - Maintain SEO-friendly documentation and content-rich pages like this one with just Markdown, beautifully styled with [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) 16 | - [Built-in UIs](https://servicestack.net/auto-ui) - Use ServiceStack's Auto UIs to [Explore your APIs](https://docs.servicestack.net/api-explorer) at [/ui](/ui/) 17 | or Query your [App's Database Tables](https://docs.servicestack.net/admin-ui-database) at [/admin-ui/database](/admin-ui/database) 18 | - [Universal API Components](https://youtu.be/66DgLHExC9E) - Effortlessly create reusable, maximally performant universal Blazor API Components that works in Blazor Server and Web Assembly Interactivity modes 19 | - [Built-in Docker Deployments](/deploy) - Use the built-in GitHub Actions to effortlessly deploy .NET 8 containerized Blazor Apps with Docker and GitHub Registry via SSH to any Linux Server -------------------------------------------------------------------------------- /MyApp/_pages/privacy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Privacy Policy 3 | --- 4 | 5 | [Your business name] is committed to providing quality services to you and this policy outlines our ongoing obligations to you in respect of how we manage your Personal Information. 6 | 7 | We have adopted the Australian Privacy Principles (APPs) contained in the Privacy Act 1988 (Cth) (the Privacy Act). The NPPs govern the way in which we collect, use, disclose, store, secure and dispose of your Personal Information. 8 | 9 | A copy of the Australian Privacy Principles may be obtained from the website of The Office of the Australian Information Commissioner at https://www.oaic.gov.au/. 10 | 11 | What is Personal Information and why do we collect it? 12 | Personal Information is information or an opinion that identifies an individual. Examples of Personal Information we collect includes names, addresses, email addresses, phone and facsimile numbers. 13 | 14 | This Personal Information is obtained in many ways including [interviews, correspondence, by telephone and facsimile, by email, via our website www.yourbusinessname.com.au, from your website, from media and publications, from other publicly available sources, from cookies- delete all that aren’t applicable] and from third parties. We don’t guarantee website links or policy of authorised third parties. 15 | 16 | We collect your Personal Information for the primary purpose of providing our services to you, providing information to our clients and marketing. We may also use your Personal Information for secondary purposes closely related to the primary purpose, in circumstances where you would reasonably expect such use or disclosure. You may unsubscribe from our mailing/marketing lists at any time by contacting us in writing. 17 | 18 | When we collect Personal Information we will, where appropriate and where possible, explain to you why we are collecting the information and how we plan to use it. 19 | 20 | Sensitive Information 21 | Sensitive information is defined in the Privacy Act to include information or opinion about such things as an individual's racial or ethnic origin, political opinions, membership of a political association, religious or philosophical beliefs, membership of a trade union or other professional body, criminal record or health information. 22 | 23 | Sensitive information will be used by us only: 24 | 25 | - For the primary purpose for which it was obtained 26 | 27 | - For a secondary purpose that is directly related to the primary purpose 28 | 29 | - With your consent; or where required or authorised by law. 30 | 31 | Third Parties 32 | Where reasonable and practicable to do so, we will collect your Personal Information only from you. However, in some circumstances we may be provided with information by third parties. In such a case we will take reasonable steps to ensure that you are made aware of the information provided to us by the third party. 33 | 34 | Disclosure of Personal Information 35 | Your Personal Information may be disclosed in a number of circumstances including the following: 36 | 37 | - Third parties where you consent to the use or disclosure; and 38 | 39 | - Where required or authorised by law. 40 | 41 | Security of Personal Information 42 | Your Personal Information is stored in a manner that reasonably protects it from misuse and loss and from unauthorized access, modification or disclosure. 43 | 44 | When your Personal Information is no longer needed for the purpose for which it was obtained, we will take reasonable steps to destroy or permanently de-identify your Personal Information. However, most of the Personal Information is or will be stored in client files which will be kept by us for a minimum of 7 years. 45 | 46 | Access to your Personal Information 47 | You may access the Personal Information we hold about you and to update and/or correct it, subject to certain exceptions. If you wish to access your Personal Information, please contact us in writing. 48 | 49 | [Your business name] will not charge any fee for your access request, but may charge an administrative fee for providing a copy of your Personal Information. 50 | 51 | In order to protect your Personal Information we may require identification from you before releasing the requested information. 52 | 53 | Maintaining the Quality of your Personal Information 54 | It is an important to us that your Personal Information is up to date. We will take reasonable steps to make sure that your Personal Information is accurate, complete and up-to-date. If you find that the information we have is not up to date or is inaccurate, please advise us as soon as practicable so we can update our records and ensure we can continue to provide quality services to you. 55 | 56 | Policy Updates 57 | This Policy may change from time to time and is available on our website. 58 | 59 | Privacy Policy Complaints and Enquiries 60 | If you have any queries or complaints about our Privacy Policy please contact us at: 61 | 62 | 63 | [Your business address] 64 | 65 | [Your business email address] 66 | 67 | [Your business phone number] 68 | 69 | -------------------------------------------------------------------------------- /MyApp/_videos/blazor/admin.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Create Beautiful UX optimized Blazor Apps with Auto Admin pages 3 | url: https://youtu.be/tt0ytzVVjEY 4 | tags: blazor,admin 5 | date: 12-12-2022 6 | order: 2 7 | --- 8 | 9 | We walk through the process of creating admin screens using ServiceStack Blazor Components by looking at our Blazor Diffusion 10 | App as an example which generates AI artworks using Stable Diffusion and curates a gallery of amazing visuals. 11 | 12 | With the ServiceStack Blazor Components, we'll show you how to quickly create a powerful and intuitive admin interface for 13 | managing the application data, with minimal coding required. 14 | -------------------------------------------------------------------------------- /MyApp/_videos/blazor/components.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Rapidly Develop Beautiful Apps with Blazor Tailwind Components 3 | url: https://youtu.be/iKpQI2233nY 4 | tags: blazor,components,autoquery 5 | date: 11-10-2022 6 | order: 1 7 | --- 8 | 9 | The ServiceStack Blazor components for Blazor WASM and Server Rendered Apps are a powerful set of tools that enable 10 | developers to quickly and easily create interactive data grids from their AutoQuery services using minimal code. 11 | 12 | These components can be styled with Tailwind CSS, enabling creating professional-looking custom applications. -------------------------------------------------------------------------------- /MyApp/_videos/blazor/darkmode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Beautiful Blazor Tailwind Components with Darkmode 3 | url: https://youtu.be/8nwpC_B4AC4 4 | tags: darkmode,blazor,components 5 | date: 07-12-2022 6 | order: 5 7 | --- 8 | 9 | We walkthrough the ServiceStack Blazor Components support for Darkmode. 10 | Our Blazor Components provide a suite of powerful, pre-built UI components that are easy to use, customizable, 11 | and can be used to build high-performing, responsive web applications. 12 | 13 | The components support both Blazor Server and Blazor WASM, and are designed to work seamlessly with 14 | ServiceStack's features, providing a simplified and integrated developer experience. -------------------------------------------------------------------------------- /MyApp/_videos/blazor/tailwind.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Creating Beautiful Blazor Apps with Tailwind 3 | url: https://youtu.be/3gD_MMcYI-4 4 | tags: blazor,autoquery 5 | date: 25-07-2022 6 | order: 4 7 | --- 8 | 9 | We tour the Blazor Web Assembly template that utilizes Tailwind CSS, 10 | and show you how to set up hot reload for both during development. 11 | 12 | Tailwind CSS is a popular utility-first CSS framework that provides a 13 | comprehensive set of pre-defined CSS styles, enabling developers to 14 | create modern and responsive designs with ease. 15 | -------------------------------------------------------------------------------- /MyApp/_videos/blazor/universal.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Universal Blazor API Components for Blazor Server and WASM 3 | url: https://youtu.be/66DgLHExC9E 4 | tags: blazor,server,wasm,components 5 | date: 04-01-2023 6 | order: 3 7 | --- 8 | 9 | We walk through the process of creating universal Blazor API components for Blazor Server 10 | and Blazor WASM using the ServiceStack.Blazor library. 11 | 12 | We'll show how Developers can create UI components that can be shared between their Blazor 13 | applications without worrying about the hosting model used. This allows developers to streamline 14 | their development process and reduce the amount of code they need to write, making it easier to build 15 | and maintain complex Apps. 16 | -------------------------------------------------------------------------------- /MyApp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /MyApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "AppConfig": { 10 | "LocalBaseUrl": "https://localhost:5001", 11 | "PublicBaseUrl": "https://blazor.web-templates.io" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /MyApp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "postinstall": "dotnet run --AppTasks=migrate && node postinstall.js", 4 | "dtos": "x mjs", 5 | "dev": "dotnet watch", 6 | "ui:dev": "tailwindcss -i ./tailwind.input.css -o ./wwwroot/css/app.css --watch", 7 | "ui:build": "tailwindcss -i ./tailwind.input.css -o ./wwwroot/css/app.css --minify", 8 | "build": "npm run ui:build", 9 | "migrate": "dotnet run --AppTasks=migrate", 10 | "revert:last": "dotnet run --AppTasks=migrate.revert:last", 11 | "revert:all": "dotnet run --AppTasks=migrate.revert:all", 12 | "rerun:last": "npm run revert:last && npm run migrate" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /MyApp/tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | theme: { 3 | extend: { 4 | colors: { 5 | 'accent-1': '#FAFAFA', 6 | 'accent-2': '#EAEAEA', 7 | danger: 'rgb(153 27 27)', 8 | success: 'rgb(22 101 52)', 9 | }, 10 | }, 11 | } 12 | } -------------------------------------------------------------------------------- /MyApp/wwwroot/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | } 4 | 5 | a, .btn-link { 6 | color: #006bb7; 7 | } 8 | 9 | .btn-primary { 10 | color: #fff; 11 | background-color: #1b6ec2; 12 | border-color: #1861ac; 13 | } 14 | 15 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { 16 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; 17 | } 18 | 19 | .content { 20 | padding-top: 1.1rem; 21 | } 22 | 23 | h1:focus { 24 | outline: none; 25 | } 26 | 27 | .valid.modified:not([type=checkbox]) { 28 | outline: 1px solid #26b050; 29 | } 30 | 31 | .invalid { 32 | outline: 1px solid #e51040; 33 | } 34 | 35 | .validation-message { 36 | color: #e51040; 37 | } 38 | 39 | .blazor-error-boundary { 40 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 41 | padding: 1rem 1rem 1rem 3.7rem; 42 | color: white; 43 | } 44 | 45 | .blazor-error-boundary::after { 46 | content: "An error has occurred." 47 | } 48 | -------------------------------------------------------------------------------- /MyApp/wwwroot/css/highlight.css: -------------------------------------------------------------------------------- 1 | /* highlight.js - vs.css */ 2 | .hljs {background:white;color:black} 3 | .hljs-comment,.hljs-quote,.hljs-variable{color:#008000} 4 | .hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-name,.hljs-tag{color:#00f} 5 | .hljs-string,.hljs-title,.hljs-section,.hljs-attribute,.hljs-literal,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-addition{color:#a31515} 6 | .hljs-deletion,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-meta{color:#2b91af} 7 | .hljs-doctag{color:#808080} 8 | .hljs-attr{color: #f00} 9 | .hljs-symbol,.hljs-bullet,.hljs-link{color:#00b0e8} 10 | .hljs-emphasis{font-style:italic} 11 | .hljs-strong{font-weight:bold} 12 | 13 | /* https://unpkg.com/@highlightjs/cdn-assets/styles/atom-one-dark.min.css */ 14 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34} 15 | .hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd} 16 | .hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst,.hljs-tag{color:#e06c75} 17 | .hljs-literal{color:#56b6c2} 18 | .hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379} 19 | .hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66} 20 | .hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee} 21 | .hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} 22 | .hljs-link{text-decoration:underline} 23 | 24 | /*highlightjs*/ 25 | .hljs, .prose :where(pre):not(:where([class~="not-prose"] *)) .hljs { 26 | color: #e5e7eb !important; 27 | background-color: #282c34 !important; 28 | } 29 | .hljs-comment, .hljs-quote { 30 | color: rgb(148 163 184); /*text-slate-400*/ 31 | } 32 | 33 | pre { 34 | overflow-x: auto; 35 | font-weight: 400; 36 | font-size: .875em; 37 | line-height: 1.7142857; 38 | margin-top: 1.7142857em; 39 | margin-bottom: 1.7142857em; 40 | border-radius: .375rem; 41 | padding: .8571429em 1.1428571em; 42 | max-width: calc(100vw - 1rem); 43 | min-width: fit-content; 44 | background-color: #282c34 !important; 45 | } 46 | pre code.hljs { 47 | display: block; 48 | overflow-x: auto; 49 | padding: 1em; 50 | } 51 | 52 | /* atom-one-dark */ 53 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34} 54 | .hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd} 55 | .hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2} 56 | .hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379} 57 | .hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66} 58 | .hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee} 59 | .hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b} 60 | .hljs-emphasis{font-style:italic} 61 | .hljs-strong{font-weight:700} 62 | .hljs-link{text-decoration:underline} -------------------------------------------------------------------------------- /MyApp/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NetCoreTemplates/blazor/c0dea0977048921e556404b7117957a17ec3df45/MyApp/wwwroot/favicon.png -------------------------------------------------------------------------------- /MyApp/wwwroot/img/blazor.svg: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /MyApp/wwwroot/img/nav/bookings.svg: -------------------------------------------------------------------------------- 1 |  6 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/nav/counter.svg: -------------------------------------------------------------------------------- 1 |  2 | 3 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/nav/coupon.svg: -------------------------------------------------------------------------------- 1 |  2 | 3 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/nav/home.svg: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /MyApp/wwwroot/img/nav/profile.svg: -------------------------------------------------------------------------------- 1 |  2 | 3 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/nav/todomvc.svg: -------------------------------------------------------------------------------- 1 |  4 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/nav/weather.svg: -------------------------------------------------------------------------------- 1 |  2 | 3 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/profiles/user1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 10 | 12 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 31 | 32 | 34 | 36 | 37 | 38 | 39 | 40 | 41 | 43 | 45 | 47 | 48 | 52 | 55 | 56 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/profiles/user2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 20 | 21 | 22 | 24 | 26 | 28 | 30 | 32 | 35 | 38 | 40 | 43 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /MyApp/wwwroot/img/profiles/user3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 20 | 21 | 22 | 24 | 26 | 28 | 30 | 32 | 35 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /MyApp/wwwroot/mjs/app.mjs: -------------------------------------------------------------------------------- 1 | export async function remount() { 2 | document.querySelectorAll('[data-module]').forEach(async el => { 3 | let modulePath = el.dataset.module 4 | if (!modulePath) return 5 | if (!modulePath.startsWith('/') && !modulePath.startsWith('.')) { 6 | modulePath = `../${modulePath}` 7 | } 8 | try { 9 | const module = await import(modulePath) 10 | if (typeof module.default?.load == 'function') { 11 | module.default.load() 12 | } 13 | } catch (e) { 14 | console.error(`Couldn't load module ${el.dataset.module}`, e) 15 | } 16 | }) 17 | } 18 | 19 | /* used in :::sh and :::nuget CopyContainerRenderer */ 20 | globalThis.copy = function (e) { 21 | e.classList.add('copying') 22 | let $el = document.createElement("textarea") 23 | let text = (e.querySelector('code') || e.querySelector('p')).innerHTML 24 | $el.innerHTML = text 25 | document.body.appendChild($el) 26 | $el.select() 27 | document.execCommand("copy") 28 | document.body.removeChild($el) 29 | setTimeout(() => e.classList.remove('copying'), 3000) 30 | } 31 | 32 | document.addEventListener('DOMContentLoaded', () => 33 | Blazor.addEventListener('enhancedload', () => { 34 | remount() 35 | globalThis.hljs?.highlightAll() 36 | })) -------------------------------------------------------------------------------- /MyApp/wwwroot/pages/Account/Manage/EnableAuthenticator.mjs: -------------------------------------------------------------------------------- 1 | import { addScript, $1 } from "@servicestack/client" 2 | const loadJs = addScript('lib/js/qrcode.min.js') 3 | 4 | export default { 5 | async load() { 6 | await loadJs 7 | new QRCode($1("#qrCode"), $1('#qrCodeData').dataset.url) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /MyApp/wwwroot/tailwind/README.txt: -------------------------------------------------------------------------------- 1 | Source code of components containing tailwind classes that also need to be included in tailwind's generated /css/app.css 2 | Available from: https://raw.githubusercontent.com/ServiceStack/ServiceStack/main/ServiceStack.Blazor/src/ServiceStack.Blazor/dist/tailwind.html -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blazor 2 | 3 | .NET 8.0 Blazor Tailwind App 4 | 5 | [![](https://raw.githubusercontent.com/ServiceStack/Assets/master/csharp-templates/blazor.png)](http://blazor.web-templates.io/) 6 | 7 | > Browse [source code](https://github.com/NetCoreTemplates/blazor), view live demo [blazor.web-templates.io](http://blazor.web-templates.io) and install with [dotnet-new](https://docs.servicestack.net/dotnet-new): 8 | 9 | $ dotnet tool install -g x 10 | 11 | $ x new blazor ProjectName 12 | 13 | Alternatively write new project files directly into an empty repository, using the Directory Name as the ProjectName: 14 | 15 | $ git clone https://github.com//.git 16 | $ cd 17 | $ x new blazor 18 | 19 | -------------------------------------------------------------------------------- /config/deploy.yml: -------------------------------------------------------------------------------- 1 | # Name of your application. Used to uniquely configure containers. 2 | service: my-app 3 | 4 | # Name of the container image. 5 | image: my-user/myapp 6 | 7 | # Required for use of ASP.NET Core with Kamal-Proxy. 8 | env: 9 | ASPNETCORE_FORWARDEDHEADERS_ENABLED: true 10 | 11 | # Deploy to these servers. 12 | servers: 13 | # IP address of server, optionally use env variable. 14 | web: 15 | - 192.168.0.1 16 | # - <%= ENV['KAMAL_DEPLOY_IP'] %> 17 | 18 | 19 | # Enable SSL auto certification via Let's Encrypt (and allow for multiple apps on one server). 20 | # If using something like Cloudflare, it is recommended to set encryption mode 21 | # in Cloudflare's SSL/TLS setting to "Full" to enable end-to-end encryption. 22 | proxy: 23 | ssl: true 24 | host: my-app.example.com 25 | # kamal-proxy connects to your container over port 80, use `app_port` to specify a different port. 26 | app_port: 8080 27 | 28 | # Credentials for your image host. 29 | registry: 30 | # Specify the registry server, if you're not using Docker Hub 31 | server: ghcr.io 32 | username: 33 | - KAMAL_REGISTRY_USERNAME 34 | 35 | # Always use an access token rather than real password (pulled from .kamal/secrets). 36 | password: 37 | - KAMAL_REGISTRY_PASSWORD 38 | 39 | # Configure builder setup. 40 | builder: 41 | arch: amd64 42 | 43 | volumes: 44 | - "/opt/docker/MyApp/App_Data:/app/App_Data" 45 | 46 | #accessories: 47 | # litestream: 48 | # roles: ["web"] 49 | # image: litestream/litestream 50 | # files: ["config/litestream.yml:/etc/litestream.yml"] 51 | # volumes: ["/opt/docker/MyApp/App_Data:/data"] 52 | # cmd: replicate 53 | # env: 54 | # secret: 55 | # - ACCESS_KEY_ID 56 | # - SECRET_ACCESS_KEY 57 | --------------------------------------------------------------------------------