diff --git a/.github/workflows/docker-compose-build.yml b/.github/workflows/docker-compose-build.yml index 07b5079b9..a832b2b96 100644 --- a/.github/workflows/docker-compose-build.yml +++ b/.github/workflows/docker-compose-build.yml @@ -15,10 +15,10 @@ jobs: options: --privileged steps: - uses: actions/checkout@v2 - - name: Set permissions and run init.sh + - name: Set permissions and run install.sh run: | - chmod +x init.sh - ./init.sh + chmod +x install.sh + ./install.sh - name: Set up Docker Compose run: | # Change the exposed host port of the SmtpService from 25 to 2525 because port 25 is not allowed in GitHub Actions @@ -33,7 +33,7 @@ jobs: # Test if the service on localhost:80 responds http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:80) if [ "$http_code" -ne 200 ]; then - echo "Service did not respond with 200 OK" + echo "Service did not respond with 200 OK. Check if client app is configured correctly." exit 1 else echo "Service responded with 200 OK" @@ -43,7 +43,7 @@ jobs: # Test if the service on localhost:81 responds http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:81) if [ "$http_code" -ne 200 ]; then - echo "Service did not respond with expected 200 OK. Check if all DB migrations are applied." + echo "Service did not respond with expected 200 OK. Check if WebApi is configured correctly." exit 1 else echo "Service responded with $http_code" @@ -57,3 +57,13 @@ jobs: else echo "SmtpService responded on port 2525" fi + - name: Test if localhost:8080 (Admin) responds + run: | + # Test if the service on localhost:8080 responds + http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/user/login) + if [ "$http_code" -ne 200 ]; then + echo "Service did not respond with expected 200 OK. Check if admin app is configured correctly." + exit 1 + else + echo "Service responded with $http_code" + fi diff --git a/.gitignore b/.gitignore index 5369ec659..c4730a9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -379,5 +379,5 @@ src/AliasVault.Client/wwwroot/appsettings.Development.json # appsettings.Development.json is added manually if needed, it should not be committed. src/Tests/AliasVault.E2ETests/appsettings.Development.json -# .env is generated by init.sh and therefore should be ignored +# .env is generated by install.sh and therefore should be ignored .env diff --git a/README.md b/README.md index 7c04840fe..b69c2ca49 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ [](https://github.com/lanedirt/OGameX/releases) [](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml) -[](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml) -[](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml) +[](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml) +[](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml) +[](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml) [](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault) [](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault) @@ -39,32 +40,35 @@ To install AliasVault on your own machine, follow the steps below. Note: the ins $ git clone https://github.com/lanedirt/AliasVault.git ``` -### 2. Run the init script. -This script will create a .env file in the root directory of the project if it does not yet exist and populate it with a random encryption secret. +### 2. Run the install script. +The script checks and creates a .env file with a JWT secret, generates an admin password, and manages Docker image building and container initiation. It ensures necessary configurations and services are ready for the application's operation. + ```bash # Go to the project directory $ cd AliasVault -# Make init script executable -$ chmod +x init.sh +# Make install script executable +$ chmod +x install.sh -# Run the init script and follow the steps -$ ./init.sh +# Run the install script +$ ./install.sh ``` -### 3. Build and run the app via Docker: +Note: if you do not wish to run the script, you can set up the environment variables and build the Docker image and containers manually instead. See the [manual setup instructions](docs/setup/1-manually-setup-docker.md) for more information. -```bash -# Build and run the app via Docker Compose -$ docker compose up -d --build --force-recreate -``` -> Note: the container binds to port 80 by default. If you have another service running on port 80, you can change the port in the `docker-compose.yml` file. +### 3. AliasVault is ready to use. +The script will output the URL where the app is available. You can now open the app in your browser and create an account. + +> Note: the container binds to port 80 for client and port 8080 for admin by default. If you have another service running on these ports, you can change the AliasVault ports in the `docker-compose.yml` file. #### Note for first time build: -- When running the docker compose command for the first time, it may take a few minutes to build the Docker image. +- When running the init script for the first time, it may take a few minutes for Docker to download all dependencies. Subsequent builds will be faster. - A SQLite database file will be created in `./database/AliasServerDb.sqlite`. This file will store all (encrypted) password vaults. It should be kept secure and not shared. -After the Docker containers have started the app will be available at http://localhost:80 +#### Other useful commands: +- To reset the admin password, run the install.sh script with the `--reset-admin-password` flag. +- To uninstall AliasVault, make the uninstall script executable with `chmod +x uninstall.sh` first, then run the script: `./uninstall.sh`. +This will remove all containers, images, and volumes related to AliasVault. It will keep all files and configuration intact however, so you can easily reinstall AliasVault later. ## Tech stack / credits The following technologies, frameworks and libraries are used in this project: diff --git a/aliasvault.sln b/aliasvault.sln index 1f437f45c..9396197d1 100644 --- a/aliasvault.sln +++ b/aliasvault.sln @@ -41,6 +41,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.SmtpService", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.IntegrationTests", "src\Tests\AliasVault.IntegrationTests\AliasVault.IntegrationTests.csproj", "{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Admin", "src\AliasVault.Admin\AliasVault.Admin.csproj", "{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InitializationCLI", "src\Utilities\InitializationCLI\InitializationCLI.csproj", "{857BCD0E-753F-437A-AF75-B995B4D9A5FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Logging", "src\Utilities\AliasVault.Logging\AliasVault.Logging.csproj", "{FF0B0E64-1AE2-415C-A404-0EB78010821A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.RazorComponents", "src\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj", "{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.WorkerStatus", "src\Utilities\AliasVault.WorkerStatus\AliasVault.WorkerStatus.csproj", "{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -103,6 +113,26 @@ Global {1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.Build.0 = Release|Any CPU + {F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Release|Any CPU.Build.0 = Release|Any CPU + {857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Release|Any CPU.Build.0 = Release|Any CPU + {FF0B0E64-1AE2-415C-A404-0EB78010821A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF0B0E64-1AE2-415C-A404-0EB78010821A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF0B0E64-1AE2-415C-A404-0EB78010821A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF0B0E64-1AE2-415C-A404-0EB78010821A}.Release|Any CPU.Build.0 = Release|Any CPU + {59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Release|Any CPU.Build.0 = Release|Any CPU + {951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -119,6 +149,10 @@ Global {A9C9A606-C87E-4298-AB32-09B1884D7487} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8} {B095A174-E528-4D38-BEC1-D1D38B3B30C0} = {8A477241-B96C-4174-968D-D40CB77F1ECD} {1C7C8DE9-5F2A-43DB-A25E-33319E80A509} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB} + {857BCD0E-753F-437A-AF75-B995B4D9A5FE} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8} + {FF0B0E64-1AE2-415C-A404-0EB78010821A} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8} + {59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8} + {951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E} diff --git a/docker-compose.yml b/docker-compose.yml index 2a8e5c76e..fe43c0106 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,17 @@ services: + admin: + image: aliasvault-admin + build: + context: . + dockerfile: src/AliasVault.Admin/Dockerfile + ports: + - "8080:8082" + volumes: + - ./database:/database + - ./logs:/logs + restart: always + env_file: + - .env client: image: aliasvault-client build: @@ -19,6 +32,7 @@ services: - "81:8081" volumes: - ./database:/database + - ./logs:/logs env_file: - .env restart: always @@ -33,6 +47,7 @@ services: - "587:587" volumes: - ./database:/database + - ./logs:/logs env_file: - .env restart: always diff --git a/docs/setup/1-manually-setup-docker.md b/docs/setup/1-manually-setup-docker.md new file mode 100644 index 000000000..3dc132f5c --- /dev/null +++ b/docs/setup/1-manually-setup-docker.md @@ -0,0 +1,110 @@ +# Manual Setup Instructions for AliasVault + +This README provides step-by-step instructions for manually setting up AliasVault without using the `install.sh` script. Follow these steps if you prefer to execute all statements yourself. + +## Prerequisites + +- Docker and Docker Compose installed on your system +- OpenSSL for generating random passwords + +## Steps + +1. **Create .env file** + + Copy the `.env.example` file to create a new `.env` file: + ``` + cp .env.example .env + ``` + +2. **Generate and set JWT_KEY** + + Update the .env file and set the JWT_KEY environment variable to a random 32-char string. This key is used for JWT token generation and should be kept secure. + + Generate a random 32 char string for the JWT: + ``` + openssl rand -base64 32 + ``` + + Add the generated key to the .env file: + + ``` + JWT_KEY=your_32_char_string_here + +3. **Set SMTP_ALLOWED_DOMAINS** + + Update the .env file and set the SMTP_ALLOWED_DOMAINS value the allowed domains that can be used for email addresses. Separate multiple domains with commas. + ``` + SMTP_ALLOWED_DOMAINS=yourdomain.com,anotherdomain.com + ``` + Replace `yourdomain.com,anotherdomain.com` with your actual allowed domains. + +4. **Set SMTP_TLS_ENABLED** + + Decide whether to enable TLS for email and add it to the .env file: + ``` + SMTP_TLS_ENABLED=true + ``` + Or set it to `false` if you don't want to enable TLS. + +5. **Generate admin password** + + Set the admin password hash in the .env file. The password hash is generated using the `InitializationCLI` utility. + + Build the Docker image for password hashing: + ``` + docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . + ``` + + Generate the password hash: + ``` + docker run --rm initcli "" + ``` + + Add the password hash and generation timestamp to the .env file: + ``` + ADMIN_PASSWORD_HASH= + ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z + ``` + +6. **Build and start Docker containers** + + Build the Docker Compose stack: + ``` + docker-compose build + ``` + + Start the Docker Compose stack: + ``` + docker-compose up -d + ``` + +7. **Access AliasVault** + + AliasVault should now be running. You can access it as follows: + + - Admin Panel: http://localhost:8080/ + - Username: admin + - Password: [Use the ADMIN_PASSWORD generated in step 5] + + - Client Website: http://localhost:80/ + - Create your own account from here + +## Important Notes + +- Make sure to save the admin password (ADMIN_PASSWORD) generated in step 5 in a secure location. It won't be shown again. +- If you need to reset the admin password in the future, you'll need to generate a new hash and update the .env file manually. +Afterwards restart the docker containers which will update the admin password in the database. +- Always keep your .env file secure and do not share it, as it contains sensitive information. + +## Troubleshooting + +If you encounter any issues during the setup: + +1. Check the Docker logs: + ``` + docker-compose logs + ``` +2. Ensure all required ports (8080 and 80) are available and not being used by other services. +3. Verify that all environment variables in the .env file are set correctly. + +For further assistance, please refer to the project documentation or seek support through the appropriate channels. diff --git a/init.sh b/init.sh deleted file mode 100755 index 560b99198..000000000 --- a/init.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/bin/sh - -# Define colors for CLI output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -MAGENTA='\033[0;35m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color - -# Define the path to the .env and .env.example files -ENV_FILE=".env" -ENV_EXAMPLE_FILE=".env.example" - -# Function to generate a new 32-character JWT key -generate_jwt_key() { - dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32 -} - -# Function to create .env file from .env.example if it doesn't exist -create_env_file() { - if [ ! -f "$ENV_FILE" ]; then - if [ -f "$ENV_EXAMPLE_FILE" ]; then - cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" - printf "${GREEN}> .env file created from .env.example.${NC}\n" - else - touch "$ENV_FILE" - printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n" - fi - else - printf "${CYAN}> .env file already exists.${NC}\n" - fi -} - -# Function to check and populate the .env file with JWT_KEY -populate_jwt_key() { - if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then - printf "${YELLOW}JWT_KEY not found or empty in $ENV_FILE. Generating a new JWT key...${NC}\n" - JWT_KEY=$(generate_jwt_key) - if grep -q "^JWT_KEY=" "$ENV_FILE"; then - awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" - else - printf "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE\n" - fi - printf "${GREEN}> JWT_KEY has been added to $ENV_FILE.${NC}\n" - else - printf "${CYAN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n" - fi -} - -# Function to ask the user for SMTP_ALLOWED_DOMAINS -set_smtp_allowed_domains() { - if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then - printf "${YELLOW}Please enter the domains that should be allowed to send email, separated by commas:${NC}\n" - read -r smtp_allowed_domains - if grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE"; then - awk -v domains="$smtp_allowed_domains" '/^SMTP_ALLOWED_DOMAINS=/ {$0="SMTP_ALLOWED_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" - else - printf "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}\n" >> "$ENV_FILE" - fi - printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set in $ENV_FILE.${NC}\n" - else - printf "${CYAN}> SMTP_ALLOWED_DOMAINS already exists and has a value in $ENV_FILE.${NC}\n" - fi -} - -# Function to ask the user if TLS should be enabled for email -set_smtp_tls_enabled() { - if ! grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE" || [ -z "$(grep "^SMTP_TLS_ENABLED=" "$ENV_FILE" | cut -d '=' -f2)" ]; then - printf "${YELLOW}Do you want TLS enabled for email? (yes/no):${NC}\n" - read -r tls_enabled - tls_enabled=$(echo "$tls_enabled" | tr '[:upper:]' '[:lower:]') - if [ "$tls_enabled" = "yes" ] || [ "$tls_enabled" = "y" ]; then - tls_enabled="true" - else - tls_enabled="false" - fi - if grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE"; then - awk -v tls="$tls_enabled" '/^SMTP_TLS_ENABLED=/ {$0="SMTP_TLS_ENABLED="tls} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" - else - printf "SMTP_TLS_ENABLED=${tls_enabled}\n" >> "$ENV_FILE" - fi - printf "${GREEN}> SMTP_TLS_ENABLED has been set to ${tls_enabled} in $ENV_FILE.${NC}\n" - else - printf "${CYAN}> SMTP_TLS_ENABLED already exists and has a value in $ENV_FILE.${NC}\n" - fi -} - -# Function to print the CLI logo -print_logo() { - printf "${MAGENTA}\n" - printf "=========================================================\n" - printf " _ _ __ __ _ _ \n" - printf " /\ | (_) \ \ / / | | | \n" - printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n" - printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n" - printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n" - printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n" - printf "\n" - printf "=========================================================\n" - printf "${NC}\n" -} - -# Run the functions and print status -print_logo -printf "${BLUE}Initializing AliasVault...${NC}\n" -create_env_file -populate_jwt_key -set_smtp_allowed_domains -set_smtp_tls_enabled -printf "${BLUE}Initialization complete.${NC}\n" -printf "\n" -printf "To build the images and start the containers, run the following command:\n" -printf "\n" -printf "${CYAN}$ docker compose up -d --build --force-recreate${NC}\n" -printf "\n" -printf "\n" diff --git a/install.sh b/install.sh new file mode 100755 index 000000000..fc239729d --- /dev/null +++ b/install.sh @@ -0,0 +1,326 @@ +#!/bin/sh + +# Define colors for CLI output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Define the path to the .env and .env.example files +ENV_FILE=".env" +ENV_EXAMPLE_FILE=".env.example" + +# Define verbose flag and reset password flag +VERBOSE=false +RESET_PASSWORD=false + +# Function to parse command-line arguments +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --verbose) + VERBOSE=true + ;; + --reset-password) + RESET_PASSWORD=true + ;; + *) + printf "${RED}Unknown argument: $1${NC}\n" + exit 1 + ;; + esac + shift + done +} + +# Function to generate a random admin password and store its hash in the .env file +generate_admin_password() { + if grep -q "^ADMIN_PASSWORD_HASH=" ".env" && [ "$RESET_PASSWORD" = false ]; then + printf "${CYAN}> Checking admin password...${NC}\n" + printf "${GREEN}> ADMIN_PASSWORD_HASH already exists in .env. Use --reset-password to generate a new one.${NC}\n" + return 0 + fi + + printf "${CYAN}> Generating new admin password...${NC}\n" + + ADMIN_PASSWORD=$(openssl rand -base64 12) + printf "${CYAN}> Building Docker image for password generation...${NC}" + + if [ "$VERBOSE" = true ]; then + printf "\n" + docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . + else + ( + # Run docker build and capture its output + docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . > install_build_output.log 2>&1 & + BUILD_PID=$! + + printf "${CYAN}" + + # Print dots while the build is running + while kill -0 $BUILD_PID 2>/dev/null; do + printf "." + sleep 1 + done + + printf "${NC}\n" + + # Wait for the build to finish and capture its exit code + wait $BUILD_PID + BUILD_EXIT_CODE=$? + + # If there was an error, display it + if [ $BUILD_EXIT_CODE -ne 0 ]; then + printf "\n${RED} An error occurred while building the Docker image for password generation. Check the output above.${NC}\n" + printf "\n" + cat install_build_output.log + exit $BUILD_EXIT_CODE + fi + ) + fi + + printf "${GREEN}> Docker image built successfully.${NC}\n" + + printf "${CYAN}> Running Docker container to generate admin password hash...${NC}\n" + + # Run the Docker container to generate the password hash + ADMIN_PASSWORD_HASH=$(docker run --rm initcli "$ADMIN_PASSWORD" 2> install_run_output.log) + RUN_EXIT_CODE=$? + + if [ $RUN_EXIT_CODE -ne 0 ]; then + printf "${RED}> Error occurred while running the Docker container. Check install_run_output.log for details.${NC}\n" + return $RUN_EXIT_CODE + fi + + # Remove existing ADMIN_PASSWORD_HASH and ADMIN_PASSWORD_GENERATED if it exists + sed -i '' '/^ADMIN_PASSWORD_HASH=/d' .env + sed -i '' '/^ADMIN_PASSWORD_GENERATED=/d' .env + + # Append new entries + echo "ADMIN_PASSWORD_HASH=$ADMIN_PASSWORD_HASH" >> .env + echo "ADMIN_PASSWORD_GENERATED=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> .env + + printf "${GREEN}> New admin password generated and hash stored in .env${NC}\n" +} + +# Function to restart Docker containers +restart_docker_containers() { + printf "${CYAN}> Restarting Docker containers...${NC}\n" + docker-compose down + docker-compose up -d + printf "${GREEN}> Docker containers restarted successfully.${NC}\n" +} + +# Function to generate a new 32-character JWT key +generate_jwt_key() { + dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32 +} + +# Function to create .env file from .env.example if it doesn't exist +create_env_file() { + printf "${CYAN}> Creating .env file...${NC}\n" + if [ ! -f "$ENV_FILE" ]; then + if [ -f "$ENV_EXAMPLE_FILE" ]; then + cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" + printf "${GREEN}> .env file created from .env.example.${NC}\n" + else + touch "$ENV_FILE" + printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n" + fi + else + printf "${GREEN}> .env file already exists.${NC}\n" + fi +} + +# Function to check and populate the .env file with JWT_KEY +populate_jwt_key() { + printf "${CYAN}> Checking JWT_KEY...${NC}\n" + if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then + JWT_KEY=$(generate_jwt_key) + if grep -q "^JWT_KEY=" "$ENV_FILE"; then + awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + else + echo "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE" + fi + printf "${GREEN}> JWT_KEY has been generated and added to $ENV_FILE.${NC}\n" + else + printf "${GREEN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n" + fi +} + +# Function to ask the user for SMTP_ALLOWED_DOMAINS +set_smtp_allowed_domains() { + printf "${CYAN}> Setting SMTP_ALLOWED_DOMAINS...${NC}\n" + if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then + printf "Please enter the domains that should be allowed to send email, separated by commas: " + read -r smtp_allowed_domains + if grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE"; then + awk -v domains="$smtp_allowed_domains" '/^SMTP_ALLOWED_DOMAINS=/ {$0="SMTP_ALLOWED_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + else + echo "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}" >> "$ENV_FILE" + fi + printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set in $ENV_FILE.${NC}\n" + else + printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists and has a value in $ENV_FILE.${NC}\n" + fi +} + +# Function to ask the user if TLS should be enabled for email +set_smtp_tls_enabled() { + printf "${CYAN}> Setting SMTP_TLS_ENABLED...${NC}\n" + if ! grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE" || [ -z "$(grep "^SMTP_TLS_ENABLED=" "$ENV_FILE" | cut -d '=' -f2)" ]; then + printf "Do you want TLS enabled for email? (yes/no): " + read -r tls_enabled + tls_enabled=$(echo "$tls_enabled" | tr '[:upper:]' '[:lower:]') + if [ "$tls_enabled" = "yes" ] || [ "$tls_enabled" = "y" ]; then + tls_enabled="true" + else + tls_enabled="false" + fi + if grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE"; then + awk -v tls="$tls_enabled" '/^SMTP_TLS_ENABLED=/ {$0="SMTP_TLS_ENABLED="tls} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + else + echo "SMTP_TLS_ENABLED=${tls_enabled}" >> "$ENV_FILE" + fi + printf "${GREEN}> SMTP_TLS_ENABLED has been set to ${tls_enabled} in $ENV_FILE.${NC}\n" + else + printf "${GREEN}> SMTP_TLS_ENABLED already exists and has a value in $ENV_FILE.${NC}\n" + fi +} + +# Function to build and run the Docker Compose stack with muted output unless an error occurs, showing progress indication +build_and_run_docker_compose() { + printf "${CYAN}> Building Docker Compose stack..." + if [ "$VERBOSE" = true ]; then + docker-compose build + else + ( + # Run docker-compose build and capture its output + docker-compose build > install_compose_build_output.log 2>&1 & + BUILD_PID=$! + + # Print dots while the build is running + while kill -0 $BUILD_PID 2>/dev/null; do + printf "." + sleep 1 + done + + printf "${NC}" + + # Wait for the build to finish and capture its exit code + wait $BUILD_PID + BUILD_EXIT_CODE=$? + + # If there was an error, display it + if [ $BUILD_EXIT_CODE -ne 0 ]; then + printf "\n${RED}> An error occurred while building the Docker Compose stack. Check install_compose_build_output.log for details.${NC}\n" + exit $BUILD_EXIT_CODE + fi + ) + fi + + printf "\n${GREEN}> Docker Compose stack built successfully.${NC}\n" + + printf "${CYAN}> Starting Docker Compose stack...${NC}\n" + if [ "$VERBOSE" = true ]; then + docker-compose up -d + else + docker-compose up -d > install_compose_up_output.log 2>&1 + fi + UP_EXIT_CODE=$? + + if [ $UP_EXIT_CODE -ne 0 ]; then + printf "${RED}> An error occurred while starting the Docker Compose stack. Check install_compose_up_output.log for details.${NC}\n" + exit $UP_EXIT_CODE + fi + + printf "${GREEN}> Docker Compose stack started successfully.${NC}\n" +} + +# Function to print the CLI logo +print_logo() { + printf "${MAGENTA}\n" + printf "=========================================================\n" + printf " _ _ __ __ _ _ \n" + printf " /\ | (_) \ \ / / | | | \n" + printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n" + printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n" + printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n" + printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n" + printf "\n" + printf " Install Script\n" + printf "=========================================================\n" + printf "${NC}\n" +} + +# Main execution flow +main() { + parse_args "$@" + + if [ "$RESET_PASSWORD" = true ]; then + print_logo + generate_admin_password + if [ $? -eq 0 ]; then + restart_docker_containers + fi + + printf "\n" + printf "${MAGENTA}=========================================================${NC}\n" + printf "\n" + printf "${GREEN}The admin password is successfully reset!${NC}\n" + printf "\n" + printf "${MAGENTA}=========================================================${NC}\n" + printf "\n" + else + # Run the original initialization process + print_logo + + printf "${YELLOW}+++ Initializing .env file +++${NC}\n" + printf "\n" + create_env_file || exit $? + populate_jwt_key || exit $? + set_smtp_allowed_domains || exit $? + set_smtp_tls_enabled || exit $? + generate_admin_password || exit $? + printf "\n${YELLOW}+++ Building Docker containers +++${NC}\n" + printf "\n" + build_and_run_docker_compose || exit $? + printf "\n" + printf "${MAGENTA}=========================================================${NC}\n" + printf "\n" + printf "${GREEN}AliasVault is successfully installed!${NC}\n" + printf "\n" + printf "${MAGENTA}=========================================================${NC}\n" + printf "\n" + fi + + printf "${CYAN}To configure the server, login to the admin panel:${NC}\n" + printf "\n" + if [ "$ADMIN_PASSWORD" != "" ]; then + printf "Admin Panel: http://localhost:8080/\n" + printf "Username: admin\n" + printf "Password: $ADMIN_PASSWORD\n" + printf "\n" + printf "${YELLOW}(!) Caution: Make sure to backup the above credentials in a safe place, they won't be shown again!${NC}\n" + printf "\n" + else + printf "Admin Panel: http://localhost:8080/\n" + printf "Username: admin\n" + printf "Password: (Previously set. Run this command with --reset-password to generate a new one.)\n" + printf "\n" + fi + printf "${CYAN}===========================${NC}\n" + printf "\n" + printf "${CYAN}In order to start using AliasVault and create your own vault, log into the client website:${NC}\n" + printf "\n" + printf "Client Website: http://localhost:80/\n" + printf "You can create your own account from there.\n" + printf "\n" + printf "${MAGENTA}=========================================================${NC}\n" +} + +# Run the main function +main "$@" diff --git a/src/AliasGenerators/Password/IPasswordGenerator.cs b/src/AliasGenerators/Password/IPasswordGenerator.cs index 3f5e5e265..1473fe7bc 100644 --- a/src/AliasGenerators/Password/IPasswordGenerator.cs +++ b/src/AliasGenerators/Password/IPasswordGenerator.cs @@ -4,7 +4,7 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- -namespace AliasGenerators.Implementations; +namespace AliasGenerators.Password; /// /// Interface for password generators. diff --git a/src/AliasGenerators/Password/Implementations/SpamOkPasswordGenerator.cs b/src/AliasGenerators/Password/Implementations/SpamOkPasswordGenerator.cs index a2e127437..9c68b5995 100644 --- a/src/AliasGenerators/Password/Implementations/SpamOkPasswordGenerator.cs +++ b/src/AliasGenerators/Password/Implementations/SpamOkPasswordGenerator.cs @@ -4,9 +4,10 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- + namespace AliasGenerators.Password.Implementations; -using AliasGenerators.Implementations; +using AliasGenerators.Password; /// /// Implementation of IPasswordGenerator which generates passwords using the SpamOK library. diff --git a/src/AliasVault.Admin/AliasVault.Admin.csproj b/src/AliasVault.Admin/AliasVault.Admin.csproj new file mode 100644 index 000000000..7b526ff63 --- /dev/null +++ b/src/AliasVault.Admin/AliasVault.Admin.csproj @@ -0,0 +1,51 @@ + + + + net8.0 + enable + enable + aspnet-AliasVault.Admin-1DAADE35-C01B-43BB-B440-AA5E1E0B672D + Linux + + + + bin\Debug\net8.0\AliasVault.Admin.xml + true + + + + bin\Release\net8.0\AliasVault.Admin.xml + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + .dockerignore + + + + + + + + + + + + + + diff --git a/src/AliasVault.Admin/Auth/Components/InputTextField.razor b/src/AliasVault.Admin/Auth/Components/InputTextField.razor new file mode 100644 index 000000000..d0777464d --- /dev/null +++ b/src/AliasVault.Admin/Auth/Components/InputTextField.razor @@ -0,0 +1,41 @@ +@using System.Linq.Expressions + + + +@code { + /// + /// Gets or sets the ID of the input field. + /// + [Parameter] public string Id { get; set; } = null!; + + /// + /// Gets or sets the value of the input field. + /// + [Parameter] public string Value { get; set; } = null!; + + /// + /// Gets or sets the event callback that is triggered when the value changes. + /// + [Parameter] public EventCallback ValueChanged { get; set; } + + /// + /// Gets or sets the expression that identifies the value property. + /// + [Parameter] public Expression> ValueExpression { get; set; } = null!; + + /// + /// Gets or sets the placeholder text for the input field. + /// + [Parameter] public string Placeholder { get; set; } = null!; + + /// + /// Gets or sets additional attributes for the input field. + /// + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } = new(); +} diff --git a/src/AliasVault.Admin/Auth/Components/Logo.razor b/src/AliasVault.Admin/Auth/Components/Logo.razor new file mode 100644 index 000000000..dfd7fe97a --- /dev/null +++ b/src/AliasVault.Admin/Auth/Components/Logo.razor @@ -0,0 +1,5 @@ + + AliasVault + + + diff --git a/src/AliasVault.Admin/Auth/Layout/AuthLayout.razor b/src/AliasVault.Admin/Auth/Layout/AuthLayout.razor new file mode 100644 index 000000000..4a125b766 --- /dev/null +++ b/src/AliasVault.Admin/Auth/Layout/AuthLayout.razor @@ -0,0 +1,36 @@ +@inherits LayoutComponentBase +@using AliasVault.Admin.Auth.Components +@implements IDisposable +@inject NavigationManager NavigationManager + +
+ +
+ @Body +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ +@code { + /// + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } + + /// + protected override void OnInitialized() + { + NavigationManager.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + StateHasChanged(); + } +} diff --git a/src/AliasVault.Admin/Auth/Layout/AuthLayout.razor.css b/src/AliasVault.Admin/Auth/Layout/AuthLayout.razor.css new file mode 100644 index 000000000..0d26cfc24 --- /dev/null +++ b/src/AliasVault.Admin/Auth/Layout/AuthLayout.razor.css @@ -0,0 +1,18 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} diff --git a/src/AliasVault.Admin/Auth/Pages/AuthBase.cs b/src/AliasVault.Admin/Auth/Pages/AuthBase.cs new file mode 100644 index 000000000..1f87d306d --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/AuthBase.cs @@ -0,0 +1,71 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Auth.Pages; + +using AliasServerDb; +using AliasVault.Admin.Main.Components.Alerts; +using AliasVault.Admin.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; + +/// +/// Base auth page that all pages that are part of the auth (non-logged in part of website) should inherit from. +/// All pages that inherit from this class will require the user to be logged out. If user is logged in they +/// are automatically redirected to index page. +/// +public class AuthBase : OwningComponentBase +{ + /// + /// Gets or sets the logger. + /// + [Inject] + protected ILogger Logger { get; set; } = null!; + + /// + /// Gets or sets the navigation service. + /// + [Inject] + protected NavigationService NavigationService { get; set; } = null!; + + /// + /// Gets or sets the sign in manager. + /// + [Inject] + protected SignInManager SignInManager { get; set; } = null!; + + /// + /// Gets or sets the user manager. + /// + [Inject] + protected UserManager UserManager { get; set; } = null!; + + /// + /// Gets or sets the authentication state provider. + /// + [Inject] + protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!; + + /// + /// Gets or sets object which holds server validation errors to show in the UI. + /// + protected ServerValidationErrors ServerValidationErrors { get; set; } = new(); + + /// + protected override async Task OnInitializedAsync() + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + // Redirect to home if the user is already authenticated + if (SignInManager.IsSignedIn(user)) + { + NavigationService.RedirectTo("/"); + } + } +} diff --git a/src/AliasVault.Admin/Auth/Pages/ForgotPassword.razor b/src/AliasVault.Admin/Auth/Pages/ForgotPassword.razor new file mode 100644 index 000000000..d53d3b129 --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/ForgotPassword.razor @@ -0,0 +1,8 @@ +@page "/user/forgot-password" + +Forgot your password? + +

+ Forgot your password? +

+

If you have forgotten your password, please consult with the server admin.

diff --git a/src/AliasVault.Admin/Auth/Pages/Lockout.razor b/src/AliasVault.Admin/Auth/Pages/Lockout.razor new file mode 100644 index 000000000..b7cbc5108 --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/Lockout.razor @@ -0,0 +1,8 @@ +@page "/user/lockout" + +Locked out + +
+

Locked out

+

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

+
diff --git a/src/AliasVault.Admin/Auth/Pages/Login.razor b/src/AliasVault.Admin/Auth/Pages/Login.razor new file mode 100644 index 000000000..3a42f10ef --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/Login.razor @@ -0,0 +1,97 @@ +@page "/user/login" + +Log in + +

+ Sign in to AliasVault Admin +

+ + + + + +
+ + + +
+
+ + + +
+ +
+
+ +
+
+ +
+ Lost Password? +
+ + +
+ + +@code { + [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); + + [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + if (HttpMethods.IsGet(HttpContext.Request.Method)) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + } + } + + /// + /// Logs in the user. + /// + protected async Task LoginUser() + { + ServerValidationErrors.Clear(); + + var result = await SignInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: true); + if (result.Succeeded) + { + Logger.LogInformation("User logged in."); + NavigationService.RedirectTo(ReturnUrl ?? "/"); + } + else if (result.RequiresTwoFactor) + { + NavigationService.RedirectTo( + "user/loginWith2fa", + new Dictionary { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe }); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User account locked out."); + NavigationService.RedirectTo("user/lockout"); + } + else + { + ServerValidationErrors.AddError("Error: Invalid login attempt."); + } + } + + private sealed class InputModel + { + [Required] public string UserName { get; set; } = ""; + + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } = ""; + + [Display(Name = "Remember me?")] public bool RememberMe { get; set; } + } + +} diff --git a/src/AliasVault.Admin/Auth/Pages/LoginWith2fa.razor b/src/AliasVault.Admin/Auth/Pages/LoginWith2fa.razor new file mode 100644 index 000000000..33acae856 --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/LoginWith2fa.razor @@ -0,0 +1,98 @@ +@page "/user/loginWith2fa" + +Two-factor authentication + +

+ Two-factor authentication +

+ + + +

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

+
+ + + + + +
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+

+ Don't have access to your authenticator device? You can + log in with a recovery code. +

+ +@code { + private AdminUser user = default!; + + [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); + + [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } + + [SupplyParameterFromQuery] private bool RememberMe { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // Ensure the user has gone through the username & password screen first + user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? + throw new InvalidOperationException("Unable to load two-factor authentication user."); + } + + /// + /// Submits the form. + /// + private async Task OnValidSubmitAsync() + { + ServerValidationErrors.Clear(); + + var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); + var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine); + var userId = await UserManager.GetUserIdAsync(user); + + if (result.Succeeded) + { + Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); + NavigationService.RedirectTo(ReturnUrl); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User with ID '{UserId}' account locked out.", userId); + NavigationService.RedirectTo("user/lockout"); + } + else + { + Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId); + ServerValidationErrors.AddError("Error: Invalid authenticator code."); + } + } + + private sealed class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Authenticator code")] + public string? TwoFactorCode { get; set; } + + [Display(Name = "Remember this machine")] + public bool RememberMachine { get; set; } + } + +} diff --git a/src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor b/src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor new file mode 100644 index 000000000..60cd065ee --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor @@ -0,0 +1,81 @@ +@page "/user/loginWithRecoveryCode" + +Recovery code verification + +

+ Recovery code verification +

+ + + +

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

+
+ + + +
+ + + +
+ +
+
+ +@code { + private AdminUser user = default!; + + [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); + + [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + // Ensure the user has gone through the username & password screen first + user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? + throw new InvalidOperationException("Unable to load two-factor authentication user."); + } + + /// + /// Submits the form. + /// + private async Task OnValidSubmitAsync() + { + ServerValidationErrors.Clear(); + + var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty); + + var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + + var userId = await UserManager.GetUserIdAsync(user); + + if (result.Succeeded) + { + Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId); + NavigationService.RedirectTo(ReturnUrl); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User account locked out."); + NavigationService.RedirectTo("user/lockout"); + } + else + { + Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId); + ServerValidationErrors.AddError("Error: Invalid recovery code entered."); + } + } + + private sealed class InputModel + { + [Required] + [DataType(DataType.Text)] + [Display(Name = "Recovery Code")] + public string RecoveryCode { get; set; } = ""; + } + +} diff --git a/src/AliasVault.Admin/Auth/Pages/Logout.razor b/src/AliasVault.Admin/Auth/Pages/Logout.razor new file mode 100644 index 000000000..d591278a0 --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/Logout.razor @@ -0,0 +1,35 @@ +@page "/user/logout" +@inject GlobalNotificationService GlobalNotificationService + +@code { + /// + protected override async Task OnInitializedAsync() + { + // Sign out the user. + // NOTE: the try/catch below is a workaround for the issue that the sign out does not work when + // the server session is already started. + try + { + try + { + await SignInManager.SignOutAsync(); + GlobalNotificationService.ClearMessages(); + + // Redirect to the home page with hard refresh. + NavigationService.RedirectTo("/", true); + } + catch + { + // Hard refresh current page if sign out fails. When an interactive server session is already started + // the sign out will fail because it tries to mutate cookies which is only possible when the server + // session is not started yet. + NavigationService.RedirectTo(NavigationService.Uri, true); + } + } + catch + { + // Redirect to the home page with hard refresh. + NavigationService.RedirectTo("/", true); + } + } +} diff --git a/src/AliasVault.Admin/Auth/Pages/_Imports.razor b/src/AliasVault.Admin/Auth/Pages/_Imports.razor new file mode 100644 index 000000000..b13636f4d --- /dev/null +++ b/src/AliasVault.Admin/Auth/Pages/_Imports.razor @@ -0,0 +1,11 @@ +@inherits AuthBase +@using System.ComponentModel.DataAnnotations +@using AliasVault.Admin.Auth.Components +@using AliasVault.Admin.Auth.Layout +@using AliasVault.Admin.Main.Components.Alerts +@using AliasVault.Admin.Main.Components.Layout +@using AliasVault.Admin.Main.Layout +@using AliasVault.Admin.Services +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@layout AuthLayout diff --git a/src/AliasVault.Admin/Auth/Providers/RevalidatingAuthenticationStateProvider.cs b/src/AliasVault.Admin/Auth/Providers/RevalidatingAuthenticationStateProvider.cs new file mode 100644 index 000000000..ca7bd4ea7 --- /dev/null +++ b/src/AliasVault.Admin/Auth/Providers/RevalidatingAuthenticationStateProvider.cs @@ -0,0 +1,67 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Auth.Providers; + +using System.Security.Claims; +using AliasServerDb; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +/// +/// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user +/// every 30 minutes an interactive circuit is connected. +/// +/// ILoggerFactory instance. +/// IServiceScopeFactory instance. +/// IOptions instance. +internal sealed class RevalidatingAuthenticationStateProvider( + ILoggerFactory loggerFactory, + IServiceScopeFactory scopeFactory, + IOptions options) + : RevalidatingServerAuthenticationStateProvider(loggerFactory) +{ + /// + /// Gets the revalidation interval. + /// + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + + /// + /// Validate the authentication state. + /// + /// AuthenticationState instance. + /// CancellationToken. + /// Boolean indicating whether the currently logged on user is still valid. + protected override async Task ValidateAuthenticationStateAsync( + AuthenticationState authenticationState, CancellationToken cancellationToken) + { + // Get the user manager from a new scope to ensure it fetches fresh data + await using var scope = scopeFactory.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } + + private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return false; + } + + if (!userManager.SupportsUserSecurityStamp) + { + return true; + } + + var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); + var userStamp = await userManager.GetSecurityStampAsync(user); + return principalStamp == userStamp; + } +} diff --git a/src/AliasVault.Admin/Auth/_Imports.razor b/src/AliasVault.Admin/Auth/_Imports.razor new file mode 100644 index 000000000..ee3fcb101 --- /dev/null +++ b/src/AliasVault.Admin/Auth/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using AliasVault.Admin +@using AliasVault.Admin.Main +@using AliasServerDb diff --git a/src/AliasVault.Admin/Config.cs b/src/AliasVault.Admin/Config.cs new file mode 100644 index 000000000..04223e1be --- /dev/null +++ b/src/AliasVault.Admin/Config.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin; + +/// +/// Configuration class for the Admin project with values loaded from environment variables. +/// +public class Config +{ + /// + /// Gets or sets the admin password hash which is generated by install.sh and will be set + /// as the default password for the admin user. + /// + public string AdminPasswordHash { get; set; } = "false"; + + /// + /// Gets or sets the last time the password was changed. This is used to check if the + /// password hash generated by install.sh should replace the current password hash if user already exists. + /// + public DateTime LastPasswordChanged { get; set; } = DateTime.MinValue; +} diff --git a/src/AliasVault.Admin/Dockerfile b/src/AliasVault.Admin/Dockerfile new file mode 100644 index 000000000..ef526a804 --- /dev/null +++ b/src/AliasVault.Admin/Dockerfile @@ -0,0 +1,32 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 8082 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release + +WORKDIR /src + +# Copy the project files and restore dependencies +COPY ["src/AliasVault.Admin/AliasVault.Admin.csproj", "src/AliasVault.Admin/"] +COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"] +RUN dotnet restore "src/AliasVault.Admin/AliasVault.Admin.csproj" + +# Copy the rest of the application code +COPY . . + +# Build the WebApi project +WORKDIR "/src/src/AliasVault.Admin" +RUN dotnet build "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/build + +# Publish the application to the /app/publish directory in the container +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +EXPOSE 8082 +ENV ASPNETCORE_URLS=http://+:8082 +ENTRYPOINT ["dotnet", "AliasVault.Admin.dll"] diff --git a/src/AliasVault.Admin/Main/App.razor b/src/AliasVault.Admin/Main/App.razor new file mode 100644 index 000000000..45749aa19 --- /dev/null +++ b/src/AliasVault.Admin/Main/App.razor @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; + + private IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/user") + ? null + : InteractiveServer; +} diff --git a/src/AliasVault.Admin/Main/Components/Alerts/AlertMessageError.razor b/src/AliasVault.Admin/Main/Components/Alerts/AlertMessageError.razor new file mode 100644 index 000000000..c6613f4c8 --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Alerts/AlertMessageError.razor @@ -0,0 +1,18 @@ +@inherits ComponentBase + +@if (Message == string.Empty) +{ + return; +} + + + +@code { + /// + /// The message to show. + /// + [Parameter] + public string Message { get; set; } = string.Empty; +} diff --git a/src/AliasVault.Admin/Main/Components/Alerts/AlertMessageSuccess.razor b/src/AliasVault.Admin/Main/Components/Alerts/AlertMessageSuccess.razor new file mode 100644 index 000000000..7ff711e90 --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Alerts/AlertMessageSuccess.razor @@ -0,0 +1,18 @@ +@inherits ComponentBase + +@if (Message == string.Empty) +{ + return; +} + + + +@code { + /// + /// The message to show. + /// + [Parameter] + public string Message { get; set; } = string.Empty; +} diff --git a/src/AliasVault.Admin/Main/Components/Alerts/GlobalNotificationDisplay.razor b/src/AliasVault.Admin/Main/Components/Alerts/GlobalNotificationDisplay.razor new file mode 100644 index 000000000..4cd4a7b89 --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Alerts/GlobalNotificationDisplay.razor @@ -0,0 +1,71 @@ +@implements IDisposable + +@foreach (var message in Messages) +{ + if (message.Key == "success") + { + + } +} +@foreach (var message in Messages) +{ + if (message.Key == "error") + { + + } +} + +@code { + private List> Messages { get; set; } = new(); + private bool _onChangeSubscribed = false; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + // We subscribe to the OnChange event of the PortalMessageService to update the UI when a new message is added + RefreshAddMessages(); + GlobalNotificationService.OnChange += RefreshAddMessages; + _onChangeSubscribed = true; + } + } + + /// + public void Dispose() + { + // We unsubscribe from the OnChange event of the PortalMessageService when the component is disposed + if (_onChangeSubscribed) + { + GlobalNotificationService.OnChange -= RefreshAddMessages; + _onChangeSubscribed = false; + } + } + + /// + /// Refreshes the messages by adding any new messages from the PortalMessageService. + /// + private void RefreshAddMessages() + { + // We retrieve any additional messages from the GlobalNotificationService that we do not yet have. + var newMessages = GlobalNotificationService.GetMessagesForDisplay(); + foreach (var message in newMessages) + { + if (!Messages.Exists(m => m.Key == message.Key && m.Value == message.Value)) + { + Messages.Add(message); + } + } + + // Remove messages that are no longer in the GlobalNotificationService and have already been displayed. + var messagesToRemove = Messages.Where(m => !newMessages.Exists(nm => nm.Key == m.Key && nm.Value == m.Value)).ToList(); + foreach (var message in messagesToRemove) + { + Messages.Remove(message); + } + + StateHasChanged(); + } +} diff --git a/src/AliasVault.Admin/Main/Components/Alerts/ServerValidationErrors.razor b/src/AliasVault.Admin/Main/Components/Alerts/ServerValidationErrors.razor new file mode 100644 index 000000000..7777532b6 --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Alerts/ServerValidationErrors.razor @@ -0,0 +1,29 @@ +@if (_errors.Any()) +{ + @foreach (var error in _errors) + { + + } +} + +@code { + private readonly List _errors = []; + + /// + /// Adds a server validation error. + /// + public void AddError(string error) + { + _errors.Add(error); + StateHasChanged(); + } + + /// + /// Clears the server validation errors. + /// + public void Clear() + { + _errors.Clear(); + StateHasChanged(); + } +} diff --git a/src/AliasVault.Admin/Main/Components/Layout/Breadcrumb.razor b/src/AliasVault.Admin/Main/Components/Layout/Breadcrumb.razor new file mode 100644 index 000000000..c435443bd --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Layout/Breadcrumb.razor @@ -0,0 +1,53 @@ +@inherits ComponentBase + + + + +@code { + /// + /// Gets or sets the list of breadcrumb items. + /// + [Parameter] + public List BreadcrumbItems { get; set; } = new(); + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + // Remove first item if it is the home page + if (BreadcrumbItems.Any() && BreadcrumbItems[0].DisplayName == "Home") + { + BreadcrumbItems.RemoveAt(0); + } + } +} diff --git a/src/AliasVault.Admin/Main/Components/Layout/LayoutPageTitle.razor b/src/AliasVault.Admin/Main/Components/Layout/LayoutPageTitle.razor new file mode 100644 index 000000000..b858de44d --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Layout/LayoutPageTitle.razor @@ -0,0 +1,9 @@ +@ChildContent - AliasVault Admin + +@code { + /// + /// Child content. + /// + [Parameter] + public RenderFragment ChildContent { get; set; } = default!; +} diff --git a/src/AliasVault.Admin/Main/Components/RedirectToLogin.razor b/src/AliasVault.Admin/Main/Components/RedirectToLogin.razor new file mode 100644 index 000000000..b0ef5c6ca --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/RedirectToLogin.razor @@ -0,0 +1,17 @@ +@inject NavigationManager NavigationManager + +@code { + /// + protected override void OnInitialized() + { + var returnUrl = NavigationManager.Uri; + if (string.IsNullOrWhiteSpace(returnUrl) || returnUrl == "/") + { + NavigationManager.NavigateTo($"user/login", forceLoad: true); + } + else + { + NavigationManager.NavigateTo($"user/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true); + } + } +} diff --git a/src/AliasVault.Admin/Main/Components/Refresh/RefreshButton.razor b/src/AliasVault.Admin/Main/Components/Refresh/RefreshButton.razor new file mode 100644 index 000000000..9ce054a63 --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/Refresh/RefreshButton.razor @@ -0,0 +1,52 @@ +@using System.Timers + + + +@code { + /// + /// The event to call in the parent when the button is clicked. + /// + [Parameter] public EventCallback OnRefresh { get; set; } + + /// + /// The text to display on the button. + /// + [Parameter] public string ButtonText { get; set; } = "Refresh"; + + private bool IsRefreshing; + private Timer Timer = new(); + + private async Task HandleClick() + { + if (IsRefreshing) return; + + IsRefreshing = true; + await OnRefresh.InvokeAsync(); + + Timer = new Timer(500); + Timer.Elapsed += (sender, args) => + { + IsRefreshing = false; + Timer.Dispose(); + InvokeAsync(StateHasChanged); + }; + Timer.Start(); + } + + private string GetButtonClasses() + { + return $"flex items-center px-3 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-4 focus:ring-primary-300 dark:focus:ring-primary-800 {(IsRefreshing ? "bg-gray-400 cursor-not-allowed" : "bg-primary-700 hover:bg-primary-800 dark:bg-primary-600 dark:hover:bg-primary-700")}"; + } + + private string GetIconClasses() + { + return $"w-4 h-4 {(IsRefreshing ? "animate-spin" : "")}"; + } +} diff --git a/src/AliasVault.Admin/Main/Components/WorkerStatus/Services.razor b/src/AliasVault.Admin/Main/Components/WorkerStatus/Services.razor new file mode 100644 index 000000000..e1d8e4394 --- /dev/null +++ b/src/AliasVault.Admin/Main/Components/WorkerStatus/Services.razor @@ -0,0 +1,156 @@ +@using AliasVault.WorkerStatus.Database +@inherits MainBase + + + +@code { + private List serviceStatus = []; + private bool initInProgress; + private bool firstInitPageDone; + + private bool smtpStatus; + private bool smtpPending; + + private string GetSmtpButtonClasses() + { + string buttonClass = "cursor-pointer "; + + if (smtpPending || !firstInitPageDone) + { + buttonClass += "bg-gray-600"; + } + else if (smtpStatus) + { + buttonClass += "bg-green-600"; + } + else + { + buttonClass += "bg-red-600"; + } + + return buttonClass; + } + + private async void SmtpClick() + { + smtpPending = true; + StateHasChanged(); + + smtpStatus = !smtpStatus; + await UpdateSmtpStatus(smtpStatus); + + smtpPending = false; + StateHasChanged(); + } + + private async Task InitPage() + { + if (initInProgress) + { + return; + } + + if (!smtpPending) + { + initInProgress = true; + serviceStatus = await GetServiceStatuses(); + // Service status checks if the status is "Started" and was lastAlive + // (so actually reported itself) in the last 5 minutes. + var smtpEntry = serviceStatus.Find(x => x.ServiceName == "AliasVault.SmtpService"); + if (smtpEntry != null) + { + smtpStatus = (DateTime.Now <= smtpEntry.Heartbeat.AddMinutes(5) && smtpEntry.CurrentStatus == "Started"); + } + + initInProgress = false; + firstInitPageDone = true; + + await InvokeAsync(() => StateHasChanged()); + } + else + { + // Service status changing in progress, don't issue parallel requests.. + } + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await InitPage(); + } + } + + /// + /// Gets the service statuses. + /// + public async Task> GetServiceStatuses() + { + return await DbContext.WorkerServiceStatuses.ToListAsync(); + } + + /// + /// Update the service statuses. + /// + public async Task UpdateServiceStatus(string serviceName, bool newStatus) + { + string translateStatus = ""; + switch (newStatus) + { + case true: + translateStatus = "Started"; + break; + case false: + translateStatus = "Stopped"; + break; + } + + var entry = await DbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync(); + if (entry != null) + { + entry.DesiredStatus = translateStatus; + await DbContext.SaveChangesAsync(); + + // Wait for service to have stopped with ACK + var timeout = DateTime.Now.AddSeconds(30); + while (true) + { + if (DateTime.Now > timeout) + { + // Timeout + return false; + } + + var newdbContext = await DbContextFactory.CreateDbContextAsync(); + var check = await newdbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync(); + if (check.CurrentStatus == translateStatus) + { + // Done + return true; + } + await Task.Delay(1000); + } + } + + return false; + } + + /// + /// Update the SMTP service status. + /// + public async Task UpdateSmtpStatus(bool newStatus) + { + return await UpdateServiceStatus("AliasVault.SmtpService", newStatus); + } +} diff --git a/src/AliasVault.Admin/Main/Layout/Footer.razor b/src/AliasVault.Admin/Main/Layout/Footer.razor new file mode 100644 index 000000000..694980a58 --- /dev/null +++ b/src/AliasVault.Admin/Main/Layout/Footer.razor @@ -0,0 +1,10 @@ + diff --git a/src/AliasVault.Admin/Main/Layout/MainLayout.razor b/src/AliasVault.Admin/Main/Layout/MainLayout.razor new file mode 100644 index 000000000..95188e3e8 --- /dev/null +++ b/src/AliasVault.Admin/Main/Layout/MainLayout.razor @@ -0,0 +1,39 @@ +@inherits LayoutComponentBase +@implements IDisposable +@inject NavigationManager NavigationManager + + +
+
+
+ @Body +
+
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ +@code { + /// + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } + + /// + protected override void OnInitialized() + { + NavigationManager.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + StateHasChanged(); + } +} + diff --git a/src/AliasVault.Admin/Main/Layout/MainLayout.razor.css b/src/AliasVault.Admin/Main/Layout/MainLayout.razor.css new file mode 100644 index 000000000..df8c10ff2 --- /dev/null +++ b/src/AliasVault.Admin/Main/Layout/MainLayout.razor.css @@ -0,0 +1,18 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/src/AliasVault.Admin/Main/Layout/TopMenu.razor b/src/AliasVault.Admin/Main/Layout/TopMenu.razor new file mode 100644 index 000000000..c0aa0e5bc --- /dev/null +++ b/src/AliasVault.Admin/Main/Layout/TopMenu.razor @@ -0,0 +1,146 @@ +@inherits MainBase +@implements IDisposable + +
+ + @if (isMobileMenuOpen) + { + + } +
+ +@code { + private bool isMenuOpen = false; + private bool isMobileMenuOpen = false; + private string _username { get; set; } = ""; + + /// + /// Close the menu. + /// + [JSInvokable] + public void CloseMenu() + { + isMenuOpen = false; + isMobileMenuOpen = false; + StateHasChanged(); + } + + /// + /// Dispose method. + /// + public void Dispose() + { + NavigationService.LocationChanged -= LocationChanged; + } + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + _username = GetUsername(); + NavigationService.LocationChanged += LocationChanged; + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + if (firstRender) + { + await Js.InvokeVoidAsync("window.initTopMenu"); + DotNetObjectReference objRef = DotNetObjectReference.Create(this); + await Js.InvokeVoidAsync("window.registerClickOutsideHandler", objRef); + } + } + + private void LocationChanged(object? sender, LocationChangedEventArgs e) + { + isMenuOpen = false; + isMobileMenuOpen = false; + StateHasChanged(); + } + + private void ToggleMenu() + { + isMenuOpen = !isMenuOpen; + } + + private void ToggleMobileMenu() + { + isMobileMenuOpen = !isMobileMenuOpen; + } +} diff --git a/src/AliasVault.Admin/Main/Models/BreadcrumbItem.cs b/src/AliasVault.Admin/Main/Models/BreadcrumbItem.cs new file mode 100644 index 000000000..2e18b9a56 --- /dev/null +++ b/src/AliasVault.Admin/Main/Models/BreadcrumbItem.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Breadcrumb item model. +/// +public class BreadcrumbItem +{ + /// + /// Gets or sets the display name. + /// + public string? DisplayName { get; set; } + + /// + /// Gets or sets the URL. + /// + public string? Url { get; set; } +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor new file mode 100644 index 000000000..1f5c115ee --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor @@ -0,0 +1,88 @@ +@page "/account/manage/change-password" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager +@inject ILogger Logger + +Change password + +
+

Change password

+ + + +
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+ +@code { + [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); + + private async Task OnValidSubmitAsync() + { + var changePasswordResult = await UserManager.ChangePasswordAsync(UserService.User(), Input.OldPassword, Input.NewPassword); + var user = UserService.User(); + user.LastPasswordChanged = DateTime.UtcNow; + await UserService.UpdateUserAsync(user); + + // Clear the password fields + Input.OldPassword = ""; + Input.NewPassword = ""; + Input.ConfirmPassword = ""; + + if (!changePasswordResult.Succeeded) + { + GlobalNotificationService.AddErrorMessage($"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}", true); + return; + } + + Logger.LogInformation("User changed their password successfully."); + + GlobalNotificationService.AddSuccessMessage("Your password has been changed.", true); + + NavigationService.RedirectToCurrentPage(); + } + + private sealed class InputModel + { + [Required] + [DataType(DataType.Password)] + [Display(Name = "Current password")] + public string OldPassword { get; set; } = ""; + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } = ""; + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } = ""; + } + +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/Components/ShowRecoveryCodes.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/Components/ShowRecoveryCodes.razor new file mode 100644 index 000000000..f9d3549a4 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/Components/ShowRecoveryCodes.razor @@ -0,0 +1,25 @@ +

Recovery codes

+ +
+ @foreach (var recoveryCode in RecoveryCodes) + { +
+ @recoveryCode +
+ } +
+ +@code { + /// + /// The recovery codes to show. + /// + [Parameter] + public string[] RecoveryCodes { get; set; } = []; +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/Disable2fa.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/Disable2fa.razor new file mode 100644 index 000000000..a4486ed3b --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/Disable2fa.razor @@ -0,0 +1,54 @@ +@page "/account/manage/disable-2fa" + +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager +@inject ILogger Logger + +Disable two-factor authentication (2FA) + +

Disable two-factor authentication (2FA)

+ + + +
+
+ + + +
+ +@code { + /// + protected override async Task OnInitializedAsync() + { + if (!await UserManager.GetTwoFactorEnabledAsync(UserService.User())) + { + throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); + } + } + + private async Task OnSubmitAsync() + { + var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false); + if (!disable2FaResult.Succeeded) + { + throw new InvalidOperationException("Unexpected error occurred disabling 2FA."); + } + + var userId = await UserManager.GetUserIdAsync(UserService.User()); + Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId); + + // Reload current page. + NavigationService.RedirectTo(NavigationService.Uri, forceLoad: true); + } + +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor new file mode 100644 index 000000000..0ecb4d062 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor @@ -0,0 +1,170 @@ +@page "/account/manage/enable-authenticator" + +@using System.ComponentModel.DataAnnotations +@using System.Globalization +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager +@inject UrlEncoder UrlEncoder +@inject ILogger Logger + +Configure authenticator app + +@if (recoveryCodes is not null) +{ + +} +else +{ +
+

Configure authenticator app

+
+

To use an authenticator app go through the following steps:

+
    +
  1. +

    + Download a two-factor authenticator app like Microsoft Authenticator for + Android and + iOS or + Google Authenticator for + Android and + iOS. +

    +
  2. +
  3. +

    Scan the QR Code or enter this key @sharedKey into your two factor authenticator app. Spaces and casing do not matter.

    +
    +
  4. +
  5. +

    + Once you have scanned the QR code or input the key above, your two factor authentication app will provide you + with a unique code. Enter the code in the confirmation box below. +

    +
    + + +
    + + + +
    +
    + +
    + +
    +
    +
  6. +
+
+
+} + +@code { + private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; + + private string? sharedKey; + private string? authenticatorUri; + private IEnumerable? recoveryCodes; + + [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + await LoadSharedKeyAndQrCodeUriAsync(UserService.User()); + + await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri"); + } + + private async Task OnValidSubmitAsync() + { + // Strip spaces and hyphens + var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty); + + var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync( + UserService.User(), UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + + if (!is2faTokenValid) + { + GlobalNotificationService.AddErrorMessage("Error: Verification code is invalid."); + return; + } + + await UserManager.SetTwoFactorEnabledAsync(UserService.User(), true); + var userId = await UserManager.GetUserIdAsync(UserService.User()); + Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId); + + GlobalNotificationService.AddSuccessMessage("Your authenticator app has been verified."); + + if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0) + { + recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10); + } + else + { + // Navigate back to the two factor authentication page. + NavigationService.RedirectTo("account/manage/2fa", forceLoad: true); + } + } + + private async ValueTask LoadSharedKeyAndQrCodeUriAsync(AdminUser user) + { + // Load the authenticator key & QR code URI to display on the form + var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(unformattedKey)) + { + await UserManager.ResetAuthenticatorKeyAsync(user); + unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + } + + sharedKey = FormatKey(unformattedKey!); + + var username = await UserManager.GetUserNameAsync(user); + authenticatorUri = GenerateQrCodeUri(username!, unformattedKey!); + } + + private string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + int currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' '); + currentPosition += 4; + } + + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private string GenerateQrCodeUri(string username, string unformattedKey) + { + return string.Format( + CultureInfo.InvariantCulture, + AuthenticatorUriFormat, + UrlEncoder.Encode("AliasVault Admin"), + UrlEncoder.Encode(username), + unformattedKey); + } + + private sealed class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Verification Code")] + public string Code { get; set; } = ""; + } + +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/GenerateRecoveryCodes.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/GenerateRecoveryCodes.razor new file mode 100644 index 000000000..b33340c08 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/GenerateRecoveryCodes.razor @@ -0,0 +1,61 @@ +@page "/account/manage/generate-recovery-codes" + +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager +@inject ILogger Logger + +Generate two-factor authentication (2FA) recovery codes + +@if (recoveryCodes is not null) +{ + +} +else +{ +

Generate two-factor authentication (2FA) recovery codes

+ +
+ +
+} + +@code { + private IEnumerable? recoveryCodes; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User()); + if (!isTwoFactorEnabled) + { + throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled."); + } + } + + private async Task GenerateCodes() + { + var userId = await UserManager.GetUserIdAsync(UserService.User()); + recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10); + GlobalNotificationService.AddSuccessMessage("You have generated new recovery codes."); + + Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); + } + +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/Index.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/Index.razor new file mode 100644 index 000000000..fe21aa3cc --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/Index.razor @@ -0,0 +1,69 @@ +@page "/account/manage" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager + +Profile + +
+

Profile

+ + + + +
+ + +
+
+ + + +
+
+ +
+
+
+ +@code { + private string? username; + private string? phoneNumber; + + [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + username = await UserManager.GetUserNameAsync(UserService.User()); + phoneNumber = await UserManager.GetPhoneNumberAsync(UserService.User()); + + Input.PhoneNumber ??= phoneNumber; + } + + private async Task OnValidSubmitAsync() + { + if (Input.PhoneNumber != phoneNumber) + { + var setPhoneResult = await UserManager.SetPhoneNumberAsync(UserService.User(), Input.PhoneNumber); + if (!setPhoneResult.Succeeded) + { + GlobalNotificationService.AddErrorMessage("Phone number could not be set", true); + } + } + + GlobalNotificationService.AddSuccessMessage("Your profile has been updated", true); + } + + private sealed class InputModel + { + [Phone] + [Display(Name = "Phone number")] + public string? PhoneNumber { get; set; } + } + +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/ResetAuthenticator.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/ResetAuthenticator.razor new file mode 100644 index 000000000..48ed7d98c --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/ResetAuthenticator.razor @@ -0,0 +1,44 @@ +@page "/account/manage/reset-authenticator" + +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager +@inject ILogger Logger + +Reset authenticator key + +

Reset authenticator key

+ +
+
+ + + +
+ +@code { + private async Task OnSubmitAsync() + { + await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false); + await UserManager.ResetAuthenticatorKeyAsync(UserService.User()); + var userId = await UserManager.GetUserIdAsync(UserService.User()); + Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId); + + GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key.", true); + + NavigationService.RedirectTo( + "account/manage/2fa"); + } + +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/TwoFactorAuthentication.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/TwoFactorAuthentication.razor new file mode 100644 index 000000000..b10fc8b1b --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/TwoFactorAuthentication.razor @@ -0,0 +1,79 @@ +@page "/account/manage/2fa" + +@using Microsoft.AspNetCore.Identity + +@inject UserManager UserManager +@inject SignInManager SignInManager + +Two-factor authentication (2FA) + +@if (is2FaEnabled) +{ +
+

Two-factor authentication (2FA)

+ + @if (recoveryCodesLeft == 0) + { +
+

You have no recovery codes left.

+

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

+
+ } + else if (recoveryCodesLeft == 1) + { +
+

You have 1 recovery code left.

+

You can generate a new set of recovery codes.

+
+ } + else if (recoveryCodesLeft <= 3) + { +
+

You have @recoveryCodesLeft recovery codes left.

+

You should generate a new set of recovery codes.

+
+ } + + +
+} + +
+

Authenticator app

+ +
+ +@code { + private bool hasAuthenticator; + private int recoveryCodesLeft; + private bool is2FaEnabled; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(UserService.User()) is not null; + is2FaEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User()); + recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(UserService.User()); + } +} diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/_Imports.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/_Imports.razor new file mode 100644 index 000000000..afab743e4 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/_Imports.razor @@ -0,0 +1,6 @@ +@layout ManageLayout +@inherits MainBase +@using AliasVault.Admin.Auth +@using AliasVault.Admin.Main.Pages.Account.Manage.Components +@using AliasVault.Admin.Main.Components.Layout +@attribute [Microsoft.AspNetCore.Authorization.Authorize] diff --git a/src/AliasVault.Admin/Main/Pages/Account/ManageLayout.razor b/src/AliasVault.Admin/Main/Pages/Account/ManageLayout.razor new file mode 100644 index 000000000..af9416a3e --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/ManageLayout.razor @@ -0,0 +1,26 @@ +@inherits LayoutComponentBase +@using AliasVault.Admin.Main.Layout +@layout MainLayout + +
+
+ + +
+

Manage account

+
+

Manage your profile here.

+
+
+ +
+
+
+
+ +
+
+ @Body +
+
+
diff --git a/src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor b/src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor new file mode 100644 index 000000000..43a9b8967 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor @@ -0,0 +1,15 @@ +@using Microsoft.AspNetCore.Identity + +@inject SignInManager SignInManager + +
    +
  • + Profile +
  • +
  • + Password +
  • +
  • + Two-factor authentication +
  • +
diff --git a/src/AliasVault.Admin/Main/Pages/Emails.razor b/src/AliasVault.Admin/Main/Pages/Emails.razor new file mode 100644 index 000000000..be3f0f62e --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Emails.razor @@ -0,0 +1,104 @@ +@page "/emails" +@using AliasVault.RazorComponents +@using Azure + +Emails + +
+
+ +
+

Emails

+ +
+

This page gives an overview of recently received mails by this AliasVault server.

+
+
+ +@if (IsLoading) +{ +
+ Loading... +
+} +else +{ +
+ + + + + + + + + + + + + + + + @foreach (var email in EmailList) + { + + + + + + + + + + } + +
IDTimeFromToSubjectPreviewAttachments
@email.Id@email.DateSystem.ToString("yyyy-MM-dd HH:mm") + @email.FromLocal@@@email.FromDomain + + @email.ToLocal@@@email.ToDomain + @email.Subject + @email.MessagePreview + + @email.Attachments.Count +
+
+} + +@code { + private List EmailList { get; set; } = []; + private bool IsLoading { get; set; } = true; + private int CurrentPage { get; set; } = 1; + private int PageSize { get; set; } = 50; + private int TotalRecords { get; set; } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await RefreshData(); + } + } + + private void HandlePageChanged(int newPage) + { + CurrentPage = newPage; + _ = RefreshData(); + } + + private async Task RefreshData() + { + IsLoading = true; + StateHasChanged(); + + TotalRecords = await DbContext.Emails.CountAsync(); + EmailList = await DbContext.Emails + .OrderByDescending(x => x.DateSystem) + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + IsLoading = false; + StateHasChanged(); + } +} diff --git a/src/AliasVault.Admin/Main/Pages/Error.razor b/src/AliasVault.Admin/Main/Pages/Error.razor new file mode 100644 index 000000000..8313ecd82 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Error.razor @@ -0,0 +1,37 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

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

+ +@code{ + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + /// + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + +} diff --git a/src/AliasVault.Admin/Main/Pages/Home.razor b/src/AliasVault.Admin/Main/Pages/Home.razor new file mode 100644 index 000000000..60deb63cb --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Home.razor @@ -0,0 +1,15 @@ +@page "/" +@inherits MainBase + +Home + +
+
+ +
+

AliasVault Admin

+
+

Welcome to the AliasVault admin portal.

+
+
+ diff --git a/src/AliasVault.Admin/Main/Pages/Logs.razor b/src/AliasVault.Admin/Main/Pages/Logs.razor new file mode 100644 index 000000000..4d0ec7ee7 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/Logs.razor @@ -0,0 +1,152 @@ +@page "/logs" +@using AliasVault.RazorComponents + +Logs + +
+
+ +
+

Logs

+ +
+

This page gives an overview of recent system logs.

+
+
+ +@if (IsLoading) +{ +
+ Loading... +
+} +else +{ +
+ + +
+ +
+ + + + + + + + + + + + @foreach (var log in LogList) + { + + + + + + @{ + string bgColor; + + switch (log.Level) + { + case "Information": + bgColor = "bg-blue-500"; + break; + case "Error": + bgColor = "bg-red-500"; + break; + case "Warning": + bgColor = "bg-yellow-500"; + break; + case "Debug": + bgColor = "bg-green-500"; + break; + default: + bgColor = "bg-gray-500"; + break; + } + } + + + + } + +
IDTimeApplicationLevelMessage
@log.Id@log.TimeStamp.ToString("yyyy-MM-dd HH:mm")@log.Application + + @log.Level + + @log.Message
+
+} + +@code { + private List LogList { get; set; } = []; + private bool IsLoading { get; set; } = true; + private int CurrentPage { get; set; } = 1; + private int PageSize { get; set; } = 50; + private int TotalRecords { get; set; } + + private string _searchTerm = string.Empty; + private string SearchTerm + { + get => _searchTerm; + set + { + if (_searchTerm != value) + { + _searchTerm = value; + _ = RefreshData(); + } + } + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await RefreshData(); + } + } + + private void HandlePageChanged(int newPage) + { + CurrentPage = newPage; + _ = RefreshData(); + } + + private async Task RefreshData() + { + IsLoading = true; + StateHasChanged(); + + if (SearchTerm.Length > 0) + { + var filteredQuery = DbContext.Logs + .Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + SearchTerm.ToLower() + "%") || + EF.Functions.Like(x.Message.ToLower(), "%" + SearchTerm.ToLower() + "%") || + EF.Functions.Like(x.Level.ToLower(), "%" + SearchTerm.ToLower() + "%")); + + TotalRecords = await filteredQuery.CountAsync(); + LogList = await filteredQuery + .OrderByDescending(x => x.Id) + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + } + else + { + TotalRecords = await DbContext.Logs.CountAsync(); + LogList = await DbContext.Logs + .OrderByDescending(x => x.Id) + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + } + + IsLoading = false; + StateHasChanged(); + } +} diff --git a/src/AliasVault.Admin/Main/Pages/MainBase.cs b/src/AliasVault.Admin/Main/Pages/MainBase.cs new file mode 100644 index 000000000..d6a005056 --- /dev/null +++ b/src/AliasVault.Admin/Main/Pages/MainBase.cs @@ -0,0 +1,99 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Pages; + +using AliasServerDb; +using AliasVault.Admin.Main.Models; +using AliasVault.Admin.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; +using Microsoft.JSInterop; + +/// +/// Base authorize page that all pages that are part of the logged in website should inherit from. +/// All pages that inherit from this class will require the user to be logged in and have a confirmed email. +/// Also, a default set of breadcrumbs is added in the parent OnInitialized method. +/// +[Authorize] +public class MainBase : OwningComponentBase +{ + /// + /// Gets or sets the NavigationService instance responsible for handling navigation, replaces the default NavigationManager. + /// + [Inject] + public NavigationService NavigationService { get; set; } = null!; + + /// + /// Gets or sets the UserService instance responsible for handling user data. + /// + [Inject] + protected UserService UserService { get; set; } = null!; + + /// + /// Gets or sets the global notification service for showing notifications throughout the app. + /// + [Inject] + protected GlobalNotificationService GlobalNotificationService { get; set; } = null!; + + /// + /// Gets or sets the JS invoke service for calling JS functions from C#. + /// + [Inject] + protected JsInvokeService JsInvokeService { get; set; } = null!; + + /// + /// Gets or sets the AliasServerDbContext instance. + /// + [Inject] + protected AliasServerDbContext DbContext { get; set; } = null!; + + /// + /// Gets or sets the AliasServerDbContextFactory instance. + /// + [Inject] + protected IDbContextFactory DbContextFactory { get; set; } = null!; + + /// + /// Gets or sets the injected JSRuntime instance. + /// + [Inject] + protected IJSRuntime Js { get; set; } = null!; + + /// + /// Gets the breadcrumb items for the page. A default set of breadcrumbs is added in the parent OnInitialized method. + /// + protected List BreadcrumbItems { get; } = new(); + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // Load the current user. + await UserService.LoadCurrentUserAsync(); + + // Add base breadcrumbs. + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationService.BaseUri }); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + } + + /// + /// Gets the username from the authentication state asynchronously. + /// + /// The username. + protected string GetUsername() + { + return UserService.User().UserName ?? "[Unknown]"; + } +} diff --git a/src/AliasVault.Admin/Main/Routes.razor b/src/AliasVault.Admin/Main/Routes.razor new file mode 100644 index 000000000..fc07ba01c --- /dev/null +++ b/src/AliasVault.Admin/Main/Routes.razor @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/AliasVault.Admin/Main/_Imports.razor b/src/AliasVault.Admin/Main/_Imports.razor new file mode 100644 index 000000000..ee624b2c7 --- /dev/null +++ b/src/AliasVault.Admin/Main/_Imports.razor @@ -0,0 +1,25 @@ +@inherits AliasVault.Admin.Main.Pages.MainBase +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.EntityFrameworkCore +@using Microsoft.JSInterop +@using AliasVault.Admin +@using AliasVault.Admin.Auth.Components +@using AliasVault.Admin.Main +@using AliasVault.Admin.Main.Components +@using AliasVault.Admin.Main.Components.Alerts +@using AliasVault.Admin.Main.Components.Layout +@using AliasVault.Admin.Main.Components.WorkerStatus +@using AliasVault.Admin.Main.Components.Refresh +@using AliasVault.Admin.Main.Models +@using AliasVault.Admin.Main.Pages +@using AliasVault.Admin.Services +@using AliasServerDb +@using Microsoft.AspNetCore.Authorization + diff --git a/src/AliasVault.Admin/Program.cs b/src/AliasVault.Admin/Program.cs new file mode 100644 index 000000000..749d880f5 --- /dev/null +++ b/src/AliasVault.Admin/Program.cs @@ -0,0 +1,135 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +using System.Data.Common; +using System.Globalization; +using System.Reflection; +using AliasServerDb; +using AliasVault.Admin; +using AliasVault.Admin.Auth.Providers; +using AliasVault.Admin.Main; +using AliasVault.Admin.Services; +using AliasVault.Logging; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); +builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); +builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../logs"); + +// Create global config object, get values from environment variables. +Config config = new Config(); +var adminPasswordHash = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_HASH") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_HASH environment variable is not set."); +config.AdminPasswordHash = adminPasswordHash; + +var lastPasswordChanged = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_GENERATED") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_GENERATED environment variable is not set."); +config.LastPasswordChanged = DateTime.ParseExact(lastPasswordChanged, "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + +builder.Services.AddSingleton(config); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(); + +builder.Services.ConfigureApplicationCookie(options => +{ + options.LoginPath = "/user/login"; +}); + +// We use dbContextFactory to create a new instance of the DbContext for every place that needs it +// as otherwise concurrency issues may occur if we use a single instance of the DbContext across the application. +builder.Services.AddSingleton(container => +{ + var connection = new SqliteConnection(builder.Configuration.GetConnectionString("AliasServerDbContext")); + connection.Open(); + + return connection; +}); + +builder.Services.AddDbContextFactory((container, options) => +{ + var connection = container.GetRequiredService(); + options.UseSqlite(connection).UseLazyLoadingProxies(); +}); + +builder.Services.AddDatabaseDeveloperPageExceptionFilter(); +builder.Services.AddIdentityCore(options => + { + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.Password.RequiredLength = 8; + options.Password.RequiredUniqueChars = 0; + options.SignIn.RequireConfirmedAccount = false; + options.User.RequireUniqueEmail = false; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseMigrationsEndPoint(); +} +else +{ + app.UseHttpsRedirection(); + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +using (var scope = app.Services.CreateScope()) +{ + var container = scope.ServiceProvider; + var db = container.GetRequiredService(); + + await db.Database.MigrateAsync(); + + await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider); + await StartupTasks.SetAdminUser(scope.ServiceProvider); +} + +await app.RunAsync(); + +namespace AliasVault.Admin +{ + /// + /// Explicit program class definition. This is required in order to start the Admin project + /// in-memory from E2ETests project via WebApplicationFactory. + /// + public partial class Program + { + } +} diff --git a/src/AliasVault.Admin/Properties/launchSettings.json b/src/AliasVault.Admin/Properties/launchSettings.json new file mode 100644 index 000000000..2db72950d --- /dev/null +++ b/src/AliasVault.Admin/Properties/launchSettings.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:12292", + "sslPort": 44398 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==", + "ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7025;http://localhost:5216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/src/AliasVault.Admin/Services/GlobalNotificationService.cs b/src/AliasVault.Admin/Services/GlobalNotificationService.cs new file mode 100644 index 000000000..e84e04045 --- /dev/null +++ b/src/AliasVault.Admin/Services/GlobalNotificationService.cs @@ -0,0 +1,103 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Services; + +/// +/// Handles global notifications that should be displayed to the user, such as success or error messages. These messages +/// are stored in this object which is scoped to the current session. This allows the messages to be cached until +/// they actually have been displayed. So they can survive redirects and page reloads. +/// +public class GlobalNotificationService +{ + /// + /// Allow other components to subscribe to changes in the event object. + /// + public event Action? OnChange; + + /// + /// Gets or sets success messages that should be displayed to the user. + /// + protected List SuccessMessages { get; set; } = []; + + /// + /// Gets or sets error messages that should be displayed to the user. + /// + protected List ErrorMessages { get; set; } = []; + + /// + /// Adds a success message to the list of messages that should be displayed to the user. + /// + /// The message to add. + /// Whether to notify state change to subscribers. Defaults to false. + /// Set this to true if you want to show the added message instantly instead of waiting for the notification + /// display to rerender (e.g. after navigation). + public void AddSuccessMessage(string message, bool notifyStateChanged = false) + { + SuccessMessages.Add(message); + + // Notify subscribers that a message has been added. + if (notifyStateChanged) + { + NotifyStateChanged(); + } + } + + /// + /// Adds an error message to the list of messages that should be displayed to the user. + /// + /// The message to add. + /// Whether to notify state change to subscribers. Defaults to false. + /// Set this to true if you want to show the added message instantly instead of waiting for the notification + /// display to rerender (e.g. after navigation). + public void AddErrorMessage(string message, bool notifyStateChanged = false) + { + ErrorMessages.Add(message); + + // Notify subscribers that a message has been added. + if (notifyStateChanged) + { + NotifyStateChanged(); + } + } + + /// + /// Returns a dictionary with messages that should be displayed to the user. After this method is called, + /// the messages are automatically cleared. + /// + /// Dictionary with messages that are ready to be displayed on the next page load. + public List> GetMessagesForDisplay() + { + var messages = new List>(); + foreach (var message in SuccessMessages) + { + messages.Add(new KeyValuePair("success", message)); + } + + foreach (var message in ErrorMessages) + { + messages.Add(new KeyValuePair("error", message)); + } + + // Clear messages + SuccessMessages.Clear(); + ErrorMessages.Clear(); + + return messages; + } + + /// + /// Clear all messages. + /// + public void ClearMessages() + { + SuccessMessages.Clear(); + ErrorMessages.Clear(); + } + + private void NotifyStateChanged() => OnChange?.Invoke(); +} diff --git a/src/AliasVault.Admin/Services/JSInvokeService.cs b/src/AliasVault.Admin/Services/JSInvokeService.cs new file mode 100644 index 000000000..efc8d5646 --- /dev/null +++ b/src/AliasVault.Admin/Services/JSInvokeService.cs @@ -0,0 +1,53 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Services; + +using Microsoft.JSInterop; + +/// +/// Service for invoking JavaScript functions from C#. +/// +public class JsInvokeService(IJSRuntime js) +{ + /// + /// Invoke a JavaScript function with retry and exponential backoff. + /// + /// The JS function name to call. + /// Initial delay before calling the function. + /// Maximum attempts before giving up. + /// Arguments to pass on to the javascript function. + /// Async Task. + public async Task RetryInvokeAsync(string functionName, TimeSpan initialDelay, int maxAttempts, params object[] args) + { + TimeSpan delay = initialDelay; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + bool isDefined = await js.InvokeAsync("isFunctionDefined", functionName); + if (isDefined) + { + await js.InvokeVoidAsync(functionName, args); + return; // Successfully called the JS function, exit the method + } + } + catch + { + // Optionally log the exception + } + + // Wait for the delay before the next attempt + await Task.Delay(delay); + + // Exponential backoff: double the delay for the next attempt + delay = TimeSpan.FromTicks(delay.Ticks * 2); + } + + // Optionally log that the JS function could not be called after maxAttempts + } +} diff --git a/src/AliasVault.Admin/Services/NavigationService.cs b/src/AliasVault.Admin/Services/NavigationService.cs new file mode 100644 index 000000000..5bd19133e --- /dev/null +++ b/src/AliasVault.Admin/Services/NavigationService.cs @@ -0,0 +1,102 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Services; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; + +/// +/// Navigation helper service. +/// +public class NavigationService +{ + private readonly NavigationManager _navigationManager; + + /// + /// Initializes a new instance of the class. + /// + /// NavigationManager instance. + public NavigationService(NavigationManager navigationManager) + { + _navigationManager = navigationManager; + _navigationManager.LocationChanged += (sender, args) => { LocationChanged?.Invoke(sender, args); }; + } + + /// + /// Location changed event. + /// + public event EventHandler? LocationChanged; + + /// + /// Gets the Base URI. + /// + public string BaseUri => _navigationManager.BaseUri; + + /// + /// Gets the URI. + /// + public string Uri => _navigationManager.Uri; + + /// + /// Gets the current path. + /// + private string CurrentPath => _navigationManager.ToAbsoluteUri(_navigationManager.Uri).GetLeftPart(UriPartial.Path); + + /// + /// Redirect to the current page. + /// + public void RedirectToCurrentPage() => RedirectTo(CurrentPath); + + /// + /// Redirect to the specified URI. + /// + /// The uri to redirect to. + /// Force load true/false. + public void RedirectTo(string? uri, bool forceLoad = false) + { + uri ??= string.Empty; + + // Prevent open redirects. + if (!System.Uri.IsWellFormedUriString(uri, UriKind.Relative)) + { + uri = _navigationManager.ToBaseRelativePath(uri); + } + + _navigationManager.NavigateTo(uri, forceLoad); + } + + /// + /// Redirect to the specified URI with query parameters. + /// + /// URI to redirect to. + /// Optional querystring parameters to add to the URL. + /// Force load true/false. + public void RedirectTo(string uri, Dictionary queryParameters, bool forceLoad = false) + { + var uriWithoutQuery = _navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); + var newUri = _navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); + RedirectTo(newUri, forceLoad); + } + + /// + /// Returns a URI constructed from except with multiple parameters + /// added, updated, or removed. + /// + /// The URI with the query to modify. + /// The values to add, update, or remove. + /// The URI with the query modified. + public string GetUriWithQueryParameters(string uri, IReadOnlyDictionary parameters) => _navigationManager.GetUriWithQueryParameters(uri, parameters); + + /// + /// Converts a relative URI into an absolute one (by resolving it + /// relative to the current absolute URI). + /// + /// The relative URI. + /// The absolute URI. + public Uri ToAbsoluteUri(string relativeUri) => _navigationManager.ToAbsoluteUri(relativeUri); +} diff --git a/src/AliasVault.Admin/Services/UserService.cs b/src/AliasVault.Admin/Services/UserService.cs new file mode 100644 index 000000000..365273e9a --- /dev/null +++ b/src/AliasVault.Admin/Services/UserService.cs @@ -0,0 +1,331 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Services; + +using System.ComponentModel.DataAnnotations; +using AliasServerDb; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +/// +/// User service for managing users. +/// +/// AliasServerDbContext instance. +/// UserManager instance. +/// HttpContextManager instance. +public class UserService(AliasServerDbContext dbContext, UserManager userManager, IHttpContextAccessor httpContextAccessor) +{ + private const string AdminRole = "Admin"; + private AdminUser? _user; + + /// + /// The roles of the current user. + /// + private List _userRoles = []; + + /// + /// Whether the current user is an admin or not. + /// + private bool _isAdmin; + + /// + /// Allow other components to subscribe to changes in the event object. + /// + public event Action OnChange = () => { }; + + /// + /// Gets a value indicating whether the User is loaded and available, false if not. Use this before accessing User() method. + /// + public bool UserLoaded => _user != null; + + /// + /// Returns all users. + /// + /// List of users. + public async Task> GetAllUsersAsync() + { + var userList = await userManager.Users.ToListAsync(); + return userList; + } + + /// + /// Finds and returns user by id, using the userManager instead of the dbContext. + /// This is necessary when performing actions on the user, such as changing password or deleting the object. + /// + /// User ID. + /// AdminUser object. + public async Task GetUserByIdUserManagerAsync(Guid userId) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + throw new ArgumentException($"User with id {userId} not found."); + } + + return user; + } + + /// + /// Returns inner User EF object. + /// + /// User object. + public AdminUser User() + { + if (_user == null) + { + throw new ArgumentException("Trying to access User object which is null."); + } + + return _user; + } + + /// + /// Returns whether current user is admin or not. + /// + /// Boolean which indicates if user is admin. + public bool CurrentUserIsAdmin() + { + return _isAdmin; + } + + /// + /// Returns current logged on user based on HttpContext. + /// + /// Async task. + public async Task LoadCurrentUserAsync() + { + if (httpContextAccessor.HttpContext != null) + { + // Load user from database. Use a new context everytime to ensure we get the latest data. + var userName = httpContextAccessor.HttpContext?.User?.Identity?.Name ?? string.Empty; + + var user = await dbContext.AdminUsers.FirstOrDefaultAsync(u => u.UserName == userName); + if (user != null) + { + _user = user; + + // Load all roles for current user. + var roles = await userManager.GetRolesAsync(User()); + _userRoles = roles.ToList(); + + // Define if current user is admin. + _isAdmin = _userRoles.Contains(AdminRole); + } + } + + // Notify listeners that the user has been loaded. + NotifyStateChanged(); + } + + /// + /// Returns current logged on user roles based on HttpContext. + /// + /// List of roles. + public async Task> GetCurrentUserRolesAsync() + { + var roles = await userManager.GetRolesAsync(User()); + + return roles.ToList(); + } + + /// + /// Search for users based on search term. + /// + /// Search term. + /// List of users matching the search term. + public async Task> SearchUsersAsync(string searchTerm) + { + return await userManager.Users.Where(x => x.UserName != null && x.UserName.Contains(searchTerm)).Take(5).ToListAsync(); + } + + /// + /// Create a new user. + /// + /// User object. + /// Password. + /// Roles. + /// List of errors if there are any. + public async Task> CreateUserAsync(AdminUser user, string password, List roles) + { + var errors = await ValidateUser(user, password, isUpdate: false); + if (errors.Count > 0) + { + return errors; + } + + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + errors.Add(error.Description); + } + + return errors; + } + + errors = await UpdateUserRolesAsync(user, roles); + + return errors; + } + + /// + /// Update user. + /// + /// User object. + /// Optional parameter for new password for the user. + /// List of errors if any. + public async Task> UpdateUserAsync(AdminUser user, string newPassword = "") + { + var errors = await ValidateUser(user, newPassword, isUpdate: true); + if (errors.Count > 0) + { + return errors; + } + + // Update password if necessary + if (!string.IsNullOrEmpty(newPassword)) + { + var passwordRemoveResult = await userManager.RemovePasswordAsync(user); + if (!passwordRemoveResult.Succeeded) + { + foreach (var error in passwordRemoveResult.Errors) + { + errors.Add(error.Description); + } + + return errors; + } + + var passwordAddResult = await userManager.AddPasswordAsync(user, newPassword); + if (!passwordAddResult.Succeeded) + { + foreach (var error in passwordAddResult.Errors) + { + errors.Add(error.Description); + } + + return errors; + } + } + + var result = await userManager.UpdateAsync(user); + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + errors.Add(error.Description); + } + + return errors; + } + + return errors; + } + + /// + /// Update user roles. This is a separate method because it is called from both CreateUserAsync and UpdateUserAsync. + /// + /// User object. + /// New roles for the user. + /// List of errors if any. + public async Task> UpdateUserRolesAsync(AdminUser user, List roles) + { + List errors = new(); + + var currentRoles = await userManager.GetRolesAsync(user); + if (user.Id == User().Id && currentRoles.Contains(AdminRole) && !roles.Contains(AdminRole)) + { + errors.Add("You cannot remove the Admin role from yourself if you are an Admin."); + return errors; + } + + var rolesToAdd = roles.Except(currentRoles).ToList(); + var rolesToRemove = currentRoles.Except(roles).ToList(); + + await userManager.AddToRolesAsync(user, rolesToAdd); + await userManager.RemoveFromRolesAsync(user, rolesToRemove); + + return errors; + } + + /// + /// Checks if supplied password is correct for the user. + /// + /// User object. + /// The password to check. + /// Boolean indicating whether supplied password is valid and matches what is stored in the database.. + public async Task CheckPasswordAsync(AdminUser user, string password) + { + if (password.Length == 0) + { + return false; + } + + return await userManager.CheckPasswordAsync(user, password); + } + + /// + /// Validate if user object contents conform to the requirements. + /// + /// User object. + /// Password for the user. + /// Boolean indicating whether the user is being updated or not. + /// List of strings. + private async Task> ValidateUser(AdminUser user, string password, bool isUpdate) + { + // Username and email are the same, so enforce any changes to username here to email as well + user.Email = user.UserName; + + var errors = new List(); + + if (string.IsNullOrEmpty(user.UserName) || string.IsNullOrEmpty(user.Email)) + { + errors.Add("Username and email are required."); + return errors; + } + + if (!new EmailAddressAttribute().IsValid(user.Email)) + { + errors.Add("Email is not valid."); + return errors; + } + + if (isUpdate) + { + var originalUser = await userManager.FindByIdAsync(user.Id); + if (originalUser != null && user.UserName != originalUser.UserName) + { + errors.Add("Username cannot be changed for existing users."); + } + } + else + { + var existingUser = await userManager.FindByNameAsync(user.UserName); + if (existingUser != null) + { + errors.Add("Username is already in use."); + } + + var existingEmail = await userManager.FindByEmailAsync(user.Email); + if (existingEmail != null) + { + errors.Add("Email is already in use."); + } + + if (string.IsNullOrEmpty(password)) + { + errors.Add("Password is required."); + } + } + + return errors; + } + + private void NotifyStateChanged() => OnChange.Invoke(); +} diff --git a/src/AliasVault.Admin/StartupTasks.cs b/src/AliasVault.Admin/StartupTasks.cs new file mode 100644 index 000000000..8c0cd2781 --- /dev/null +++ b/src/AliasVault.Admin/StartupTasks.cs @@ -0,0 +1,82 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin; + +using AliasServerDb; +using Microsoft.AspNetCore.Identity; + +/// +/// Startup tasks that should be run when the application starts. +/// +public static class StartupTasks +{ + /// + /// Creates the roles if they do not exist. + /// + /// IServiceProvider instance. + /// Task. + public static async Task CreateRolesIfNotExist(IServiceProvider serviceProvider) + { + var roleManager = serviceProvider.GetRequiredService>(); + + const string adminRole = "Admin"; + + if (!await roleManager.RoleExistsAsync(adminRole)) + { + await roleManager.CreateAsync(new AdminRole(adminRole)); + } + } + + /// + /// Creates the admin user if it does not exist. + /// + /// IServiceProvider instance. + /// Async Task. + public static async Task SetAdminUser(IServiceProvider serviceProvider) + { + using var scope = serviceProvider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var adminUser = await userManager.FindByNameAsync("admin"); + var config = serviceProvider.GetRequiredService(); + + if (adminUser == null) + { + var adminPasswordHash = config.AdminPasswordHash; + adminUser = new AdminUser(); + adminUser.UserName = "admin"; + + await userManager.CreateAsync(adminUser); + adminUser.PasswordHash = adminPasswordHash; + adminUser.LastPasswordChanged = DateTime.UtcNow; + await userManager.UpdateAsync(adminUser); + + Console.WriteLine("Admin user created."); + } + else + { + // Check if the password hash is different AND the password in .env file is newer than the password of user. + // If so, update the password hash of the user in the database so it matches the one in the .env file. + if (adminUser.PasswordHash != config.AdminPasswordHash && (adminUser.LastPasswordChanged is null || config.LastPasswordChanged > adminUser.LastPasswordChanged)) + { + // The password has been changed in the .env file, update the user's password hash. + adminUser.PasswordHash = config.AdminPasswordHash; + adminUser.LastPasswordChanged = DateTime.UtcNow; + + // Reset 2FA settings + adminUser.TwoFactorEnabled = false; + + // Clear existing recovery codes + await userManager.GenerateNewTwoFactorRecoveryCodesAsync(adminUser, 0); + + await userManager.UpdateAsync(adminUser); + + Console.WriteLine("Admin password hash updated."); + } + } + } +} diff --git a/src/AliasVault.Admin/appsettings.Development.json b/src/AliasVault.Admin/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/AliasVault.Admin/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/AliasVault.Admin/appsettings.json b/src/AliasVault.Admin/appsettings.json new file mode 100644 index 000000000..0e169ebd5 --- /dev/null +++ b/src/AliasVault.Admin/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/AliasVault.Admin/package-lock.json b/src/AliasVault.Admin/package-lock.json new file mode 100644 index 000000000..f15dcd52a --- /dev/null +++ b/src/AliasVault.Admin/package-lock.json @@ -0,0 +1,1425 @@ +{ + "name": "aliasvault.client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aliasvault.client", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/autoprefixer": { + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001626", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001626.tgz", + "integrity": "sha512-JRW7kAH8PFJzoPCJhLSHgDgKg5348hsQ68aqb+slnzuB5QFERv846oA/mRChmlLAOdEDeOkRn3ynb1gSFnjt3w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/electron-to-chromium": { + "version": "1.4.788", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.788.tgz", + "integrity": "sha512-ubp5+Ev/VV8KuRoWnfP2QF2Bg+O2ZFdb49DiiNbz2VmgkIqrnyYaqIOqj8A6K/3p1xV0QcU5hBQ1+BmB6ot1OA==" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz", + "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jackspeak": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz", + "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/lru-cache": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", + "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", + "integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/update-browserslist-db": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.3.tgz", + "integrity": "sha512-sntgmxj8o7DE7g/Qi60cqpLBA3HG3STcDA0kO+WfB05jEKhZMbY7umNm2rBpQvsmZ16/lPXCJGW2672dgOUkrg==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/src/AliasVault.Admin/package.json b/src/AliasVault.Admin/package.json new file mode 100644 index 000000000..0d9e59c9d --- /dev/null +++ b/src/AliasVault.Admin/package.json @@ -0,0 +1,18 @@ +{ + "name": "aliasvault.client", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build:css": "tailwindcss -i ./tailwind.css -o ./wwwroot/css/tailwind.css --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3" + } +} diff --git a/src/AliasVault.Admin/postcss.config.js b/src/AliasVault.Admin/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/src/AliasVault.Admin/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/AliasVault.Admin/tailwind.config.js b/src/AliasVault.Admin/tailwind.config.js new file mode 100644 index 000000000..11c8241f3 --- /dev/null +++ b/src/AliasVault.Admin/tailwind.config.js @@ -0,0 +1,58 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './**/*.html', + './**/*.razor', + '../Utilities/AliasVault.RazorComponents/**/*.razor', + ], + safelist: [ + 'w-64', + 'w-1/2', + 'rounded-l-lg', + 'rounded-r-lg', + 'bg-gray-200', + 'grid-cols-4', + 'grid-cols-7', + 'h-6', + 'leading-6', + 'h-9', + 'leading-9', + 'shadow-lg', + 'bg-opacity-50', + 'dark:bg-opacity-80' + ], + darkMode: "class", + theme: { + extend: { + colors: { + primary: { + "900": "#7b4a1e", + "800": "#9a5d26", + "700": "#b8702f", + "600": "#d68338", + "500": "#f49541", + "400": "#f6a752", + "300": "#f8b963", + "200": "#fbcb74", + "100": "#fdde85", + "50": "#ffe096" + } + + }, + fontFamily: { + 'sans': ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'], + 'body': ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'], + 'mono': ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace'] + }, + transitionProperty: { + 'width': 'width' + }, + textDecoration: ['active'], + minWidth: { + 'kanban': '28rem' + }, + }, + }, + plugins: [ + ], +} diff --git a/src/AliasVault.Admin/tailwind.css b/src/AliasVault.Admin/tailwind.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/src/AliasVault.Admin/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/AliasVault.Admin/wwwroot/css/app.css b/src/AliasVault.Admin/wwwroot/css/app.css new file mode 100644 index 000000000..6de91c1c4 --- /dev/null +++ b/src/AliasVault.Admin/wwwroot/css/app.css @@ -0,0 +1,41 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} diff --git a/src/AliasVault.Admin/wwwroot/css/tailwind.css b/src/AliasVault.Admin/wwwroot/css/tailwind.css new file mode 100644 index 000000000..f29ae592c --- /dev/null +++ b/src/AliasVault.Admin/wwwroot/css/tailwind.css @@ -0,0 +1,2055 @@ +/* +! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: Inter, ui-sans-serif, system-ui, -apple-system, system-ui, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.invisible { + visibility: hidden; +} + +.static { + position: static; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.inset-0 { + inset: 0px; +} + +.right-0 { + right: 0px; +} + +.top-10 { + top: 2.5rem; +} + +.z-10 { + z-index: 10; +} + +.z-30 { + z-index: 30; +} + +.z-50 { + z-index: 50; +} + +.col-span-full { + grid-column: 1 / -1; +} + +.mx-3 { + margin-left: 0.75rem; + margin-right: 0.75rem; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.my-4 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-3 { + margin-left: 0.75rem; +} + +.ml-auto { + margin-left: auto; +} + +.mr-14 { + margin-right: 3.5rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mr-2\.5 { + margin-right: 0.625rem; +} + +.mr-3 { + margin-right: 0.75rem; +} + +.mr-4 { + margin-right: 1rem; +} + +.mt-0 { + margin-top: 0px; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-6 { + margin-top: 1.5rem; +} + +.mt-8 { + margin-top: 2rem; +} + +.line-clamp-1 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.inline { + display: inline; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.grid { + display: grid; +} + +.hidden { + display: none; +} + +.h-4 { + height: 1rem; +} + +.h-5 { + height: 1.25rem; +} + +.h-6 { + height: 1.5rem; +} + +.h-8 { + height: 2rem; +} + +.h-9 { + height: 2.25rem; +} + +.h-full { + height: 100%; +} + +.w-1\/2 { + width: 50%; +} + +.w-4 { + width: 1rem; +} + +.w-5 { + width: 1.25rem; +} + +.w-56 { + width: 14rem; +} + +.w-6 { + width: 1.5rem; +} + +.w-64 { + width: 16rem; +} + +.w-8 { + width: 2rem; +} + +.w-full { + width: 100%; +} + +.max-w-2xl { + max-width: 42rem; +} + +.max-w-md { + max-width: 28rem; +} + +.max-w-screen-2xl { + max-width: 1536px; +} + +.max-w-xl { + max-width: 36rem; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +.cursor-not-allowed { + cursor: not-allowed; +} + +.cursor-pointer { + cursor: pointer; +} + +.list-decimal { + list-style-type: decimal; +} + +.list-none { + list-style-type: none; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.grid-cols-7 { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-start { + align-items: flex-start; +} + +.items-center { + align-items: center; +} + +.justify-start { + justify-content: flex-start; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.space-x-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.25rem * var(--tw-space-x-reverse)); + margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1rem * var(--tw-space-x-reverse)); + margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1.5rem * var(--tw-space-x-reverse)); + margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-y-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); +} + +.space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); +} + +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} + +.space-y-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); +} + +.divide-y > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); +} + +.divide-gray-100 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(243 244 246 / var(--tw-divide-opacity)); +} + +.self-center { + align-self: center; +} + +.overflow-hidden { + overflow: hidden; +} + +.overflow-x-auto { + overflow-x: auto; +} + +.overflow-y-auto { + overflow-y: auto; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded-l-lg { + border-top-left-radius: 0.5rem; + border-bottom-left-radius: 0.5rem; +} + +.rounded-r-lg { + border-top-right-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; +} + +.border { + border-width: 1px; +} + +.border-2 { + border-width: 2px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-l-4 { + border-left-width: 4px; +} + +.border-t { + border-top-width: 1px; +} + +.border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity)); +} + +.border-gray-300 { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + +.border-primary-300 { + --tw-border-opacity: 1; + border-color: rgb(248 185 99 / var(--tw-border-opacity)); +} + +.border-primary-500 { + --tw-border-opacity: 1; + border-color: rgb(244 149 65 / var(--tw-border-opacity)); +} + +.border-red-500 { + --tw-border-opacity: 1; + border-color: rgb(239 68 68 / var(--tw-border-opacity)); +} + +.border-yellow-500 { + --tw-border-opacity: 1; + border-color: rgb(234 179 8 / var(--tw-border-opacity)); +} + +.bg-blue-500 { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.bg-gray-50 { + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity)); +} + +.bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + +.bg-gray-800 { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); +} + +.bg-gray-900 { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); +} + +.bg-green-50 { + --tw-bg-opacity: 1; + background-color: rgb(240 253 244 / var(--tw-bg-opacity)); +} + +.bg-green-500 { + --tw-bg-opacity: 1; + background-color: rgb(34 197 94 / var(--tw-bg-opacity)); +} + +.bg-primary-100 { + --tw-bg-opacity: 1; + background-color: rgb(253 222 133 / var(--tw-bg-opacity)); +} + +.bg-primary-200 { + --tw-bg-opacity: 1; + background-color: rgb(251 203 116 / var(--tw-bg-opacity)); +} + +.bg-primary-600 { + --tw-bg-opacity: 1; + background-color: rgb(214 131 56 / var(--tw-bg-opacity)); +} + +.bg-primary-700 { + --tw-bg-opacity: 1; + background-color: rgb(184 112 47 / var(--tw-bg-opacity)); +} + +.bg-red-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 226 226 / var(--tw-bg-opacity)); +} + +.bg-red-50 { + --tw-bg-opacity: 1; + background-color: rgb(254 242 242 / var(--tw-bg-opacity)); +} + +.bg-red-500 { + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + +.bg-red-600 { + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-yellow-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 249 195 / var(--tw-bg-opacity)); +} + +.bg-yellow-500 { + --tw-bg-opacity: 1; + background-color: rgb(234 179 8 / var(--tw-bg-opacity)); +} + +.bg-gray-600 { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.bg-green-600 { + --tw-bg-opacity: 1; + background-color: rgb(22 163 74 / var(--tw-bg-opacity)); +} + +.bg-blue-700 { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + +.bg-gray-400 { + --tw-bg-opacity: 1; + background-color: rgb(156 163 175 / var(--tw-bg-opacity)); +} + +.bg-opacity-50 { + --tw-bg-opacity: 0.5; +} + +.p-2 { + padding: 0.5rem; +} + +.p-2\.5 { + padding: 0.625rem; +} + +.p-4 { + padding: 1rem; +} + +.p-6 { + padding: 1.5rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-1\.5 { + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} + +.ps-2 { + padding-inline-start: 0.5rem; +} + +.pt-16 { + padding-top: 4rem; +} + +.pt-6 { + padding-top: 1.5rem; +} + +.pt-8 { + padding-top: 2rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-bold { + font-weight: 700; +} + +.font-light { + font-weight: 300; +} + +.font-medium { + font-weight: 500; +} + +.font-normal { + font-weight: 400; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.leading-6 { + line-height: 1.5rem; +} + +.leading-9 { + line-height: 2.25rem; +} + +.text-blue-600 { + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity)); +} + +.text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.text-gray-500 { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + +.text-gray-700 { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity)); +} + +.text-gray-800 { + --tw-text-opacity: 1; + color: rgb(31 41 55 / var(--tw-text-opacity)); +} + +.text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +.text-green-800 { + --tw-text-opacity: 1; + color: rgb(22 101 52 / var(--tw-text-opacity)); +} + +.text-primary-600 { + --tw-text-opacity: 1; + color: rgb(214 131 56 / var(--tw-text-opacity)); +} + +.text-primary-700 { + --tw-text-opacity: 1; + color: rgb(184 112 47 / var(--tw-text-opacity)); +} + +.text-red-600 { + --tw-text-opacity: 1; + color: rgb(220 38 38 / var(--tw-text-opacity)); +} + +.text-red-700 { + --tw-text-opacity: 1; + color: rgb(185 28 28 / var(--tw-text-opacity)); +} + +.text-red-800 { + --tw-text-opacity: 1; + color: rgb(153 27 27 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.text-yellow-700 { + --tw-text-opacity: 1; + color: rgb(161 98 7 / var(--tw-text-opacity)); +} + +.text-yellow-800 { + --tw-text-opacity: 1; + color: rgb(133 77 14 / var(--tw-text-opacity)); +} + +.underline { + text-decoration-line: underline; +} + +.opacity-0 { + opacity: 0; +} + +.opacity-50 { + opacity: 0.5; +} + +.opacity-25 { + opacity: 0.25; +} + +.opacity-75 { + opacity: 0.75; +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-lg { + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-sm { + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-opacity { + transition-property: opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-150 { + transition-duration: 150ms; +} + +.duration-300 { + transition-duration: 300ms; +} + +.hover\:bg-gray-100:hover { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + +.hover\:bg-gray-50:hover { + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity)); +} + +.hover\:bg-primary-200:hover { + --tw-bg-opacity: 1; + background-color: rgb(251 203 116 / var(--tw-bg-opacity)); +} + +.hover\:bg-primary-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(184 112 47 / var(--tw-bg-opacity)); +} + +.hover\:bg-primary-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(154 93 38 / var(--tw-bg-opacity)); +} + +.hover\:bg-blue-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); +} + +.hover\:text-gray-900:hover { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +.hover\:text-primary-600:hover { + --tw-text-opacity: 1; + color: rgb(214 131 56 / var(--tw-text-opacity)); +} + +.hover\:text-primary-700:hover { + --tw-text-opacity: 1; + color: rgb(184 112 47 / var(--tw-text-opacity)); +} + +.hover\:text-primary-800:hover { + --tw-text-opacity: 1; + color: rgb(154 93 38 / var(--tw-text-opacity)); +} + +.hover\:underline:hover { + text-decoration-line: underline; +} + +.focus\:border-primary-500:focus { + --tw-border-opacity: 1; + border-color: rgb(244 149 65 / var(--tw-border-opacity)); +} + +.focus\:border-primary-600:focus { + --tw-border-opacity: 1; + border-color: rgb(214 131 56 / var(--tw-border-opacity)); +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.focus\:ring-2:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus\:ring-4:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus\:ring-blue-500:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); +} + +.focus\:ring-gray-200:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity)); +} + +.focus\:ring-gray-300:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); +} + +.focus\:ring-primary-300:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(248 185 99 / var(--tw-ring-opacity)); +} + +.focus\:ring-primary-500:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(244 149 65 / var(--tw-ring-opacity)); +} + +.focus\:ring-primary-600:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(214 131 56 / var(--tw-ring-opacity)); +} + +.focus\:ring-blue-300:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity)); +} + +.focus\:ring-offset-2:focus { + --tw-ring-offset-width: 2px; +} + +.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(75 85 99 / var(--tw-divide-opacity)); +} + +.dark\:border-gray-500:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + +.dark\:border-gray-600:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(75 85 99 / var(--tw-border-opacity)); +} + +.dark\:border-gray-700:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity)); +} + +.dark\:bg-gray-600:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.dark\:bg-gray-700:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + +.dark\:bg-gray-800:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); +} + +.dark\:bg-gray-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); +} + +.dark\:bg-primary-500:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(244 149 65 / var(--tw-bg-opacity)); +} + +.dark\:bg-primary-600:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(214 131 56 / var(--tw-bg-opacity)); +} + +.dark\:bg-red-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(127 29 29 / var(--tw-bg-opacity)); +} + +.dark\:bg-yellow-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(113 63 18 / var(--tw-bg-opacity)); +} + +.dark\:bg-blue-600:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.dark\:bg-opacity-80:is(.dark *) { + --tw-bg-opacity: 0.8; +} + +.dark\:text-blue-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); +} + +.dark\:text-gray-100:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(243 244 246 / var(--tw-text-opacity)); +} + +.dark\:text-gray-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + +.dark\:text-gray-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity)); +} + +.dark\:text-gray-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.dark\:text-green-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(74 222 128 / var(--tw-text-opacity)); +} + +.dark\:text-primary-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(251 203 116 / var(--tw-text-opacity)); +} + +.dark\:text-primary-500:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(244 149 65 / var(--tw-text-opacity)); +} + +.dark\:text-red-100:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(254 226 226 / var(--tw-text-opacity)); +} + +.dark\:text-red-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(254 202 202 / var(--tw-text-opacity)); +} + +.dark\:text-red-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + +.dark\:text-white:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.dark\:text-yellow-100:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(254 249 195 / var(--tw-text-opacity)); +} + +.dark\:text-yellow-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(254 240 138 / var(--tw-text-opacity)); +} + +.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder { + --tw-placeholder-opacity: 1; + color: rgb(156 163 175 / var(--tw-placeholder-opacity)); +} + +.dark\:placeholder-gray-400:is(.dark *)::placeholder { + --tw-placeholder-opacity: 1; + color: rgb(156 163 175 / var(--tw-placeholder-opacity)); +} + +.dark\:ring-offset-gray-800:is(.dark *) { + --tw-ring-offset-color: #1f2937; +} + +.dark\:hover\:bg-gray-600:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.dark\:hover\:bg-gray-700:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + +.dark\:hover\:bg-primary-600:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(214 131 56 / var(--tw-bg-opacity)); +} + +.dark\:hover\:bg-primary-700:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(184 112 47 / var(--tw-bg-opacity)); +} + +.dark\:hover\:bg-blue-700:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + +.dark\:hover\:text-primary-500:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(244 149 65 / var(--tw-text-opacity)); +} + +.dark\:hover\:text-white:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.dark\:focus\:border-blue-500:focus:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(59 130 246 / var(--tw-border-opacity)); +} + +.dark\:focus\:border-primary-500:focus:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(244 149 65 / var(--tw-border-opacity)); +} + +.dark\:focus\:ring-blue-500:focus:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); +} + +.dark\:focus\:ring-gray-600:focus:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity)); +} + +.dark\:focus\:ring-gray-700:focus:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(55 65 81 / var(--tw-ring-opacity)); +} + +.dark\:focus\:ring-primary-500:focus:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(244 149 65 / var(--tw-ring-opacity)); +} + +.dark\:focus\:ring-primary-600:focus:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(214 131 56 / var(--tw-ring-opacity)); +} + +.dark\:focus\:ring-primary-800:focus:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(154 93 38 / var(--tw-ring-opacity)); +} + +.dark\:focus\:ring-blue-800:focus:is(.dark *) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity)); +} + +@media (min-width: 640px) { + .sm\:flex { + display: flex; + } + + .sm\:w-auto { + width: auto; + } + + .sm\:flex-row { + flex-direction: row; + } + + .sm\:space-x-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); + } + + .sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0px * var(--tw-space-y-reverse)); + } + + .sm\:p-8 { + padding: 2rem; + } + + .sm\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + + .sm\:text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } +} + +@media (min-width: 768px) { + .md\:mb-0 { + margin-bottom: 0px; + } + + .md\:ml-2 { + margin-left: 0.5rem; + } + + .md\:mr-0 { + margin-right: 0px; + } + + .md\:mr-6 { + margin-right: 1.5rem; + } + + .md\:flex { + display: flex; + } + + .md\:h-screen { + height: 100vh; + } + + .md\:w-1\/4 { + width: 25%; + } + + .md\:w-3\/4 { + width: 75%; + } + + .md\:flex-row { + flex-direction: row; + } + + .md\:items-center { + align-items: center; + } + + .md\:justify-between { + justify-content: space-between; + } + + .md\:space-x-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); + } + + .md\:py-10 { + padding-top: 2.5rem; + padding-bottom: 2.5rem; + } + + .md\:pl-8 { + padding-left: 2rem; + } +} + +@media (min-width: 1024px) { + .lg\:order-1 { + order: 1; + } + + .lg\:order-2 { + order: 2; + } + + .lg\:mb-10 { + margin-bottom: 2.5rem; + } + + .lg\:mt-0 { + margin-top: 0px; + } + + .lg\:flex { + display: flex; + } + + .lg\:hidden { + display: none; + } + + .lg\:w-auto { + width: auto; + } + + .lg\:flex-row { + flex-direction: row; + } + + .lg\:px-0 { + padding-left: 0px; + padding-right: 0px; + } + + .lg\:py-0 { + padding-top: 0px; + padding-bottom: 0px; + } + + .lg\:hover\:underline:hover { + text-decoration-line: underline; + } +} + +@media (min-width: 1280px) { + .xl\:mb-2 { + margin-bottom: 0.5rem; + } + + .xl\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .xl\:gap-4 { + gap: 1rem; + } + + .xl\:space-x-8 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(2rem * var(--tw-space-x-reverse)); + margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); + } +} + +@media (min-width: 1536px) { + .\32xl\:px-0 { + padding-left: 0px; + padding-right: 0px; + } +} diff --git a/src/AliasVault.Admin/wwwroot/favicon.png b/src/AliasVault.Admin/wwwroot/favicon.png new file mode 100644 index 000000000..c8aff58e8 Binary files /dev/null and b/src/AliasVault.Admin/wwwroot/favicon.png differ diff --git a/src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png b/src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png new file mode 100644 index 000000000..b3a037d75 Binary files /dev/null and b/src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png differ diff --git a/src/AliasVault.Admin/wwwroot/icon-trimmed.png b/src/AliasVault.Admin/wwwroot/icon-trimmed.png new file mode 100644 index 000000000..7935f77e3 Binary files /dev/null and b/src/AliasVault.Admin/wwwroot/icon-trimmed.png differ diff --git a/src/AliasVault.Admin/wwwroot/img/avatar.webp b/src/AliasVault.Admin/wwwroot/img/avatar.webp new file mode 100644 index 000000000..d52d725f1 Binary files /dev/null and b/src/AliasVault.Admin/wwwroot/img/avatar.webp differ diff --git a/src/AliasVault.Admin/wwwroot/img/service-placeholder.webp b/src/AliasVault.Admin/wwwroot/img/service-placeholder.webp new file mode 100644 index 000000000..85460a537 Binary files /dev/null and b/src/AliasVault.Admin/wwwroot/img/service-placeholder.webp differ diff --git a/src/AliasVault.Admin/wwwroot/js/dark-mode.js b/src/AliasVault.Admin/wwwroot/js/dark-mode.js new file mode 100644 index 000000000..d84880f91 --- /dev/null +++ b/src/AliasVault.Admin/wwwroot/js/dark-mode.js @@ -0,0 +1,53 @@ +function initDarkModeSwitcher() { + const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); + const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); + + if (themeToggleDarkIcon === null && themeToggleLightIcon === null) { + return; + } + + if (localStorage.getItem('color-theme')) { + if (localStorage.getItem('color-theme') === 'light') { + document.documentElement.classList.remove('dark'); + themeToggleLightIcon.classList.remove('hidden'); + } else { + document.documentElement.classList.add('dark'); + themeToggleDarkIcon.classList.remove('hidden'); + } + } + else { + // Default to light mode if not set. + document.documentElement.classList.remove('dark'); + themeToggleLightIcon.classList.remove('hidden'); + } + + const themeToggleBtn = document.getElementById('theme-toggle'); + + let event = new Event('dark-mode'); + + themeToggleBtn.addEventListener('click', function () { + // toggle icons + themeToggleDarkIcon.classList.toggle('hidden'); + themeToggleLightIcon.classList.toggle('hidden'); + + // if set via local storage previously + if (localStorage.getItem('color-theme')) { + if (localStorage.getItem('color-theme') === 'light') { + document.documentElement.classList.add('dark'); + localStorage.setItem('color-theme', 'dark'); + } else { + document.documentElement.classList.remove('dark'); + localStorage.setItem('color-theme', 'light'); + } + // if NOT set via local storage previously + } else if (document.documentElement.classList.contains('dark')) { + document.documentElement.classList.remove('dark'); + localStorage.setItem('color-theme', 'light'); + } else { + document.documentElement.classList.add('dark'); + localStorage.setItem('color-theme', 'dark'); + } + + document.dispatchEvent(event); + }); +} diff --git a/src/AliasVault.Admin/wwwroot/js/utilities.js b/src/AliasVault.Admin/wwwroot/js/utilities.js new file mode 100644 index 000000000..023edf851 --- /dev/null +++ b/src/AliasVault.Admin/wwwroot/js/utilities.js @@ -0,0 +1,53 @@ +function downloadFileFromStream(fileName, contentStreamReference) { + const arrayBuffer = new Uint8Array(contentStreamReference).buffer; + const blob = new Blob([arrayBuffer]); + const url = URL.createObjectURL(blob); + const anchorElement = document.createElement('a'); + anchorElement.href = url; + anchorElement.download = fileName ?? ''; + anchorElement.click(); + anchorElement.remove(); + URL.revokeObjectURL(url); +} + +/** + * Generate a QR code for the given id element that has a data-url attribute. + * @param id + */ +function generateQrCode(id) { + console.log(`Generating QR code for element with id "${id}".`); + // Find the element by id + const element = document.getElementById(id); + + // Check if the element exists + if (!element) { + console.log(`Element with id "${id}" not found. QR code generation aborted.`); + return; // Silently fail + } + + // Get the data-url attribute + const dataUrl = element.getAttribute('data-url'); + + // Check if data-url exists + if (!dataUrl) { + console.log(`No data-url attribute found on element with id "${id}". QR code generation aborted.`); + return; // Silently fail + } + + // Create a container for the QR code + const qrContainer = document.createElement('div'); + qrContainer.id = `qrcode-${id}`; + element.appendChild(qrContainer); + + // Initialize QRCode object + let qrcode = new QRCode(qrContainer, { + width: 256, + height: 256, + colorDark : "#000000", + colorLight : "#ffffff", + correctLevel : QRCode.CorrectLevel.H + }); + + qrcode.makeCode(dataUrl); +} + diff --git a/src/AliasVault.Admin/wwwroot/lib/qrcode.min.js b/src/AliasVault.Admin/wwwroot/lib/qrcode.min.js new file mode 100644 index 000000000..993e88f39 --- /dev/null +++ b/src/AliasVault.Admin/wwwroot/lib/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/src/AliasVault.Api/AliasVault.Api.csproj b/src/AliasVault.Api/AliasVault.Api.csproj index f8db28bba..e9b9a6d26 100644 --- a/src/AliasVault.Api/AliasVault.Api.csproj +++ b/src/AliasVault.Api/AliasVault.Api.csproj @@ -7,6 +7,7 @@ AliasVault.Api Linux True + $(DefineConstants);E2ETEST @@ -20,13 +21,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -38,6 +39,7 @@ + diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index 1714ce74b..230183b51 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -20,13 +20,14 @@ using Asp.Versioning; using Cryptography.Models; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; /// /// Auth controller for handling authentication. /// -/// AliasServerDbContext instance. +/// AliasServerDbContext instance. /// UserManager instance. /// SignInManager instance. /// IConfiguration instance. @@ -35,7 +36,7 @@ using Microsoft.IdentityModel.Tokens; [Route("api/v{version:apiVersion}/[controller]")] [ApiController] [ApiVersion("1")] -public class AuthController(AliasServerDbContext context, UserManager userManager, SignInManager signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider) : ControllerBase +public class AuthController(IDbContextFactory dbContextFactory, UserManager userManager, SignInManager signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider) : ControllerBase { /// /// Error message for invalid email or password. @@ -114,6 +115,8 @@ public class AuthController(AliasServerDbContext context, UserManager Refresh([FromBody] TokenModel tokenModel) { + await using var context = await dbContextFactory.CreateDbContextAsync(); + var principal = GetPrincipalFromExpiredToken(tokenModel.Token); if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null) { @@ -129,20 +132,20 @@ public class AuthController(AliasServerDbContext context, UserManager t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier).FirstOrDefault(); + var existingToken = context.AliasVaultUserRefreshTokens.FirstOrDefault(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier); if (existingToken == null || existingToken.Value != tokenModel.RefreshToken || existingToken.ExpireDate < timeProvider.UtcNow) { return Unauthorized("Refresh token expired"); } // Remove the existing refresh token. - context.AspNetUserRefreshTokens.Remove(existingToken); + context.AliasVaultUserRefreshTokens.Remove(existingToken); // Generate a new refresh token to replace the old one. var newRefreshToken = GenerateRefreshToken(); // Add new refresh token. - await context.AspNetUserRefreshTokens.AddAsync(new AspNetUserRefreshToken + await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken { UserId = user.Id, DeviceIdentifier = deviceIdentifier, @@ -164,6 +167,8 @@ public class AuthController(AliasServerDbContext context, UserManager Revoke([FromBody] TokenModel model) { + await using var context = await dbContextFactory.CreateDbContextAsync(); + var principal = GetPrincipalFromExpiredToken(model.Token); if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null) { @@ -178,14 +183,14 @@ public class AuthController(AliasServerDbContext context, UserManager t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier).FirstOrDefault(); + var existingToken = context.AliasVaultUserRefreshTokens.FirstOrDefault(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier); if (existingToken == null || existingToken.Value != model.RefreshToken) { return Unauthorized("Invalid refresh token"); } // Remove the existing refresh token. - context.AspNetUserRefreshTokens.Remove(existingToken); + context.AliasVaultUserRefreshTokens.Remove(existingToken); await context.SaveChangesAsync(); return Ok("Refresh token revoked successfully"); @@ -330,6 +335,8 @@ public class AuthController(AliasServerDbContext context, UserManagerTokenModel which includes new access and refresh token. private async Task GenerateNewTokensForUser(AliasVaultUser user) { + await using var context = await dbContextFactory.CreateDbContextAsync(); + var token = GenerateJwtToken(user); var refreshToken = GenerateRefreshToken(); @@ -338,11 +345,11 @@ public class AuthController(AliasServerDbContext context, UserManager t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier); - context.AspNetUserRefreshTokens.RemoveRange(existingTokens); + var existingTokens = context.AliasVaultUserRefreshTokens.Where(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier); + context.AliasVaultUserRefreshTokens.RemoveRange(existingTokens); // Add new refresh token. - await context.AspNetUserRefreshTokens.AddAsync(new AspNetUserRefreshToken + await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken { UserId = user.Id, DeviceIdentifier = deviceIdentifier, @@ -352,6 +359,6 @@ public class AuthController(AliasServerDbContext context, UserManager [ApiController] [Route("/")] -public class RootController : ControllerBase +public class RootController(IDbContextFactory dbContextFactory) : ControllerBase { /// /// Root endpoint that returns a 200 OK if the database connection is successful @@ -26,24 +26,23 @@ public class RootController : ControllerBase [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult Get() + public async Task Get() { + await using var context = await dbContextFactory.CreateDbContextAsync(); + try { - using (var dbContext = new AliasServerDbContext()) + var appliedMigrations = await context.Database.GetAppliedMigrationsAsync(); + var allMigrations = context.Database.GetMigrations(); + + if (allMigrations.Except(appliedMigrations).Any()) { - var appliedMigrations = dbContext.Database.GetAppliedMigrations(); - var allMigrations = dbContext.Database.GetMigrations(); - - if (allMigrations.Except(appliedMigrations).Any()) - { - // There are pending migrations - return StatusCode(500, "There are pending migrations. Please run 'dotnet ef database update' to apply them."); - } - - // Database is up to date - return Ok("OK"); + // There are pending migrations + return StatusCode(500, "There are pending migrations. Please run 'dotnet ef database update' to apply them."); } + + // Database is up to date + return Ok("OK"); } catch { diff --git a/src/AliasVault.Api/Controllers/TestController.cs b/src/AliasVault.Api/Controllers/TestController.cs index a4535b7d5..9fdf2a3b6 100644 --- a/src/AliasVault.Api/Controllers/TestController.cs +++ b/src/AliasVault.Api/Controllers/TestController.cs @@ -5,10 +5,17 @@ // //----------------------------------------------------------------------- +/* + * Note: this file is used for E2E testing purposes only. It contains test endpoints that are called by pages on + * the client for testing purposes. Because certain endpoints that simulate exceptions are prone to Denial-Of-Service + * attack surfaces we don't include this file in the production build. + */ + namespace AliasVault.Api.Controllers; using AliasServerDb; using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -22,10 +29,22 @@ public class TestController(UserManager userManager) : Authentic /// /// Authenticated test request. /// - /// List of aliases in JSON format. + /// Static OK. [HttpGet("")] public IActionResult TestCall() { return Ok(); } + + /// + /// Test request that throws an exception. Used for testing error handling. + /// + /// Static OK. + [AllowAnonymous] + [HttpGet("Error")] + public IActionResult TestCallError() + { + // Throw an exception here to test error handling. + throw new ApplicationException("Test error"); + } } diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index 607e12b7b..89b82d526 100644 --- a/src/AliasVault.Api/Controllers/VaultController.cs +++ b/src/AliasVault.Api/Controllers/VaultController.cs @@ -19,11 +19,11 @@ using Microsoft.EntityFrameworkCore; /// /// Vault controller for handling CRUD operations on the database for encrypted vault entities. /// -/// DbContext instance. +/// DbContext instance. /// UserManager instance. /// ITimeProvider instance. [ApiVersion("1")] -public class VaultController(AliasServerDbContext context, UserManager userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager) +public class VaultController(IDbContextFactory dbContextFactory, UserManager userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager) { /// /// Default retention policy for vaults. @@ -46,6 +46,8 @@ public class VaultController(AliasServerDbContext context, UserManager GetVault() { + await using var context = await dbContextFactory.CreateDbContextAsync(); + var user = await GetCurrentUserAsync(); if (user == null) { @@ -76,6 +78,8 @@ public class VaultController(AliasServerDbContext context, UserManager Update([FromBody] Shared.Models.WebApi.Vault model) { + await using var context = await dbContextFactory.CreateDbContextAsync(); + var user = await GetCurrentUserAsync(); if (user == null) { diff --git a/src/AliasVault.Api/Program.cs b/src/AliasVault.Api/Program.cs index b374d790f..47478f1a6 100644 --- a/src/AliasVault.Api/Program.cs +++ b/src/AliasVault.Api/Program.cs @@ -6,9 +6,11 @@ //----------------------------------------------------------------------- using System.Data.Common; +using System.Reflection; using System.Text; using AliasServerDb; using AliasVault.Api.Jwt; +using AliasVault.Logging; using AliasVault.Shared.Providers.Time; using Asp.Versioning; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -19,7 +21,9 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); -var configuration = builder.Configuration; +builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); +builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); +builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../logs"); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -34,18 +38,13 @@ builder.Services.AddLogging(logging => builder.Services.AddSingleton(container => { - var configFile = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json") - .Build(); - - var connection = new SqliteConnection(configFile.GetConnectionString("AliasServerDbContext")); + var connection = new SqliteConnection(builder.Configuration.GetConnectionString("AliasServerDbContext")); connection.Open(); return connection; }); -builder.Services.AddDbContext((container, options) => +builder.Services.AddDbContextFactory((container, options) => { var connection = container.GetRequiredService(); options.UseSqlite(connection).UseLazyLoadingProxies(); @@ -57,7 +56,7 @@ builder.Services.Configure(options => options.TokenLifespan = TimeSpan.FromDays(30); options.Name = "AliasVault"; }); -builder.Services.AddIdentity(options => +builder.Services.AddIdentity(options => { options.Password.RequireDigit = false; options.Password.RequireLowercase = false; @@ -92,8 +91,8 @@ builder.Services.AddAuthentication(options => ValidateLifetime = true, RequireExpirationTime = true, ValidateIssuerSigningKey = true, - ValidIssuer = configuration["Jwt:Issuer"], - ValidAudience = configuration["Jwt:Issuer"], + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Issuer"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), ClockSkew = TimeSpan.Zero, }; @@ -167,8 +166,11 @@ if (app.Environment.IsDevelopment()) app.UseSwagger(); app.UseSwaggerUI(); } +else +{ + app.UseHttpsRedirection(); +} -app.UseHttpsRedirection(); app.UseCors("CorsPolicy"); app.UseAuthentication(); @@ -179,7 +181,7 @@ app.MapControllers(); using (var scope = app.Services.CreateScope()) { var container = scope.ServiceProvider; - var db = container.GetRequiredService(); + var db = await container.GetRequiredService>().CreateDbContextAsync(); await db.Database.MigrateAsync(); } diff --git a/src/AliasVault.Api/appsettings.Development.json b/src/AliasVault.Api/appsettings.Development.json index 43b43db8a..42ef3acff 100644 --- a/src/AliasVault.Api/appsettings.Development.json +++ b/src/AliasVault.Api/appsettings.Development.json @@ -1,8 +1,9 @@ { - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft.AspNetCore": "Debug" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } } - } } diff --git a/src/AliasVault.Api/appsettings.json b/src/AliasVault.Api/appsettings.json index b1e8ed26e..102d3665a 100644 --- a/src/AliasVault.Api/appsettings.json +++ b/src/AliasVault.Api/appsettings.json @@ -1,8 +1,9 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" } }, "Jwt": { diff --git a/src/AliasVault.Client/AliasVault.Client.csproj b/src/AliasVault.Client/AliasVault.Client.csproj index 6b73051f7..b00ced633 100644 --- a/src/AliasVault.Client/AliasVault.Client.csproj +++ b/src/AliasVault.Client/AliasVault.Client.csproj @@ -48,10 +48,10 @@ - - - - + + + + all diff --git a/src/AliasVault.Client/Main/Components/Alerts/ServerValidationErrors.razor b/src/AliasVault.Client/Main/Components/Alerts/ServerValidationErrors.razor index 6fd7ec31d..7777532b6 100644 --- a/src/AliasVault.Client/Main/Components/Alerts/ServerValidationErrors.razor +++ b/src/AliasVault.Client/Main/Components/Alerts/ServerValidationErrors.razor @@ -1,5 +1,3 @@ -@using AliasVault.Shared.Models.WebApi - @if (_errors.Any()) { @foreach (var error in _errors) diff --git a/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor b/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor index 3236271a3..42d50f7f2 100644 --- a/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor +++ b/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor @@ -34,9 +34,15 @@ @code { + /// + /// Attachments to be uploaded. + /// [Parameter] - public List Attachments { get; set; } = new List(); + public List Attachments { get; set; } = []; + /// + /// Callback that is invoked when the attachments are changed. + /// [Parameter] public EventCallback> AttachmentsChanged { get; set; } diff --git a/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor b/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor index 609d7ccef..467c2cab5 100644 --- a/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor +++ b/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor @@ -35,6 +35,9 @@ @code { + /// + /// The attachments to display. + /// [Parameter] public ICollection Attachments { get; set; } = new List(); diff --git a/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor index 89721349f..d14cea7ea 100644 --- a/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor +++ b/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor @@ -1,7 +1,4 @@ -@using AliasVault.Client.Services - - -@inject ClipboardCopyService ClipboardCopyService +@inject ClipboardCopyService ClipboardCopyService @inject IJSRuntime JsRuntime @implements IDisposable @@ -17,12 +14,22 @@ @code { - [Parameter] public string Label { get; set; } = "Value"; - [Parameter] public string Value { get; set; } = string.Empty; + /// + /// The label for the input. + /// + [Parameter] + public string Label { get; set; } = "Value"; + + /// + /// The value to copy to the clipboard. + /// + [Parameter] + public string Value { get; set; } = string.Empty; private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId; private readonly string _inputId = Guid.NewGuid().ToString(); + /// protected override void OnInitialized() { ClipboardCopyService.OnCopy += HandleCopy; @@ -46,6 +53,7 @@ StateHasChanged(); } + /// public void Dispose() { ClipboardCopyService.OnCopy -= HandleCopy; diff --git a/src/AliasVault.Client/Main/Components/Layout/LayoutPageTitle.razor b/src/AliasVault.Client/Main/Components/Layout/LayoutPageTitle.razor index 937a500f6..2b7652f24 100644 --- a/src/AliasVault.Client/Main/Components/Layout/LayoutPageTitle.razor +++ b/src/AliasVault.Client/Main/Components/Layout/LayoutPageTitle.razor @@ -1,6 +1,9 @@ @ChildContent - AliasVault @code { + /// + /// The content to display as prefix in the page title. + /// [Parameter] public RenderFragment ChildContent { get; set; } = default!; } diff --git a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor index 2439ad2c8..d2e7b4987 100644 --- a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor +++ b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor @@ -3,7 +3,7 @@ @inherits MainBase @inject CredentialService CredentialService @using System.Globalization -@using AliasGenerators.Implementations +@using AliasGenerators.Password @using AliasGenerators.Password.Implementations @if (EditMode) diff --git a/src/AliasVault.Client/Main/Pages/Sync/Sync.razor b/src/AliasVault.Client/Main/Pages/Sync/Sync.razor index 32633e3bb..1a449a35f 100644 --- a/src/AliasVault.Client/Main/Pages/Sync/Sync.razor +++ b/src/AliasVault.Client/Main/Pages/Sync/Sync.razor @@ -38,6 +38,7 @@ private DbServiceState.DatabaseState CurrentDbState { get; set; } = new(); private const int MinimumLoadingTimeMs = 800; + /// protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); @@ -118,6 +119,7 @@ } } + /// public void Dispose() { DbService.GetState().StateChanged -= OnDatabaseStateChanged; diff --git a/src/AliasVault.Client/Program.cs b/src/AliasVault.Client/Program.cs index 50916c66b..903c614ec 100644 --- a/src/AliasVault.Client/Program.cs +++ b/src/AliasVault.Client/Program.cs @@ -13,20 +13,24 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); +builder.Configuration.AddJsonFile($"appsettings.{builder.HostEnvironment.Environment}.json", optional: true, reloadOnChange: true); builder.Services.AddLogging(logging => { - logging.SetMinimumLevel(LogLevel.Warning); + if (builder.HostEnvironment.IsDevelopment()) + { + logging.SetMinimumLevel(LogLevel.Debug); + } + else + { + logging.SetMinimumLevel(LogLevel.Warning); + } + logging.AddFilter("Microsoft.AspNetCore.Identity.DataProtectorTokenProvider", LogLevel.Error); logging.AddFilter("Microsoft.AspNetCore.Identity.UserManager", LogLevel.Error); }); -builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); -if (builder.HostEnvironment.IsDevelopment()) -{ - builder.Configuration.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true); -} - builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); builder.Services.AddHttpClient("AliasVault.Api").AddHttpMessageHandler(); diff --git a/src/AliasVault.Shared/AliasVault.Shared.csproj b/src/AliasVault.Shared/AliasVault.Shared.csproj index d56a6ba10..49688d792 100644 --- a/src/AliasVault.Shared/AliasVault.Shared.csproj +++ b/src/AliasVault.Shared/AliasVault.Shared.csproj @@ -27,4 +27,8 @@ + + + + diff --git a/src/Databases/AliasClientDb/AliasClientDb.csproj b/src/Databases/AliasClientDb/AliasClientDb.csproj index daaaba977..a557111b0 100644 --- a/src/Databases/AliasClientDb/AliasClientDb.csproj +++ b/src/Databases/AliasClientDb/AliasClientDb.csproj @@ -17,15 +17,15 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + all diff --git a/src/Databases/AliasClientDb/AliasClientDbContext.cs b/src/Databases/AliasClientDb/AliasClientDbContext.cs index 519f71d4f..6a5b33d44 100644 --- a/src/Databases/AliasClientDb/AliasClientDbContext.cs +++ b/src/Databases/AliasClientDb/AliasClientDbContext.cs @@ -135,7 +135,8 @@ public class AliasClientDbContext : DbContext .UseSqlite(configuration.GetConnectionString("AliasClientDbContext")) .UseLazyLoadingProxies(); - optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information); + // Log queries made as debug output. + optionsBuilder.LogTo(Console.WriteLine); } base.OnConfiguring(optionsBuilder); diff --git a/src/Databases/AliasServerDb/AdminRole.cs b/src/Databases/AliasServerDb/AdminRole.cs new file mode 100644 index 000000000..191ed41d7 --- /dev/null +++ b/src/Databases/AliasServerDb/AdminRole.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using Microsoft.AspNetCore.Identity; + +/// +/// Extends IdentityRole with a string type. +/// +public class AdminRole : IdentityRole +{ + /// + /// Initializes a new instance of the class. + /// + public AdminRole() + : base() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Role name. + public AdminRole(string roleName) + : base(roleName) + { + } +} diff --git a/src/Databases/AliasServerDb/AdminUser.cs b/src/Databases/AliasServerDb/AdminUser.cs new file mode 100644 index 000000000..08aa690f1 --- /dev/null +++ b/src/Databases/AliasServerDb/AdminUser.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using Microsoft.AspNetCore.Identity; + +/// +/// Admin user extending IdentityUser only used for access to the admin panel. +/// +public class AdminUser : IdentityUser +{ + /// + /// Gets or sets the last time the password was changed. + /// + public DateTime? LastPasswordChanged { get; set; } +} diff --git a/src/Databases/AliasServerDb/AliasServerDb.csproj b/src/Databases/AliasServerDb/AliasServerDb.csproj index cbce773a5..0b2f7423e 100644 --- a/src/Databases/AliasServerDb/AliasServerDb.csproj +++ b/src/Databases/AliasServerDb/AliasServerDb.csproj @@ -16,17 +16,17 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + all @@ -41,4 +41,8 @@ + + + + diff --git a/src/Databases/AliasServerDb/AliasServerDbContext.cs b/src/Databases/AliasServerDb/AliasServerDbContext.cs index 14e4b3154..ba89850c4 100644 --- a/src/Databases/AliasServerDb/AliasServerDbContext.cs +++ b/src/Databases/AliasServerDb/AliasServerDbContext.cs @@ -7,14 +7,17 @@ namespace AliasServerDb; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using AliasVault.WorkerStatus.Database; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; /// -/// The AliasServerDbContext class. +/// The AliasServerDbContext class. Note: we are using DbContext instead of IdentityDbContext because +/// we have two separate user objects, one for the admin panel and one for the vault. We manually +/// define the Identity tables in the OnModelCreating method. /// -public class AliasServerDbContext : IdentityDbContext +public class AliasServerDbContext : WorkerStatusDbContext { /// /// Initializes a new instance of the class. @@ -32,16 +35,56 @@ public class AliasServerDbContext : IdentityDbContext { } - /// - /// Gets or sets the AspNetUserRefreshTokens DbSet. - /// - public DbSet AspNetUserRefreshTokens { get; set; } - /// /// Gets or sets the AliasVaultUser DbSet. /// public DbSet AliasVaultUsers { get; set; } + /// + /// Gets or sets the AliasVaultRoles DbSet. + /// + public DbSet AliasVaultRoles { get; set; } + + /// + /// Gets or sets the UserRoles DbSet. + /// + public DbSet> UserRoles { get; set; } + + /// + /// Gets or sets the UserClaims DbSet. + /// + public DbSet> UserClaims { get; set; } + + /// + /// Gets or sets the UserLogin DbSet. + /// + public DbSet> UserLogin { get; set; } + + /// + /// Gets or sets the RoleClaims DbSet. + /// + public DbSet> RoleClaims { get; set; } + + /// + /// Gets or sets the UserTokens DbSet. + /// + public DbSet> UserTokens { get; set; } + + /// + /// Gets or sets the UserRefreshTokens DbSet. + /// + public DbSet AliasVaultUserRefreshTokens { get; set; } + + /// + /// Gets or sets the AdminUser DbSet. + /// + public DbSet AdminUsers { get; set; } + + /// + /// Gets or sets the AdminRoles DbSet. + /// + public DbSet AdminRoles { get; set; } + /// /// Gets or sets the Vaults DbSet. /// @@ -57,14 +100,19 @@ public class AliasServerDbContext : IdentityDbContext /// public DbSet EmailAttachments { get; set; } + /// + /// Gets or sets the Logs DbSet. + /// + public DbSet Logs { get; set; } + /// /// The OnModelCreating method. /// - /// ModelBuilder instance. - protected override void OnModelCreating(ModelBuilder builder) + /// ModelBuilder instance. + protected override void OnModelCreating(ModelBuilder modelBuilder) { - base.OnModelCreating(builder); - foreach (var entity in builder.Model.GetEntityTypes()) + base.OnModelCreating(modelBuilder); + foreach (var entity in modelBuilder.Model.GetEntityTypes()) { foreach (var property in entity.GetProperties()) { @@ -79,24 +127,54 @@ public class AliasServerDbContext : IdentityDbContext } } - // Configure the User - AspNetUserRefreshToken entity. - builder.Entity() - .HasOne(p => p.User) - .WithMany() - .HasForeignKey(p => p.UserId) - .IsRequired(); + // Configure AspNetIdentity tables manually. + modelBuilder.Entity>(entity => + { + entity.HasKey(r => new { r.UserId, r.RoleId }); + entity.ToTable("UserRoles"); + }); - // Configure the Vault - UserId entity. - builder.Entity() - .HasOne(p => p.User) - .WithMany() - .HasForeignKey(p => p.UserId) - .IsRequired(); + modelBuilder.Entity>(entity => + { + entity.HasKey(c => c.Id); + entity.ToTable("UserClaims"); + }); - // Configure the Email - Attachments entity. - builder.Entity().HasOne(d => d.Email) - .WithMany(p => p.Attachments) - .HasForeignKey(d => d.EmailId); + modelBuilder.Entity>(entity => + { + entity.HasKey(l => new { l.LoginProvider, l.ProviderKey }); + entity.ToTable("UserLogins"); + }); + + modelBuilder.Entity>(entity => + { + entity.HasKey(rc => rc.Id); + entity.ToTable("RoleClaims"); + }); + + modelBuilder.Entity>(entity => + { + entity.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); + entity.ToTable("UserTokens"); + }); + + // Configure Log entity + modelBuilder.Entity(builder => + { + builder.ToTable("Logs"); + builder.Property(e => e.Application).HasMaxLength(50).IsRequired(); + builder.Property(e => e.Message); + builder.Property(e => e.MessageTemplate); + builder.Property(e => e.Level).HasMaxLength(128); + builder.Property(e => e.TimeStamp); + builder.Property(e => e.Exception); + builder.Property(e => e.Properties); + builder.Property(e => e.LogEvent); + + // Indexes for faster querying + builder.HasIndex(e => e.TimeStamp); + builder.HasIndex(e => e.Application); + }); } /// diff --git a/src/Databases/AliasServerDb/AliasVaultRole.cs b/src/Databases/AliasServerDb/AliasVaultRole.cs new file mode 100644 index 000000000..f36e2af3f --- /dev/null +++ b/src/Databases/AliasServerDb/AliasVaultRole.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using Microsoft.AspNetCore.Identity; + +/// +/// Extends IdentityRole with a string type. +/// +public class AliasVaultRole : IdentityRole +{ + /// + /// Initializes a new instance of the class. + /// + public AliasVaultRole() + : base() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Role name. + public AliasVaultRole(string roleName) + : base(roleName) + { + } +} diff --git a/src/Databases/AliasServerDb/AspNetUserRefreshToken.cs b/src/Databases/AliasServerDb/AliasVaultUserRefreshToken.cs similarity index 93% rename from src/Databases/AliasServerDb/AspNetUserRefreshToken.cs rename to src/Databases/AliasServerDb/AliasVaultUserRefreshToken.cs index fbf8bf9d2..e74624522 100644 --- a/src/Databases/AliasServerDb/AspNetUserRefreshToken.cs +++ b/src/Databases/AliasServerDb/AliasVaultUserRefreshToken.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) lanedirt. All rights reserved. // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // @@ -13,7 +13,7 @@ using Microsoft.AspNetCore.Identity; /// /// Refresh tokens for users. /// -public class AspNetUserRefreshToken +public class AliasVaultUserRefreshToken { /// /// Gets or sets Refresh Token ID. diff --git a/src/Databases/AliasServerDb/Email.cs b/src/Databases/AliasServerDb/Email.cs index daf1f95b5..e5fd7f565 100644 --- a/src/Databases/AliasServerDb/Email.cs +++ b/src/Databases/AliasServerDb/Email.cs @@ -19,14 +19,6 @@ using Microsoft.EntityFrameworkCore; [Index(nameof(PushNotificationSent))] public class Email { - /// - /// Initializes a new instance of the class. - /// - public Email() - { - Attachments = new HashSet(); - } - /// /// Gets or sets the ID of the email. /// @@ -110,5 +102,5 @@ public class Email /// /// Gets or sets the collection of email attachments. /// - public virtual ICollection Attachments { get; set; } + public virtual ICollection Attachments { get; set; } = []; } diff --git a/src/Databases/AliasServerDb/Log.cs b/src/Databases/AliasServerDb/Log.cs new file mode 100644 index 000000000..af40a2faa --- /dev/null +++ b/src/Databases/AliasServerDb/Log.cs @@ -0,0 +1,68 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +/// +/// Represents a log entry in the AliasServerDb. +/// +public class Log +{ + /// + /// Gets or sets the unique identifier of the log entry. + /// + [Key] + public int Id { get; set; } + + /// + /// Gets or sets the application name associated with the log entry. + /// + [Required] + [Column(TypeName = "nvarchar(50)")] + public string Application { get; set; } = null!; + + /// + /// Gets or sets the log message. + /// + public string Message { get; set; } = null!; + + /// + /// Gets or sets the message template for the log entry. + /// + public string MessageTemplate { get; set; } = null!; + + /// + /// Gets or sets the log level. + /// + [Column(TypeName = "nvarchar(128)")] + public string Level { get; set; } = null!; + + /// + /// Gets or sets the timestamp of the log entry. + /// + public DateTimeOffset TimeStamp { get; set; } + + /// + /// Gets or sets the exception associated with the log entry. + /// + public string Exception { get; set; } = null!; + + /// + /// Gets or sets the additional properties of the log entry. + /// + public string Properties { get; set; } = null!; + + /// + /// Gets or sets the log event. + /// + [Column("LogEvent")] + public string LogEvent { get; set; } = null!; +} diff --git a/src/Databases/AliasServerDb/Migrations/20240720211546_AddAdminUsersTable.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240720211546_AddAdminUsersTable.Designer.cs new file mode 100644 index 000000000..eb1b8ad99 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240720211546_AddAdminUsersTable.Designer.cs @@ -0,0 +1,484 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240720211546_AddAdminUsersTable")] + partial class AddAdminUsersTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240720211546_AddAdminUsersTable.cs b/src/Databases/AliasServerDb/Migrations/20240720211546_AddAdminUsersTable.cs new file mode 100644 index 000000000..8717d79a9 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240720211546_AddAdminUsersTable.cs @@ -0,0 +1,526 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddAdminUsersTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + table: "AspNetRoleClaims"); + + migrationBuilder.DropForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + table: "AspNetUserClaims"); + + migrationBuilder.DropForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + table: "AspNetUserLogins"); + + migrationBuilder.DropForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + table: "AspNetUserRoles"); + + migrationBuilder.DropForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + table: "AspNetUserRoles"); + + migrationBuilder.DropForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + table: "AspNetUserTokens"); + + migrationBuilder.DropForeignKey( + name: "FK_Vaults_AspNetUsers_UserId", + table: "Vaults"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserRefreshTokens"); + + migrationBuilder.DropPrimaryKey( + name: "PK_AspNetUserTokens", + table: "AspNetUserTokens"); + + migrationBuilder.DropPrimaryKey( + name: "PK_AspNetUsers", + table: "AspNetUsers"); + + migrationBuilder.DropIndex( + name: "EmailIndex", + table: "AspNetUsers"); + + migrationBuilder.DropIndex( + name: "UserNameIndex", + table: "AspNetUsers"); + + migrationBuilder.DropPrimaryKey( + name: "PK_AspNetUserRoles", + table: "AspNetUserRoles"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles"); + + migrationBuilder.DropPrimaryKey( + name: "PK_AspNetUserLogins", + table: "AspNetUserLogins"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins"); + + migrationBuilder.DropPrimaryKey( + name: "PK_AspNetUserClaims", + table: "AspNetUserClaims"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims"); + + migrationBuilder.DropPrimaryKey( + name: "PK_AspNetRoleClaims", + table: "AspNetRoleClaims"); + + migrationBuilder.DropIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims"); + + migrationBuilder.RenameTable( + name: "AspNetUserTokens", + newName: "UserTokens"); + + migrationBuilder.RenameTable( + name: "AspNetUsers", + newName: "AliasVaultUsers"); + + migrationBuilder.RenameTable( + name: "AspNetUserRoles", + newName: "UserRoles"); + + migrationBuilder.RenameTable( + name: "AspNetUserLogins", + newName: "UserLogins"); + + migrationBuilder.RenameTable( + name: "AspNetUserClaims", + newName: "UserClaims"); + + migrationBuilder.RenameTable( + name: "AspNetRoleClaims", + newName: "RoleClaims"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "UserLogins", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "UserClaims", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "RoleId", + table: "RoleClaims", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AddPrimaryKey( + name: "PK_UserTokens", + table: "UserTokens", + columns: new[] { "UserId", "LoginProvider", "Name" }); + + migrationBuilder.AddPrimaryKey( + name: "PK_AliasVaultUsers", + table: "AliasVaultUsers", + column: "Id"); + + migrationBuilder.AddPrimaryKey( + name: "PK_UserRoles", + table: "UserRoles", + columns: new[] { "UserId", "RoleId" }); + + migrationBuilder.AddPrimaryKey( + name: "PK_UserLogins", + table: "UserLogins", + columns: new[] { "LoginProvider", "ProviderKey" }); + + migrationBuilder.AddPrimaryKey( + name: "PK_UserClaims", + table: "UserClaims", + column: "Id"); + + migrationBuilder.AddPrimaryKey( + name: "PK_RoleClaims", + table: "RoleClaims", + column: "Id"); + + migrationBuilder.CreateTable( + name: "AdminRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AdminRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AdminUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", nullable: true), + NormalizedUserName = table.Column(type: "TEXT", nullable: true), + Email = table.Column(type: "TEXT", nullable: true), + NormalizedEmail = table.Column(type: "TEXT", nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AdminUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AliasVaultRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AliasVaultRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AliasVaultUserRefreshTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + DeviceIdentifier = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Value = table.Column(type: "TEXT", maxLength: 255, nullable: false), + ExpireDate = table.Column(type: "TEXT", maxLength: 255, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AliasVaultUserRefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_AliasVaultUserRefreshTokens_AliasVaultUsers_UserId", + column: x => x.UserId, + principalTable: "AliasVaultUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AliasVaultUserRefreshTokens_UserId", + table: "AliasVaultUserRefreshTokens", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Vaults_AliasVaultUsers_UserId", + table: "Vaults", + column: "UserId", + principalTable: "AliasVaultUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Vaults_AliasVaultUsers_UserId", + table: "Vaults"); + + migrationBuilder.DropTable( + name: "AdminRoles"); + + migrationBuilder.DropTable( + name: "AdminUsers"); + + migrationBuilder.DropTable( + name: "AliasVaultRoles"); + + migrationBuilder.DropTable( + name: "AliasVaultUserRefreshTokens"); + + migrationBuilder.DropPrimaryKey( + name: "PK_UserTokens", + table: "UserTokens"); + + migrationBuilder.DropPrimaryKey( + name: "PK_UserRoles", + table: "UserRoles"); + + migrationBuilder.DropPrimaryKey( + name: "PK_UserLogins", + table: "UserLogins"); + + migrationBuilder.DropPrimaryKey( + name: "PK_UserClaims", + table: "UserClaims"); + + migrationBuilder.DropPrimaryKey( + name: "PK_RoleClaims", + table: "RoleClaims"); + + migrationBuilder.DropPrimaryKey( + name: "PK_AliasVaultUsers", + table: "AliasVaultUsers"); + + migrationBuilder.RenameTable( + name: "UserTokens", + newName: "AspNetUserTokens"); + + migrationBuilder.RenameTable( + name: "UserRoles", + newName: "AspNetUserRoles"); + + migrationBuilder.RenameTable( + name: "UserLogins", + newName: "AspNetUserLogins"); + + migrationBuilder.RenameTable( + name: "UserClaims", + newName: "AspNetUserClaims"); + + migrationBuilder.RenameTable( + name: "RoleClaims", + newName: "AspNetRoleClaims"); + + migrationBuilder.RenameTable( + name: "AliasVaultUsers", + newName: "AspNetUsers"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "AspNetUserLogins", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "AspNetUserClaims", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "RoleId", + table: "AspNetRoleClaims", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_AspNetUserTokens", + table: "AspNetUserTokens", + columns: new[] { "UserId", "LoginProvider", "Name" }); + + migrationBuilder.AddPrimaryKey( + name: "PK_AspNetUserRoles", + table: "AspNetUserRoles", + columns: new[] { "UserId", "RoleId" }); + + migrationBuilder.AddPrimaryKey( + name: "PK_AspNetUserLogins", + table: "AspNetUserLogins", + columns: new[] { "LoginProvider", "ProviderKey" }); + + migrationBuilder.AddPrimaryKey( + name: "PK_AspNetUserClaims", + table: "AspNetUserClaims", + column: "Id"); + + migrationBuilder.AddPrimaryKey( + name: "PK_AspNetRoleClaims", + table: "AspNetRoleClaims", + column: "Id"); + + migrationBuilder.AddPrimaryKey( + name: "PK_AspNetUsers", + table: "AspNetUsers", + column: "Id"); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRefreshTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + DeviceIdentifier = table.Column(type: "TEXT", maxLength: 255, nullable: false), + ExpireDate = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Value = table.Column(type: "TEXT", maxLength: 255, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserRefreshTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRefreshTokens_UserId", + table: "AspNetUserRefreshTokens", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + table: "AspNetRoleClaims", + column: "RoleId", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + table: "AspNetUserClaims", + column: "UserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + table: "AspNetUserLogins", + column: "UserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + table: "AspNetUserRoles", + column: "UserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + table: "AspNetUserTokens", + column: "UserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Vaults_AspNetUsers_UserId", + table: "Vaults", + column: "UserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.Designer.cs new file mode 100644 index 000000000..d66644047 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.Designer.cs @@ -0,0 +1,487 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240722144205_AddAdminUserPasswordSetDate")] + partial class AddAdminUserPasswordSetDate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.cs b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.cs new file mode 100644 index 000000000..4949d21e1 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.cs @@ -0,0 +1,30 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddAdminUserPasswordSetDate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastPasswordChanged", + table: "AdminUsers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastPasswordChanged", + table: "AdminUsers"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240723194939_CreateLogTable.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240723194939_CreateLogTable.Designer.cs new file mode 100644 index 000000000..97b3cf9e8 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240723194939_CreateLogTable.Designer.cs @@ -0,0 +1,536 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240723194939_CreateLogTable")] + partial class CreateLogTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240723194939_CreateLogTable.cs b/src/Databases/AliasServerDb/Migrations/20240723194939_CreateLogTable.cs new file mode 100644 index 000000000..0343633dc --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240723194939_CreateLogTable.cs @@ -0,0 +1,53 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class CreateLogTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Logs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Application = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Message = table.Column(type: "TEXT", nullable: false), + MessageTemplate = table.Column(type: "TEXT", nullable: false), + Level = table.Column(type: "TEXT", maxLength: 128, nullable: false), + TimeStamp = table.Column(type: "TEXT", nullable: false), + Exception = table.Column(type: "TEXT", nullable: false), + Properties = table.Column(type: "TEXT", nullable: false), + LogEvent = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Logs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Logs_Application", + table: "Logs", + column: "Application"); + + migrationBuilder.CreateIndex( + name: "IX_Logs_TimeStamp", + table: "Logs", + column: "TimeStamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Logs"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240725202058_WorkerStatusTable.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240725202058_WorkerStatusTable.Designer.cs new file mode 100644 index 000000000..0f219651f --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240725202058_WorkerStatusTable.Designer.cs @@ -0,0 +1,565 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240725202058_WorkerStatusTable")] + partial class WorkerStatusTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("AliasVault.WorkerStatus.WorkerServiceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Heartbeat") + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar"); + + b.HasKey("Id"); + + b.ToTable("WorkerServiceStatuses"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240725202058_WorkerStatusTable.cs b/src/Databases/AliasServerDb/Migrations/20240725202058_WorkerStatusTable.cs new file mode 100644 index 000000000..4252d1ed5 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240725202058_WorkerStatusTable.cs @@ -0,0 +1,39 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class WorkerStatusTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WorkerServiceStatuses", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ServiceName = table.Column(type: "varchar", maxLength: 255, nullable: false), + CurrentStatus = table.Column(type: "TEXT", maxLength: 50, nullable: false), + DesiredStatus = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Heartbeat = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkerServiceStatuses", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkerServiceStatuses"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 8d980b6c1..b7f4746c5 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -16,11 +16,104 @@ namespace AliasServerDb.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("ProductVersion", "8.0.7") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true); + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => { b.Property("Id") @@ -30,11 +123,9 @@ namespace AliasServerDb.Migrations .HasColumnType("INTEGER"); b.Property("ConcurrencyStamp") - .IsConcurrencyToken() .HasColumnType("TEXT"); b.Property("Email") - .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("EmailConfirmed") @@ -47,11 +138,9 @@ namespace AliasServerDb.Migrations .HasColumnType("TEXT"); b.Property("NormalizedEmail") - .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("NormalizedUserName") - .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PasswordHash") @@ -74,7 +163,6 @@ namespace AliasServerDb.Migrations .HasColumnType("INTEGER"); b.Property("UserName") - .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("Verifier") @@ -83,17 +171,10 @@ namespace AliasServerDb.Migrations b.HasKey("Id"); - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); + b.ToTable("AliasVaultUsers"); }); - modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b => + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -125,7 +206,7 @@ namespace AliasServerDb.Migrations b.HasIndex("UserId"); - b.ToTable("AspNetUserRefreshTokens"); + b.ToTable("AliasVaultUserRefreshTokens"); }); modelBuilder.Entity("AliasServerDb.Email", b => @@ -236,6 +317,55 @@ namespace AliasServerDb.Migrations b.ToTable("EmailAttachments"); }); + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + modelBuilder.Entity("AliasServerDb.Vault", b => { b.Property("Id") @@ -269,30 +399,33 @@ namespace AliasServerDb.Migrations b.ToTable("Vaults"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + modelBuilder.Entity("AliasVault.WorkerStatus.WorkerServiceStatus", b => { - b.Property("Id") + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) .HasColumnType("TEXT"); - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) .HasColumnType("TEXT"); - b.Property("Name") - .HasMaxLength(256) + b.Property("Heartbeat") .HasColumnType("TEXT"); - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar"); b.HasKey("Id"); - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", (string)null); + b.ToTable("WorkerServiceStatuses"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => @@ -308,14 +441,11 @@ namespace AliasServerDb.Migrations .HasColumnType("TEXT"); b.Property("RoleId") - .IsRequired() .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); + b.ToTable("RoleClaims", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => @@ -331,14 +461,11 @@ namespace AliasServerDb.Migrations .HasColumnType("TEXT"); b.Property("UserId") - .IsRequired() .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); + b.ToTable("UserClaims", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => @@ -353,14 +480,11 @@ namespace AliasServerDb.Migrations .HasColumnType("TEXT"); b.Property("UserId") - .IsRequired() .HasColumnType("TEXT"); b.HasKey("LoginProvider", "ProviderKey"); - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); + b.ToTable("UserLogins", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => @@ -373,9 +497,7 @@ namespace AliasServerDb.Migrations b.HasKey("UserId", "RoleId"); - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); + b.ToTable("UserRoles", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => @@ -394,10 +516,10 @@ namespace AliasServerDb.Migrations b.HasKey("UserId", "LoginProvider", "Name"); - b.ToTable("AspNetUserTokens", (string)null); + b.ToTable("UserTokens", (string)null); }); - modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b => + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => { b.HasOne("AliasServerDb.AliasVaultUser", "User") .WithMany() @@ -430,57 +552,6 @@ namespace AliasServerDb.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("AliasServerDb.AliasVaultUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("AliasServerDb.AliasVaultUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("AliasServerDb.AliasVaultUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("AliasServerDb.AliasVaultUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("AliasServerDb.Email", b => { b.Navigation("Attachments"); diff --git a/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj index cf16cdd4e..634909937 100644 --- a/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj +++ b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj @@ -15,7 +15,7 @@ - + @@ -23,5 +23,10 @@ + + + + + diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 3839cb375..e8f1b1f39 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -71,7 +71,7 @@ public class DatabaseMessageStore(ILogger logger, Config c if (toAddress == null) { // No toAddress, skip. - logger.LogWarning("Skip email, no toAddress available"); + logger.LogWarning("Skip email, no toAddress available."); return SmtpResponse.NoValidRecipientsGiven; } if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) @@ -84,12 +84,12 @@ public class DatabaseMessageStore(ILogger logger, Config c } // If only one recipient, return error. - logger.LogWarning("Email to {ToAddress} is not allowed", toAddress.User + "@" + toAddress.Host); + logger.LogWarning("Rejected email: email for {ToAddress} is not allowed.", toAddress.User + "@" + toAddress.Host); return SmtpResponse.NoValidRecipientsGiven; } var insertedId = await InsertEmailIntoDatabase(message); - logger.LogInformation("Email saved into database with ID {insertedId}.", insertedId); + logger.LogInformation("Email for {ToAddress} successfully saved into database with ID {insertedId}.", toAddress.User + "@" + toAddress.Host, insertedId); } return SmtpResponse.Ok; diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index 4aea2c4ab..c9e63546c 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -6,6 +6,7 @@ //----------------------------------------------------------------------- using System.Data.Common; +using System.Reflection; using System.Security.Cryptography.X509Certificates; using AliasServerDb; using AliasVault.SmtpService; @@ -14,8 +15,14 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using SmtpServer; using SmtpServer.Storage; +using AliasVault.Logging; +using AliasVault.SmtpService.Workers; +using AliasVault.WorkerStatus.ServiceExtensions; var builder = Host.CreateApplicationBuilder(args); +builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); +builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); +builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../../logs"); // Create global config object, get values from environment variables. Config config = new Config(); @@ -30,12 +37,7 @@ builder.Services.AddSingleton(config); builder.Services.AddSingleton(container => { - var configFile = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json") - .Build(); - - var connection = new SqliteConnection(configFile.GetConnectionString("AliasServerDbContext")); + var connection = new SqliteConnection(builder.Configuration.GetConnectionString("AliasServerDbContext")); connection.Open(); return connection; @@ -48,7 +50,6 @@ builder.Services.AddDbContextFactory((container, options) }); builder.Services.AddTransient(); - builder.Services.AddSingleton( provider => { @@ -119,15 +120,20 @@ builder.Services.AddSingleton( } ); -builder.Services.AddHostedService(); +// ----------------------------------------------------------------------- +// Register hosted services via Status library wrapper in order to monitor and control (start/stop) them via the database. +// ----------------------------------------------------------------------- +builder.Services.AddStatusHostedService(Assembly.GetExecutingAssembly().GetName().Name!); +// ----------------------------------------------------------------------- + var host = builder.Build(); using (var scope = host.Services.CreateScope()) { var container = scope.ServiceProvider; - var db = container.GetRequiredService(); - - await db.Database.MigrateAsync(); + var factory = container.GetRequiredService>(); + await using var context = await factory.CreateDbContextAsync(); + await context.Database.MigrateAsync(); } await host.RunAsync(); diff --git a/src/Services/AliasVault.SmtpService/Worker.cs b/src/Services/AliasVault.SmtpService/Worker.cs deleted file mode 100644 index 028d9707a..000000000 --- a/src/Services/AliasVault.SmtpService/Worker.cs +++ /dev/null @@ -1,22 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) lanedirt. All rights reserved. -// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. -// -//----------------------------------------------------------------------- - -namespace AliasVault.SmtpService -{ - public class Worker(ILogger logger, SmtpServer.SmtpServer smtpServer) : BackgroundService - { - /// - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - if (logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("AliasVault.SmtpService running at: {Time}", DateTimeOffset.Now); - } - return smtpServer.StartAsync(stoppingToken); - } - } -} diff --git a/src/Services/AliasVault.SmtpService/Workers/SmtpServerWorker.cs b/src/Services/AliasVault.SmtpService/Workers/SmtpServerWorker.cs new file mode 100644 index 000000000..66bae290b --- /dev/null +++ b/src/Services/AliasVault.SmtpService/Workers/SmtpServerWorker.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.SmtpService.Workers; + +public class SmtpServerWorker(ILogger logger, SmtpServer.SmtpServer smtpServer) : BackgroundService +{ + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogWarning("AliasVault.SmtpService started at: {Time}", DateTimeOffset.Now); + + // Start the SMTP server + await smtpServer.StartAsync(stoppingToken); + } +} diff --git a/src/Services/AliasVault.SmtpService/appsettings.Development.json b/src/Services/AliasVault.SmtpService/appsettings.Development.json index b2dcdb674..df591876d 100644 --- a/src/Services/AliasVault.SmtpService/appsettings.Development.json +++ b/src/Services/AliasVault.SmtpService/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" } } } diff --git a/src/Services/AliasVault.SmtpService/appsettings.json b/src/Services/AliasVault.SmtpService/appsettings.json index c74fb8b2a..ddbf1678f 100644 --- a/src/Services/AliasVault.SmtpService/appsettings.json +++ b/src/Services/AliasVault.SmtpService/appsettings.json @@ -1,8 +1,9 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" } }, "ConnectionStrings": { diff --git a/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj b/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj index e8a408ac1..f0d5ce876 100644 --- a/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj +++ b/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj @@ -12,11 +12,13 @@ true bin\Debug\net8.0\AliasVault.E2ETests.xml + TRACE true bin\Release\net8.0\AliasVault.E2ETests.xml + TRACE @@ -24,15 +26,15 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -44,6 +46,7 @@ + diff --git a/src/Tests/AliasVault.E2ETests/Common/AdminPlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/AdminPlaywrightTest.cs new file mode 100644 index 000000000..e748600b2 --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Common/AdminPlaywrightTest.cs @@ -0,0 +1,104 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.E2ETests.Common; + +using AliasServerDb; + +/// +/// Base class for Playwright E2E tests that run against Admin webapp. +/// +public class AdminPlaywrightTest : PlaywrightTest +{ + private static readonly int _basePort = 5700; + private static int _currentPort = _basePort; + + /// + /// For starting the Admin project in-memory. + /// + private readonly WebApplicationAdminFactoryFixture _webAppFactory = new(); + + /// + /// Gets the db context for the Admin project. + /// + protected AliasServerDbContext DbContext => _webAppFactory.GetDbContext(); + + /// + /// Tear down the Playwright test which runs after all tests are done in the class. + /// + /// Async task. + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + await Page.CloseAsync(); + await Context.CloseAsync(); + await Browser.CloseAsync(); + + await _webAppFactory.DisposeAsync(); + } + + /// + /// Setup the Playwright test environment. + /// + /// Async task. + protected override async Task SetupEnvironment() + { + // Set the base port for the test starting at 5600. Increase the port by 2 for each test running + // in parallel to avoid port conflicts. + int appPort; + lock (Lock) + { + appPort = Interlocked.Increment(ref _currentPort); + } + + AppBaseUrl = "http://localhost:" + appPort + "/"; + + // Start Admin project in-memory. + _webAppFactory.HostUrl = "http://localhost:" + appPort; + _webAppFactory.CreateDefaultClient(); + + await SetupPlaywrightBrowserAndContext(); + Page = await Context.NewPageAsync(); + InputHelper = new(Page); + + // Check that we get redirected to /user/login when accessing the root URL and not authenticated. + await Page.GotoAsync(AppBaseUrl); + await WaitForUrlAsync("user/login**"); + + // These credentials are based on the static environment variables set by WebApplicationAdminFactoryFixture.cs + // for this test. + TestUserUsername = "admin"; + TestUserPassword = "password"; + await LoginAsAdmin(); + } + + /// + /// Login to the Admin webapp as the default admin user. + /// + /// Async task. + protected async Task LoginAsAdmin() + { + // Check that we are on the login page. + await WaitForUrlAsync("user/login**"); + + // Enter login credentials. + await InputHelper.FillInputFields(new Dictionary + { + { "username", TestUserUsername }, + { "password", TestUserPassword }, + }); + + var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Login" }); + await submitButton.ClickAsync(); + + // Wait for the dashboard to load. + await WaitForUrlAsync("**", "Welcome"); + + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("Welcome to the AliasVault admin portal"), "No entry page content visible after logging in to admin app."); + } +} diff --git a/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs new file mode 100644 index 000000000..b848e22c6 --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs @@ -0,0 +1,225 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.E2ETests.Common; + +using AliasServerDb; +using AliasVault.Shared.Providers.Time; +using Microsoft.Playwright; + +/// +/// Base class for tests that use Playwright for E2E browser testing. +/// +public class ClientPlaywrightTest : PlaywrightTest +{ + private static readonly int _basePort = 5600; + private static int _currentPort = _basePort; + + /// + /// For starting the WebAPI project in-memory. + /// + private readonly WebApplicationApiFactoryFixture _apiFactory = new(); + + /// + /// For starting the Client project in-memory. + /// + private readonly WebApplicationClientFactoryFixture _clientFactory = new(); + + /// + /// Gets the time provider instance for mutating the current WebApi time in tests. + /// + protected TestTimeProvider ApiTimeProvider => _apiFactory.TimeProvider; + + /// + /// Gets the db context for the WebAPI project. + /// + protected AliasServerDbContext ApiDbContext => _apiFactory.GetDbContext(); + + /// + /// Gets or sets the base URL where the WebAPI project runs on including random port. + /// + protected string ApiBaseUrl { get; set; } = string.Empty; + + /// + /// Tear down the Playwright test which runs after all tests are done in the class. + /// + /// Async task. + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + await Page.CloseAsync(); + await Context.CloseAsync(); + await Browser.CloseAsync(); + + await _apiFactory.DisposeAsync(); + await _clientFactory.DisposeAsync(); + } + + /// + /// Setup the Playwright test environment. + /// + /// Async task. + protected override async Task SetupEnvironment() + { + // Set the base port for the test starting at 5600. Increase the port by 2 for each test running + // in parallel to avoid port conflicts. + int apiPort; + int appPort; + lock (Lock) + { + apiPort = Interlocked.Increment(ref _currentPort); + appPort = Interlocked.Increment(ref _currentPort); + } + + AppBaseUrl = "http://localhost:" + appPort + "/"; + ApiBaseUrl = "http://localhost:" + apiPort + "/"; + + // Start WebAPI in-memory. + _apiFactory.HostUrl = "http://localhost:" + apiPort; + _apiFactory.CreateDefaultClient(); + + // Start Blazor WASM in-memory. + _clientFactory.HostUrl = "http://localhost:" + appPort; + _clientFactory.CreateDefaultClient(); + + await SetupPlaywrightBrowserAndContext(); + + // Intercept Blazor WASM app requests to override appsettings.json + await Context.RouteAsync( + "**/appsettings.json", + async route => + { + var response = new + { + ApiUrl = ApiBaseUrl.TrimEnd('/'), + }; + await route.FulfillAsync( + new RouteFulfillOptions + { + ContentType = "application/json", + Body = System.Text.Json.JsonSerializer.Serialize(response), + }); + }); + await Context.RouteAsync( + "**/appsettings.Development.json", + async route => + { + var response = new + { + ApiUrl = ApiBaseUrl.TrimEnd('/'), + }; + await route.FulfillAsync( + new RouteFulfillOptions + { + ContentType = "application/json", + Body = System.Text.Json.JsonSerializer.Serialize(response), + }); + }); + + Page = await Context.NewPageAsync(); + InputHelper = new(Page); + + // Check that we get redirected to /user/login when accessing the root URL and not authenticated. + await Page.GotoAsync(AppBaseUrl); + await WaitForUrlAsync("user/login"); + + // Register a new account here because every test requires this. + await Register(); + } + + /// + /// Refresh the page which will lock the vault, then enter password to unlock the vault again. + /// + /// Async task. + protected async Task RefreshPageAndUnlockVault() + { + // Get current URL. + var currentUrl = Page.Url; + + // Hard refresh the page. + await Page.ReloadAsync(); + + // Check if the unlock page is displayed. + await WaitForUrlAsync("unlock", "unlock"); + + // Check if by entering password the unlock page is replaced by the alias listing page. + await InputHelper.FillInputFields(new Dictionary + { + { "password", TestUserPassword }, + }); + + var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" }); + await submitButton.ClickAsync(); + + // Wait for the original page to load again. + await WaitForUrlAsync(currentUrl); + } + + /// + /// Create new credential entry. + /// + /// Dictionary with html element ids and values to input as field value. + /// Async task. + protected async Task CreateCredentialEntry(Dictionary? formValues = null) + { + await NavigateUsingBlazorRouter("add-credentials"); + await WaitForUrlAsync("add-credentials", "Add credentials"); + + // Check if a button with text "Generate Random Identity" appears + var generateButton = Page.Locator("text=Generate Random Identity"); + Assert.That(generateButton, Is.Not.Null, "Generate button not found."); + + // Fill all input fields with specified values and remaining empty fields with random data. + await InputHelper.FillInputFields(formValues); + await InputHelper.FillEmptyInputFieldsWithRandom(); + + var submitButton = Page.Locator("text=Save Credentials").First; + await submitButton.ClickAsync(); + await WaitForUrlAsync("credentials/**", "Login credentials"); + + // Check if the credential was created + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("Login credentials"), "Credential not created."); + } + + /// + /// Register a new random account. + /// + /// Async task. + private async Task Register() + { + // If email is not set by test explicitly, generate a random email. + TestUserUsername = TestUserUsername.Length > 0 ? TestUserUsername : $"{Guid.NewGuid()}@test.com"; + + // If password is not set by test explicitly, generate a random password. + TestUserPassword = TestUserPassword.Length > 0 ? TestUserPassword : Guid.NewGuid().ToString(); + + // Try to register a new account. + var registerButton = Page.Locator("a[href='/user/register']"); + await registerButton.ClickAsync(); + await WaitForUrlAsync("user/register"); + + // Try to register an account with the generated test credentials. + var emailField = Page.Locator("input[id='email']"); + var passwordField = Page.Locator("input[id='password']"); + var password2Field = Page.Locator("input[id='password2']"); + await emailField.FillAsync(TestUserUsername); + await passwordField.FillAsync(TestUserPassword); + await password2Field.FillAsync(TestUserPassword); + + // Check the terms of service checkbox + var termsCheckbox = Page.Locator("input[id='terms']"); + await termsCheckbox.CheckAsync(); + + // Check if we get redirected when clicking on the register button. + var submitButton = Page.Locator("button[type='submit']"); + await submitButton.ClickAsync(); + + // Check if we get redirected to the root URL after registration which means we are logged in. + await WaitForUrlAsync(AppBaseUrl, "Find all of your credentials below"); + } +} diff --git a/src/Tests/AliasVault.E2ETests/Common/PlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/PlaywrightTest.cs index 4919c836a..8db769afe 100644 --- a/src/Tests/AliasVault.E2ETests/Common/PlaywrightTest.cs +++ b/src/Tests/AliasVault.E2ETests/Common/PlaywrightTest.cs @@ -7,44 +7,23 @@ namespace AliasVault.E2ETests.Common; -using AliasServerDb; -using AliasVault.Shared.Providers.Time; using Microsoft.Extensions.Configuration; using Microsoft.Playwright; /// /// Base class for tests that use Playwright for E2E browser testing. /// -public class PlaywrightTest +public abstract class PlaywrightTest { - private static readonly object _lock = new(); - private static readonly int _basePort = 5600; - private static int _currentPort = _basePort; - /// - /// For starting the WebAPI project in-memory. + /// Lock object for thread safety. /// - private readonly WebApplicationApiFactoryFixture _apiFactory = new(); - - /// - /// For starting the Client project in-memory. - /// - private readonly WebApplicationClientFactoryFixture _clientFactory = new(); - - /// - /// Gets the time provider instance for mutating the current WebApi time in tests. - /// - protected TestTimeProvider ApiTimeProvider => _apiFactory.TimeProvider; - - /// - /// Gets or sets base URL where the Blazor WASM app runs on including random port. - /// - protected string AppBaseUrl { get; set; } = string.Empty; + protected static readonly object Lock = new(); /// /// Gets or sets random unique account email that is used for the test. /// - protected virtual string TestUserEmail { get; set; } = string.Empty; + protected virtual string TestUserUsername { get; set; } = string.Empty; /// /// Gets or sets random unique account password that is used for the test. @@ -52,29 +31,29 @@ public class PlaywrightTest protected virtual string TestUserPassword { get; set; } = string.Empty; /// - /// Gets the Playwright browser instance. + /// Gets or sets the Playwright browser instance. /// - protected IBrowser Browser { get; private set; } = null!; + protected IBrowser Browser { get; set; } = null!; /// - /// Gets the Playwright browser context. + /// Gets or sets the Playwright browser context. /// - protected IBrowserContext Context { get; private set; } = null!; + protected IBrowserContext Context { get; set; } = null!; /// - /// Gets the Playwright page. + /// Gets or sets the Playwright page. /// - protected IPage Page { get; private set; } = null!; + protected IPage Page { get; set; } = null!; /// - /// Gets the input helper for Playwright tests. + /// Gets or sets the input helper for Playwright tests. /// - protected PlaywrightInputHelper InputHelper { get; private set; } = null!; + protected PlaywrightInputHelper InputHelper { get; set; } = null!; /// - /// Gets the db context for the WebAPI project. + /// Gets or sets base URL where the Blazor WASM app runs on including random port. /// - protected AliasServerDbContext ApiDbContext => _apiFactory.GetDbContext(); + protected string AppBaseUrl { get; set; } = string.Empty; /// /// One time setup for the Playwright test which runs before all tests in the class. @@ -91,9 +70,16 @@ public class PlaywrightTest try { await SetupEnvironment(); - await Register(); return; } + catch (PlaywrightException) + { + throw; + } + catch (AggregateException) + { + throw; + } catch (Exception ex) { currentRetry++; @@ -108,22 +94,7 @@ public class PlaywrightTest } } - /// - /// Tear down the Playwright test which runs after all tests are done in the class. - /// - /// Async task. - [OneTimeTearDown] - public async Task OneTimeTearDown() - { - await Page.CloseAsync(); - await Context.CloseAsync(); - await Browser.CloseAsync(); - - await _apiFactory.DisposeAsync(); - await _clientFactory.DisposeAsync(); - } - - /// + /// /// Navigate to a relative URL using Blazor's client-side router. /// /// Relative URL. @@ -143,156 +114,61 @@ public class PlaywrightTest await Page.EvaluateAsync($"window.blazorNavigate('{relativeUrl}')"); } + /// + /// Navigate to a relative URL using the browser's navigation. + /// + /// Relative URL. + /// Task. + protected async Task NavigateBrowser(string relativeUrl) + { + await Page.GotoAsync(AppBaseUrl + relativeUrl); + + // Wait for Blazor to load completely + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + /// /// Wait for the specified URL to be loaded with a default timeout. /// - /// The URL to wait for. This may also contains wildcard such as "**/user/login". + /// The relative URL to wait for e.g. "home". This may also contain wildcard such as "user/login**". /// Async task. - protected async Task WaitForURLAsync(string url) + protected async Task WaitForUrlAsync(string relativeUrl) { - await Page.WaitForURLAsync(url, new PageWaitForURLOptions() { Timeout = TestDefaults.DefaultTimeout }); + await Page.WaitForURLAsync("**/" + relativeUrl, new PageWaitForURLOptions() { Timeout = TestDefaults.DefaultTimeout }); } /// /// Wait for the specified URL to be loaded with a custom timeout. /// - /// The URL to wait for. This may also contains wildcard such as "**/user/login". + /// The relative URL to wait for e.g. "home". This may also contain wildcard such as "user/login**". /// Custom timeout in milliseconds. /// Async task. - protected async Task WaitForURLAsync(string url, int timeoutInMs) + protected async Task WaitForUrlAsync(string relativeUrl, int timeoutInMs) { - await Page.WaitForURLAsync(url, new PageWaitForURLOptions() { Timeout = timeoutInMs }); + await Page.WaitForURLAsync("**/" + relativeUrl, new PageWaitForURLOptions() { Timeout = timeoutInMs }); } /// /// Wait for the specified URL to be loaded with a custom timeout. /// - /// The URL to wait for. This may also contains wildcard such as "**/user/login". + /// The relative URL to wait for e.g. "home". This may also contain wildcard such as "user/login**". /// Wait until a certain text appears on the page. /// This can be useful for content that is loaded via AJAX after navigation. /// Async task. - protected async Task WaitForURLAsync(string url, string waitForText) + protected async Task WaitForUrlAsync(string relativeUrl, string waitForText) { - await Page.WaitForURLAsync(url, new PageWaitForURLOptions() { Timeout = TestDefaults.DefaultTimeout }); + await Page.WaitForURLAsync("**/" + relativeUrl, new PageWaitForURLOptions() { Timeout = TestDefaults.DefaultTimeout }); // Wait for actual content to load (web API calls, etc.) await Page.WaitForSelectorAsync("text=" + waitForText, new PageWaitForSelectorOptions() { Timeout = TestDefaults.DefaultTimeout }); } /// - /// Refresh the page which will lock the vault, then enter password to unlock the vault again. + /// Setup the Playwright browser and context based on settings defined in appsettings.json. /// - /// Async task. - protected async Task RefreshPageAndUnlockVault() + /// Task. + protected async Task SetupPlaywrightBrowserAndContext() { - // Get current URL. - var currentUrl = Page.Url; - - // Hard refresh the page. - await Page.ReloadAsync(); - - // Check if the unlock page is displayed. - await WaitForURLAsync("**/unlock", "unlock"); - - // Check if by entering password the unlock page is replaced by the alias listing page. - await InputHelper.FillInputFields(new Dictionary - { - { "password", TestUserPassword }, - }); - - var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" }); - await submitButton.ClickAsync(); - - // Wait for the original page to load again. - await WaitForURLAsync(currentUrl); - } - - /// - /// Create new credential entry. - /// - /// Dictionary with html element ids and values to input as field value. - /// Async task. - protected async Task CreateCredentialEntry(Dictionary? formValues = null) - { - await NavigateUsingBlazorRouter("add-credentials"); - await WaitForURLAsync("**/add-credentials", "Add credentials"); - - // Check if a button with text "Generate Random Identity" appears - var generateButton = Page.Locator("text=Generate Random Identity"); - Assert.That(generateButton, Is.Not.Null, "Generate button not found."); - - // Fill all input fields with specified values and remaining empty fields with random data. - await InputHelper.FillInputFields(formValues); - await InputHelper.FillEmptyInputFieldsWithRandom(); - - var submitButton = Page.Locator("text=Save Credentials").First; - await submitButton.ClickAsync(); - await WaitForURLAsync("**/credentials/**", "Login credentials"); - - // Check if the credential was created - var pageContent = await Page.TextContentAsync("body"); - Assert.That(pageContent, Does.Contain("Login credentials"), "Credential not created."); - } - - /// - /// Register a new random account. - /// - /// Async task. - private async Task Register() - { - // If email is not set by test explicitly, generate a random email. - TestUserEmail = TestUserEmail.Length > 0 ? TestUserEmail : $"{Guid.NewGuid()}@test.com"; - - // If password is not set by test explicitly, generate a random password. - TestUserPassword = TestUserPassword.Length > 0 ? TestUserPassword : Guid.NewGuid().ToString(); - - // Try to register a new account. - var registerButton = Page.Locator("a[href='/user/register']"); - await registerButton.ClickAsync(); - await WaitForURLAsync("**/user/register"); - - // Try to register an account with the generated test credentials. - var emailField = Page.Locator("input[id='email']"); - var passwordField = Page.Locator("input[id='password']"); - var password2Field = Page.Locator("input[id='password2']"); - await emailField.FillAsync(TestUserEmail); - await passwordField.FillAsync(TestUserPassword); - await password2Field.FillAsync(TestUserPassword); - - // Check the terms of service checkbox - var termsCheckbox = Page.Locator("input[id='terms']"); - await termsCheckbox.CheckAsync(); - - // Check if we get redirected when clicking on the register button. - var submitButton = Page.Locator("button[type='submit']"); - await submitButton.ClickAsync(); - - // Check if we get redirected to the root URL after registration which means we are logged in. - await WaitForURLAsync(AppBaseUrl, "Find all of your credentials below"); - } - - private async Task SetupEnvironment() - { - // Set the base port for the test starting at 5600. Increase the port by 2 for each test running - // in parallel to avoid port conflicts. - var apiPort = 0; - var appPort = 0; - lock (_lock) - { - apiPort = Interlocked.Increment(ref _currentPort); - appPort = Interlocked.Increment(ref _currentPort); - } - - AppBaseUrl = "http://localhost:" + appPort + "/"; - - // Start WebAPI in-memory. - _apiFactory.HostUrl = "http://localhost:" + apiPort; - _apiFactory.CreateDefaultClient(); - - // Start Blazor WASM app out-of-process. - _clientFactory.HostUrl = "http://localhost:" + appPort; - _clientFactory.CreateDefaultClient(); - // Set Playwright headless mode based on appsettings.json value. var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) @@ -306,44 +182,11 @@ public class PlaywrightTest var playwright = await Playwright.CreateAsync(); Browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = headless }); Context = await Browser.NewContextAsync(); - - // Intercept Blazor WASM app requests to override appsettings.json - await Context.RouteAsync( - "**/appsettings.json", - async route => - { - var response = new - { - ApiUrl = "http://localhost:" + apiPort, - }; - await route.FulfillAsync( - new RouteFulfillOptions - { - ContentType = "application/json", - Body = System.Text.Json.JsonSerializer.Serialize(response), - }); - }); - await Context.RouteAsync( - "**/appsettings.Development.json", - async route => - { - var response = new - { - ApiUrl = "http://localhost:" + apiPort, - }; - await route.FulfillAsync( - new RouteFulfillOptions - { - ContentType = "application/json", - Body = System.Text.Json.JsonSerializer.Serialize(response), - }); - }); - - Page = await Context.NewPageAsync(); - InputHelper = new(Page); - - // Check that we get redirected to /user/login when accessing the root URL and not authenticated. - await Page.GotoAsync(AppBaseUrl); - await WaitForURLAsync("**/user/login"); } + + /// + /// Setup the Playwright test environment. This method is required to be implemented by the derived class. + /// + /// Async task. + protected abstract Task SetupEnvironment(); } diff --git a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationAdminFactoryFixture.cs b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationAdminFactoryFixture.cs new file mode 100644 index 000000000..71af6b8de --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationAdminFactoryFixture.cs @@ -0,0 +1,144 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.E2ETests.Infrastructure; + +using System.Data.Common; +using AliasServerDb; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +/// +/// Admin web application factory fixture for integration tests. +/// +/// The entry point. +public class WebApplicationAdminFactoryFixture : WebApplicationFactory + where TEntryPoint : class +{ + /// + /// The DbConnection instance that is created for the test. + /// + private DbConnection _dbConnection; + + /// + /// The DbContextFactory instance that is created for the test. + /// + private IDbContextFactory _dbContextFactory = null!; + + /// + /// The cached DbContext instance that can be used during the test. + /// + private AliasServerDbContext? _dbContext; + + /// + /// Initializes a new instance of the class. + /// + public WebApplicationAdminFactoryFixture() + { + _dbConnection = new SqliteConnection("DataSource=:memory:"); + _dbConnection.Open(); + } + + /// + /// Gets or sets the URL the web application host will listen on. + /// + public string HostUrl { get; set; } = "https://localhost:5003"; + + /// + /// Returns the DbContext instance for the test. This can be used to seed the database with test data. + /// + /// AliasServerDbContext instance. + public AliasServerDbContext GetDbContext() + { + if (_dbContext != null) + { + return _dbContext; + } + + _dbContext = _dbContextFactory.CreateDbContext(); + return _dbContext; + } + + /// + /// Disposes the DbConnection instance. + /// + /// ValueTask. + public override ValueTask DisposeAsync() + { + _dbConnection.Dispose(); + GC.SuppressFinalize(this); + return base.DisposeAsync(); + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseUrls(HostUrl); + + // Set static environment variables for the test. + // These are used in the Admin project to set the admin password hash and last password change date. + // An admin user will automatically be created with these values if the database is empty. + Environment.SetEnvironmentVariable("ADMIN_PASSWORD_HASH", "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag=="); + Environment.SetEnvironmentVariable("ADMIN_PASSWORD_GENERATED", "2024-01-01T00:00:00Z"); + + builder.ConfigureServices((context, services) => + { + // Remove the existing DbContextFactory registration + var dbContextFactoryDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IDbContextFactory)); + + services.Remove(dbContextFactoryDescriptor ?? throw new InvalidOperationException("No IDbContextFactory registered.")); + + // Remove the existing DbConnection registration. + var dbConnectionDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbConnection)); + + services.Remove(dbConnectionDescriptor ?? throw new InvalidOperationException("No DbContextOptions registered.")); + + // Add the DbConnection as a singleton + services.AddSingleton(_dbConnection); + + // Add the DbContextFactory + services.AddDbContextFactory((container, options) => + { + var connection = container.GetRequiredService(); + options.UseSqlite(connection).UseLazyLoadingProxies(); + }); + + // Enable detailed errors for server-side Blazor. + services.AddServerSideBlazor() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); + }); + } + + /// + protected override IHost CreateHost(IHostBuilder builder) + { + var dummyHost = builder.Build(); + + builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel()); + + var host = builder.Build(); + host.Start(); + + // Get the DbContextFactory instance and store it for later use during tests. + _dbContextFactory = host.Services.GetRequiredService>(); + + // This delay prevents "ERR_CONNECTION_REFUSED" errors + // which happened like 1 out of 10 times when running tests. + Thread.Sleep(100); + + return dummyHost; + } +} diff --git a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs index 9c4b4f297..014a5e927 100644 --- a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs +++ b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs @@ -18,7 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; /// -/// Web application factory fixture for integration tests. +/// API web application factory fixture for integration tests. /// /// The entry point. public class WebApplicationApiFactoryFixture : WebApplicationFactory @@ -27,13 +27,27 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor /// /// The DbConnection instance that is created for the test. /// - private DbConnection? _dbConnection; + private DbConnection _dbConnection; /// - /// The DbContext instance that is created for the test. + /// The DbContextFactory instance that is created for the test. + /// + private IDbContextFactory _dbContextFactory = null!; + + /// + /// The cached DbContext instance that can be used during the test. /// private AliasServerDbContext? _dbContext; + /// + /// Initializes a new instance of the class. + /// + public WebApplicationApiFactoryFixture() + { + _dbConnection = new SqliteConnection("DataSource=:memory:"); + _dbConnection.Open(); + } + /// /// Gets or sets the URL the web application host will listen on. /// @@ -50,18 +64,26 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor /// AliasServerDbContext instance. public AliasServerDbContext GetDbContext() { - if (_dbContext == null) + if (_dbContext != null) { - var options = new DbContextOptionsBuilder() - .UseSqlite(_dbConnection!) - .Options; - - _dbContext = new AliasServerDbContext(options); + return _dbContext; } + _dbContext = _dbContextFactory.CreateDbContext(); return _dbContext; } + /// + /// Disposes the DbConnection instance. + /// + /// ValueTask. + public override ValueTask DisposeAsync() + { + _dbConnection.Dispose(); + GC.SuppressFinalize(this); + return base.DisposeAsync(); + } + /// protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -72,61 +94,36 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor builder.ConfigureServices((context, services) => { - // Replace the ITimeProvider registration with a TestTimeProvider. + // Remove the existing DbContextFactory registration var timeProviderDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(ITimeProvider)); - if (timeProviderDescriptor is null) - { - throw new InvalidOperationException( - "No ITimeProvider registered."); - } + services.Remove(timeProviderDescriptor ?? throw new InvalidOperationException("No ITimeProvider registered.")); - services.Remove(timeProviderDescriptor); + // Remove the existing DbContextFactory registration + var dbContextFactoryDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IDbContextFactory)); - // Add TestTimeProvider - services.AddSingleton(TimeProvider); - - // Remove the existing AliasServerDbContext registration. - var dbContextDescriptor = services.SingleOrDefault( - d => d.ServiceType == - typeof(DbContextOptions)); - - if (dbContextDescriptor is null) - { - throw new InvalidOperationException( - "No DbContextOptions registered."); - } - - services.Remove(dbContextDescriptor); + services.Remove(dbContextFactoryDescriptor ?? throw new InvalidOperationException("No IDbContextFactory registered.")); // Remove the existing DbConnection registration. var dbConnectionDescriptor = services.SingleOrDefault( - d => d.ServiceType == - typeof(DbConnection)); + d => d.ServiceType == typeof(DbConnection)); - if (dbConnectionDescriptor is null) - { - throw new InvalidOperationException( - "No DbContextOptions registered."); - } + services.Remove(dbConnectionDescriptor ?? throw new InvalidOperationException("No DbContextOptions registered.")); - services.Remove(dbConnectionDescriptor); + // Add the DbConnection as a singleton + services.AddSingleton(_dbConnection); - // Create a new DbConnection and AliasServerDbContext with an in-memory database. - services.AddSingleton(container => - { - _dbConnection = new SqliteConnection("DataSource=:memory:"); - _dbConnection.Open(); - - return _dbConnection; - }); - - services.AddDbContext((container, options) => + // Add the DbContextFactory + services.AddDbContextFactory((container, options) => { var connection = container.GetRequiredService(); - options.UseSqlite(connection); + options.UseSqlite(connection).UseLazyLoadingProxies(); }); + + // Add TestTimeProvider + services.AddSingleton(TimeProvider); }); } @@ -140,6 +137,9 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor var host = builder.Build(); host.Start(); + // Get the DbContextFactory instance and store it for later use during tests. + _dbContextFactory = host.Services.GetRequiredService>(); + // This delay prevents "ERR_CONNECTION_REFUSED" errors // which happened like 1 out of 10 times when running tests. Thread.Sleep(100); diff --git a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationClientFactoryFixture.cs b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationClientFactoryFixture.cs index 8e9cf3251..8653749b4 100644 --- a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationClientFactoryFixture.cs +++ b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationClientFactoryFixture.cs @@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Hosting; /// -/// Web application factory fixture for integration tests. +/// Client web application factory fixture for integration tests. /// /// The entry point. public class WebApplicationClientFactoryFixture : WebApplicationFactory diff --git a/src/Tests/AliasVault.E2ETests/Tests/Admin/AuthTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Admin/AuthTests.cs new file mode 100644 index 000000000..c0ff39567 --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Tests/Admin/AuthTests.cs @@ -0,0 +1,97 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.E2ETests.Tests.Admin; + +/// +/// End-to-end tests for authentication. +/// +[Parallelizable(ParallelScope.Self)] +[TestFixture] +public class AuthTests : AdminPlaywrightTest +{ + /// + /// Test if entering a wrong password gives an error during password change. + /// + /// Async task. + [Order(1)] + [Test] + public async Task ChangePasswordWrongInputTest() + { + // Go to change password page. + await NavigateBrowser("account/manage/change-password"); + await WaitForUrlAsync("account/manage/change-password", "New password"); + + // Fill in the form. + await Page.Locator("input[id='old-password']").FillAsync("incorrect-old-password"); + await Page.Locator("input[id='new-password']").FillAsync("newnewnew"); + await Page.Locator("input[id='confirm-password']").FillAsync("newnewnew"); + + var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Update password" }); + await submitButton.ClickAsync(); + + // Wait for current page to refresh and confirm message shows. + await WaitForUrlAsync("account/manage/change-password", "Error"); + + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("Error: Incorrect password."), "No error shown after submitting change password field with wrong old password."); + } + + /// + /// Test if changing password works. + /// + /// Async task. + [Order(2)] + [Test] + public async Task ChangePasswordTest() + { + // Go to change password page. + await NavigateBrowser("account/manage/change-password"); + await WaitForUrlAsync("account/manage/change-password", "New password"); + + // Fill in the form. + await Page.Locator("input[id='old-password']").FillAsync("password"); + await Page.Locator("input[id='new-password']").FillAsync("newnewnew"); + await Page.Locator("input[id='confirm-password']").FillAsync("newnewnew"); + + var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Update password" }); + await submitButton.ClickAsync(); + + // Wait for current page to refresh and confirm message shows. + await WaitForUrlAsync("account/manage/change-password", "Your password"); + + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("Your password has been changed."), "No success message shown after successfully changing password."); + + // Set new password for next tests. + TestUserPassword = "newnewnew"; + + // Logout. + await NavigateBrowser("user/logout"); + await WaitForUrlAsync("user/login**", "Sign in to"); + + await LoginAsAdmin(); + } + + /// + /// Test if logging out and logging in works. + /// + /// Async task. + [Order(3)] + [Test] + public async Task LogoutAndLoginTest() + { + // Logout. + await NavigateBrowser("user/logout"); + + // Wait and check if we get redirected to /user/login. + await WaitForUrlAsync("user/login**", "Sign in to"); + + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("Sign in to"), "No login page visible after logout."); + } +} diff --git a/src/Tests/AliasVault.E2ETests/Tests/Admin/EmailLogTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Admin/EmailLogTests.cs new file mode 100644 index 000000000..546b8f0c9 --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Tests/Admin/EmailLogTests.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.E2ETests.Tests.Admin; + +/// +/// End-to-end tests for email log feature. +/// +[Parallelizable(ParallelScope.Self)] +[TestFixture] +public class EmailLogTests : AdminPlaywrightTest +{ + /// + /// Test if accessing the email log index page works. + /// + /// Async task. + [Test] + public async Task EmailLogIndexTest() + { + // Navigate to emails page. + await NavigateBrowser("emails"); + await WaitForUrlAsync("emails", "received mails"); + + var pageContent = await Page.TextContentAsync("body"); + Assert.That(pageContent, Does.Contain("received mails"), "No email page content found."); + } +} diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/ApiLoggingTest.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/ApiLoggingTest.cs new file mode 100644 index 000000000..59b5a1b01 --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/ApiLoggingTest.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.E2ETests.Tests.Client; + +using Microsoft.EntityFrameworkCore; + +/// +/// End-to-end tests for making sure errors and warnings in API are logged to database. +/// +[TestFixture] +[NonParallelizable] +public class ApiLoggingTest : ClientPlaywrightTest +{ + /// + /// Test if an error in the API is logged to the database. + /// + /// Async task. + [Test] + public async Task ApiDbLogTest() + { + // Call webapi endpoint that throws an exception. + try + { + await Page.GotoAsync(ApiBaseUrl + "api/v1/Test/Error"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + catch + { + // Ignore exception as this is expected. + } + + // Read from database to check if the log entry was created. + var logEntry = await ApiDbContext.Logs.Where(x => x.Application == "AliasVault.Api").OrderByDescending(x => x.Id).FirstOrDefaultAsync(); + + Assert.That(logEntry, Is.Not.Null, "Log entry for triggered exception not found in database. Check Serilog configuration and /api/v1/Test/Error endpoint."); + Assert.That(logEntry.Exception, Does.Contain("Test error"), "Log entry in database does not contain expected message. Check exception and Serilog configuration."); + } +} diff --git a/src/Tests/AliasVault.E2ETests/Tests/AuthTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/AuthTests.cs similarity index 83% rename from src/Tests/AliasVault.E2ETests/Tests/AuthTests.cs rename to src/Tests/AliasVault.E2ETests/Tests/Client/AuthTests.cs index dccdbc6b2..94ee84207 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/AuthTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/AuthTests.cs @@ -5,28 +5,28 @@ // //----------------------------------------------------------------------- -namespace AliasVault.E2ETests.Tests; +namespace AliasVault.E2ETests.Tests.Client; /// /// End-to-end tests for authentication. /// [Parallelizable(ParallelScope.Self)] [TestFixture] -public class AuthTests : PlaywrightTest +public class AuthTests : ClientPlaywrightTest { /// /// Test if logging out and logging in works. /// /// Async task. [Test] - public async Task LogoutAndLogin() + public async Task LogoutAndLoginTest() { // Logout. await NavigateUsingBlazorRouter("user/logout"); - await WaitForURLAsync("**/user/logout", "AliasVault"); + await WaitForUrlAsync("user/logout", "AliasVault"); // Wait and check if we get redirected to /user/login. - await WaitForURLAsync("**/user/login"); + await WaitForUrlAsync("user/login"); await Login(); } @@ -36,25 +36,25 @@ public class AuthTests : PlaywrightTest /// /// Async task. [Test] - public async Task RegisterFormWarning() + public async Task RegisterFormWarningTest() { // Logout. await NavigateUsingBlazorRouter("user/logout"); - await WaitForURLAsync("**/user/logout", "AliasVault"); + await WaitForUrlAsync("user/logout", "AliasVault"); // Wait and check if we get redirected to /user/login. - await WaitForURLAsync("**/user/login"); + await WaitForUrlAsync("user/login"); // Try to register a new account. var registerButton = Page.Locator("a[href='/user/register']"); await registerButton.ClickAsync(); - await WaitForURLAsync("**/user/register"); + await WaitForUrlAsync("user/register"); // Register account with same test credentials as used in the initial registration bootstrap method. var emailField = Page.Locator("input[id='email']"); var passwordField = Page.Locator("input[id='password']"); var password2Field = Page.Locator("input[id='password2']"); - await emailField.FillAsync(TestUserEmail); + await emailField.FillAsync(TestUserUsername); await passwordField.FillAsync(TestUserPassword); await password2Field.FillAsync(TestUserPassword); @@ -80,18 +80,18 @@ public class AuthTests : PlaywrightTest // Check that we are on the login page after navigating to the base URL. // We are expecting to not be authenticated and thus to be redirected to the login page. - await WaitForURLAsync("**/user/login"); + await WaitForUrlAsync("user/login"); // Try to login with test credentials. var emailField = Page.Locator("input[id='email']"); var passwordField = Page.Locator("input[id='password']"); - await emailField.FillAsync(TestUserEmail); + await emailField.FillAsync(TestUserUsername); await passwordField.FillAsync(TestUserPassword); // Check if we get redirected when clicking on the login button. var loginButton = Page.Locator("button[type='submit']"); await loginButton.ClickAsync(); - await WaitForURLAsync(AppBaseUrl, "Find all of your credentials below"); + await WaitForUrlAsync(AppBaseUrl, "Find all of your credentials below"); // Check if the login was successful by verifying content. var pageContent = await Page.TextContentAsync("body"); diff --git a/src/Tests/AliasVault.E2ETests/Tests/CredentialTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/CredentialTests.cs similarity index 91% rename from src/Tests/AliasVault.E2ETests/Tests/CredentialTests.cs rename to src/Tests/AliasVault.E2ETests/Tests/Client/CredentialTests.cs index eac0549f5..b339b4c8c 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/CredentialTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/CredentialTests.cs @@ -5,14 +5,14 @@ // //----------------------------------------------------------------------- -namespace AliasVault.E2ETests.Tests; +namespace AliasVault.E2ETests.Tests.Client; /// /// End-to-end tests for the credential management. /// [TestFixture] [Parallelizable(ParallelScope.Self)] -public class CredentialTests : PlaywrightTest +public class CredentialTests : ClientPlaywrightTest { private static readonly Random Random = new(); @@ -24,7 +24,7 @@ public class CredentialTests : PlaywrightTest public async Task CredentialListingTest() { await NavigateUsingBlazorRouter("credentials"); - await WaitForURLAsync("**/credentials", "AliasVault"); + await WaitForUrlAsync("credentials", "AliasVault"); // Check if the expected content is present. var pageContent = await Page.TextContentAsync("body"); @@ -70,7 +70,7 @@ public class CredentialTests : PlaywrightTest // Click the edit button. var editButton = Page.Locator("text=Edit credentials entry").First; await editButton.ClickAsync(); - await WaitForURLAsync("**/edit", "Save Credentials"); + await WaitForUrlAsync("edit", "Save Credentials"); var serviceNameAfter = "Credential service after"; await InputHelper.FillInputFields( @@ -81,7 +81,7 @@ public class CredentialTests : PlaywrightTest var submitButton = Page.Locator("text=Save Credentials").First; await submitButton.ClickAsync(); - await WaitForURLAsync("**/credentials/**", "View credentials entry"); + await WaitForUrlAsync("credentials/**", "View credentials entry"); pageContent = await Page.TextContentAsync("body"); Assert.That(pageContent, Does.Contain("Credentials updated"), "Credential update confirmation message not shown."); diff --git a/src/Tests/AliasVault.E2ETests/Tests/DbPersistTest.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/DbPersistTest.cs similarity index 89% rename from src/Tests/AliasVault.E2ETests/Tests/DbPersistTest.cs rename to src/Tests/AliasVault.E2ETests/Tests/Client/DbPersistTest.cs index 842fe6294..756030055 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/DbPersistTest.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/DbPersistTest.cs @@ -5,14 +5,14 @@ // //----------------------------------------------------------------------- -namespace AliasVault.E2ETests.Tests; +namespace AliasVault.E2ETests.Tests.Client; /// -/// End-to-end tests for the credential management. +/// End-to-end tests for the client database persistence. /// [TestFixture] [NonParallelizable] -public class DbPersistTest : PlaywrightTest +public class DbPersistTest : ClientPlaywrightTest { /// /// Test if a created credential is still present after a hard page refresh which causes @@ -37,7 +37,7 @@ public class DbPersistTest : PlaywrightTest await RefreshPageAndUnlockVault(); // Wait for the credentials page to load again. - await WaitForURLAsync("**/credentials/**", serviceNameBefore); + await WaitForUrlAsync("credentials/**", serviceNameBefore); // Check if the service name is still present in the content. pageContent = await Page.TextContentAsync("body"); diff --git a/src/Tests/AliasVault.E2ETests/Tests/DbUpgradeTest.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/DbUpgradeTest.cs similarity index 99% rename from src/Tests/AliasVault.E2ETests/Tests/DbUpgradeTest.cs rename to src/Tests/AliasVault.E2ETests/Tests/Client/DbUpgradeTest.cs index 2322f06b3..12dfe6620 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/DbUpgradeTest.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/DbUpgradeTest.cs @@ -5,21 +5,21 @@ // //----------------------------------------------------------------------- -namespace AliasVault.E2ETests.Tests; +namespace AliasVault.E2ETests.Tests.Client; using AliasServerDb; /// -/// End-to-end tests for the credential management. +/// End-to-end tests for upgrading client databases. /// [TestFixture] [NonParallelizable] -public class DbUpgradeTest : PlaywrightTest +public class DbUpgradeTest : ClientPlaywrightTest { /// /// Gets or sets user email (override). /// - protected override string TestUserEmail { get; set; } = "testdbupgrade@example.com"; + protected override string TestUserUsername { get; set; } = "testdbupgrade@example.com"; /// /// Gets or sets user password (override). @@ -41,7 +41,7 @@ public class DbUpgradeTest : PlaywrightTest ]; // Update salt and verifier for the test user so the key derivation is deterministic. - var user = ApiDbContext.Users.First(); + var user = ApiDbContext.AliasVaultUsers.First(); user.Salt = "1a73a8ef3a1c6dd891674c415962d87246450f8ca5004ecca24be770a4d7b1f7"; user.Verifier = "ab284d4e6da07a2bc95fb4b9dcd0e192988cc45f51e4c51605e42d4fc1055f8398e579755f4772a045abdbded8ae47ae861faa9ff7cb98155103d7038b9713b12d80dff9134067f02564230ab2f5a550ae293b8b7049516a7dc3f918156cde7190bee7e9c84398b2b5b63aeea763cd776b3e9708fb1f66884340451187ca8aacfced19ea28bc94ae28eefa720aae7a3185b139cf6349c2d43e8147f1edadd249c7e125ce15e775c45694d9796ee3f9b8c5beacd37e777a2ea1e745c781b5c085b7e3826f6abe303a14f539cd8d9519661a91cc4e7d44111b8bc9aac1cf1a51ad76658502b436da746844348dfcfb2581c4e4c340058c116a06f975f57a689df4"; @@ -50,7 +50,7 @@ public class DbUpgradeTest : PlaywrightTest new Vault { Id = Guid.NewGuid(), - UserId = ApiDbContext.Users.First().Id, + UserId = ApiDbContext.AliasVaultUsers.First().Id, Version = "1.0.0", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, @@ -64,12 +64,12 @@ public class DbUpgradeTest : PlaywrightTest // Wait for two things: either the homepage to show with credentials OR the // vault upgrade step to show. - await WaitForURLAsync("**/sync", "Vault needs to be upgraded"); + await WaitForUrlAsync("sync", "Vault needs to be upgraded"); var submitButton = Page.Locator("text=Start upgrade process").First; await submitButton.ClickAsync(); - await WaitForURLAsync("**/", "Test credential 1"); + await WaitForUrlAsync(string.Empty, "Test credential 1"); // Check if the expected service names still appear on the index page and are still accessible. var pageContent = await Page.TextContentAsync("body"); @@ -85,7 +85,7 @@ public class DbUpgradeTest : PlaywrightTest await credentialCard.ClickAsync(); // Wait for navigation to complete - await WaitForURLAsync("**/credentials/**"); + await WaitForUrlAsync("credentials/**"); // Check if the service name appears in the body of the new page var credentialPageContent = await Page.TextContentAsync("body"); diff --git a/src/Tests/AliasVault.E2ETests/Tests/JwtTokenTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/JwtTokenTests.cs similarity index 88% rename from src/Tests/AliasVault.E2ETests/Tests/JwtTokenTests.cs rename to src/Tests/AliasVault.E2ETests/Tests/Client/JwtTokenTests.cs index 7d0c154ce..e0fd9ffc0 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/JwtTokenTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/JwtTokenTests.cs @@ -5,14 +5,14 @@ // //----------------------------------------------------------------------- -namespace AliasVault.E2ETests.Tests; +namespace AliasVault.E2ETests.Tests.Client; /// /// End-to-end tests for JWT token handling. /// [Parallelizable(ParallelScope.Self)] [TestFixture] -public class JwtTokenTests : PlaywrightTest +public class JwtTokenTests : ClientPlaywrightTest { /// /// Test that when the JWT token expires the client automatically refreshes it with the refresh token. @@ -24,7 +24,7 @@ public class JwtTokenTests : PlaywrightTest { // Soft navigate to verify that we are logged in. await NavigateUsingBlazorRouter("test/1"); - await WaitForURLAsync("**/test/1", "Test 1 OK"); + await WaitForUrlAsync("test/1", "Test 1 OK"); // Increase the time by 1 hour to make the JWT token expire. ApiTimeProvider.AdvanceBy(TimeSpan.FromHours(1)); @@ -32,7 +32,7 @@ public class JwtTokenTests : PlaywrightTest // Soft navigate to another page to trigger the JWT token refresh // and check if the page is loaded successfully. await NavigateUsingBlazorRouter("test/2"); - await WaitForURLAsync("**/test/2", "Test 2 OK"); + await WaitForUrlAsync("test/2", "Test 2 OK"); var pageContent = await Page.TextContentAsync("body"); Assert.That(pageContent, Does.Contain("Test webapi call 2."), "No page content after refreshing access token."); @@ -50,11 +50,11 @@ public class JwtTokenTests : PlaywrightTest // Soft navigate to verify that we are logged in. var startUrl = "test/1"; await NavigateUsingBlazorRouter(startUrl); - await WaitForURLAsync("**/" + startUrl, "Test 1 OK"); + await WaitForUrlAsync(startUrl, "Test 1 OK"); // Hard reload the page to trigger the unlock page to show up. await Page.ReloadAsync(); - await WaitForURLAsync("**/unlock", "unlock"); + await WaitForUrlAsync("unlock", "unlock"); // Increase the time by 24 hours to make the JWT token expire. ApiTimeProvider.AdvanceBy(TimeSpan.FromHours(24)); @@ -70,7 +70,7 @@ public class JwtTokenTests : PlaywrightTest await submitButton.ClickAsync(); // Check if we get redirected back to the page we were trying to access. - await WaitForURLAsync("**/" + startUrl, "Test 1 OK"); + await WaitForUrlAsync(startUrl, "Test 1 OK"); var pageContent = await Page.TextContentAsync("body"); Assert.That(pageContent, Does.Contain("Test webapi call 1."), "No index content after unlocking database with a expired JWT token."); @@ -86,7 +86,7 @@ public class JwtTokenTests : PlaywrightTest { // Soft navigate to verify that we are logged in. await NavigateUsingBlazorRouter("test/1"); - await WaitForURLAsync("**/test/1", "Test 1 OK"); + await WaitForUrlAsync("test/1", "Test 1 OK"); // Increase the time by 1 year to make the JWT token AND refresh token expire. ApiTimeProvider.AdvanceBy(TimeSpan.FromDays(365)); @@ -96,7 +96,7 @@ public class JwtTokenTests : PlaywrightTest // Not all pages do a webapi call on load everytime so we need to navigate to a page that does. await NavigateUsingBlazorRouter("test/2"); - await WaitForURLAsync("**/user/login", "Login"); + await WaitForUrlAsync("user/login", "Login"); var pageContent = await Page.TextContentAsync("body"); Assert.That(pageContent, Does.Contain("Login"), "No redirect to login while refresh token should be expired."); diff --git a/src/Tests/AliasVault.E2ETests/Tests/UnlockTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/UnlockTests.cs similarity index 88% rename from src/Tests/AliasVault.E2ETests/Tests/UnlockTests.cs rename to src/Tests/AliasVault.E2ETests/Tests/Client/UnlockTests.cs index e897cbe26..437dfcf97 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/UnlockTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/UnlockTests.cs @@ -5,14 +5,14 @@ // //----------------------------------------------------------------------- -namespace AliasVault.E2ETests.Tests; +namespace AliasVault.E2ETests.Tests.Client; /// /// End-to-end tests for the database unlock functionality. /// [Parallelizable(ParallelScope.Self)] [TestFixture] -public class UnlockTests : PlaywrightTest +public class UnlockTests : ClientPlaywrightTest { /// /// Test that the unlock page is displayed after hard refresh which should @@ -29,7 +29,7 @@ public class UnlockTests : PlaywrightTest await RefreshPageAndUnlockVault(); // Check if we get redirected back to the page we were trying to access. - await WaitForURLAsync("**/" + startUrl, "Test webapi call 1."); + await WaitForUrlAsync(startUrl, "Test webapi call 1."); var pageContent = await Page.TextContentAsync("body"); Assert.That(pageContent, Does.Contain("Test webapi call 1."), "No index content after unlocking database."); @@ -50,13 +50,13 @@ public class UnlockTests : PlaywrightTest await Page.ReloadAsync(); // Check if the unlock page is displayed. - await WaitForURLAsync("**/unlock", "unlock"); + await WaitForUrlAsync("unlock", "unlock"); // Hard refresh the page again. await Page.ReloadAsync(); // Check if the unlock page is displayed. - await WaitForURLAsync("**/unlock", "unlock"); + await WaitForUrlAsync("unlock", "unlock"); // Check if by entering password the unlock page is replaced by the alias listing page. await InputHelper.FillInputFields(new Dictionary @@ -68,7 +68,7 @@ public class UnlockTests : PlaywrightTest await submitButton.ClickAsync(); // Check if we get redirected back to the page we were trying to access. - await WaitForURLAsync("**/" + startUrl, "Test webapi call 1."); + await WaitForUrlAsync(startUrl, "Test webapi call 1."); var pageContent = await Page.TextContentAsync("body"); Assert.That(pageContent, Does.Contain("Test webapi call 1."), "No index content after unlocking database."); diff --git a/src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj b/src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj index 3c5af2523..939ba9b08 100644 --- a/src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj +++ b/src/Tests/AliasVault.IntegrationTests/AliasVault.IntegrationTests.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index c52e6c004..853bf414c 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -6,6 +6,7 @@ // ----------------------------------------------------------------------- using AliasVault.SmtpService.Handlers; +using AliasVault.SmtpService.Workers; namespace AliasVault.IntegrationTests.SmtpServer; @@ -19,6 +20,9 @@ using Microsoft.EntityFrameworkCore; using global::SmtpServer; using global::SmtpServer.Storage; +/// +/// Builder class for creating a test host for the SmtpServiceWorker in order to run integration tests against it. +/// public class TestHostBuilder { /// @@ -98,7 +102,7 @@ public class TestHostBuilder } ); - services.AddHostedService(); + services.AddHostedService(); // Ensure the in-memory database is populated with tables var serviceProvider = services.BuildServiceProvider(); diff --git a/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj b/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj index 3f6a682f6..14e1ed894 100644 --- a/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj +++ b/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj @@ -33,7 +33,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Server/AliasVault.E2ETests.Client.Server/AliasVault.E2ETests.Client.Server.csproj b/src/Tests/Server/AliasVault.E2ETests.Client.Server/AliasVault.E2ETests.Client.Server.csproj index 1ce08073d..422df3738 100644 --- a/src/Tests/Server/AliasVault.E2ETests.Client.Server/AliasVault.E2ETests.Client.Server.csproj +++ b/src/Tests/Server/AliasVault.E2ETests.Client.Server/AliasVault.E2ETests.Client.Server.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Utilities/AliasVault.Logging/AliasVault.Logging.csproj b/src/Utilities/AliasVault.Logging/AliasVault.Logging.csproj new file mode 100644 index 000000000..2604c1776 --- /dev/null +++ b/src/Utilities/AliasVault.Logging/AliasVault.Logging.csproj @@ -0,0 +1,48 @@ + + + + net8.0 + enable + enable + Logging + + + + bin\Debug\net8.0\AliasVault.Logging.xml + true + + + + bin\Release\net8.0\AliasVault.Logging.xml + true + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + stylecop.json + + + + + + + + diff --git a/src/Utilities/AliasVault.Logging/DatabaseSink.cs b/src/Utilities/AliasVault.Logging/DatabaseSink.cs new file mode 100644 index 000000000..a0c06605e --- /dev/null +++ b/src/Utilities/AliasVault.Logging/DatabaseSink.cs @@ -0,0 +1,54 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Logging; + +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using Serilog.Core; +using Serilog.Events; + +/// +/// Custom Serilog sink for Database via Entity Framework Core. +/// +/// IFormatProvider instance. +/// DB context factory to use. +/// Application name. +public class DatabaseSink(IFormatProvider formatProvider, Func> contextFactory, string applicationName) : ILogEventSink +{ + /// + /// Saves the log event to the database. + /// + /// LogEvent instance. + public void Emit(LogEvent logEvent) + { + var logEntry = new Log + { + TimeStamp = logEvent.Timestamp.UtcDateTime, + Level = logEvent.Level.ToString(), + Message = logEvent.RenderMessage(formatProvider), + Exception = logEvent.Exception?.ToString() ?? string.Empty, + Properties = JsonConvert.SerializeObject(logEvent.Properties), + LogEvent = (logEvent.Level >= LogEventLevel.Error) ? JsonConvert.SerializeObject(logEvent) : string.Empty, + MessageTemplate = logEvent.MessageTemplate.Text, + Application = applicationName, + }; + + try + { + using var context = contextFactory.Invoke().CreateDbContext(); + context.Logs.Add(logEntry); + context.SaveChanges(); + } + catch (Exception ex) + { + Console.WriteLine($"Error writing log entry to database: {ex}"); + } + } +} diff --git a/src/Utilities/AliasVault.Logging/LoggingConfiguration.cs b/src/Utilities/AliasVault.Logging/LoggingConfiguration.cs new file mode 100644 index 000000000..61979dc63 --- /dev/null +++ b/src/Utilities/AliasVault.Logging/LoggingConfiguration.cs @@ -0,0 +1,103 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Logging; + +using System.Globalization; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Events; +using Serilog.Filters; + +/// +/// Extension methods for configuring logging. +/// +public static class LoggingConfiguration +{ + /// + /// Configures Serilog logging for the application. + /// + /// IServiceCollection. + /// IConfiguration. + /// The application name to include in the log. + /// The folder to log to. + /// IServiceCollection instance. + public static IServiceCollection ConfigureLogging(this IServiceCollection services, IConfiguration configuration, string applicationName, string logFolder = "logs/") + { + services.AddSerilog(new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .Enrich.FromLogContext() + .Enrich.WithProperty("Application", applicationName) + .Filter.ByIncludingOnly(GetSourceContextFilter(configuration)) + + // Log to console. + .WriteTo.Logger(lc => lc + .WriteTo.Console()) + + // Log everything to a file. + .WriteTo.Logger(lc => lc + .WriteTo.File($"{logFolder}/{applicationName}-log-.txt", rollingInterval: RollingInterval.Day)) + + // Log all errors and above to a separate file. + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(evt => evt.Level >= LogEventLevel.Error) + .WriteTo.File($"{logFolder}/{applicationName}-error-.txt", rollingInterval: RollingInterval.Day)) + + // Log all warning and above to database via EF core except for Microsoft.EntityFrameworkCore logs + // as this would create a loop. + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(evt => evt.Level >= LogEventLevel.Warning) + .Filter.ByExcluding(Matching.FromSource("Microsoft.EntityFrameworkCore")) + .WriteTo.Sink(new DatabaseSink(CultureInfo.InvariantCulture, () => services.BuildServiceProvider().GetRequiredService>(), applicationName))) + .CreateLogger()); + + return services; + } + + /// + /// Helper method to create the source context filter. + /// + /// IConfiguration instance. + /// Source context filter for serilog builder. + private static Func GetSourceContextFilter(IConfiguration configuration) + { + return evt => + { + var sourceContext = evt.Properties.ContainsKey("SourceContext") + ? evt.Properties["SourceContext"].ToString() + : string.Empty; + var configuredLevel = GetLogEventLevel(sourceContext, configuration); + return evt.Level >= configuredLevel; + }; + } + + /// + /// Gets the log event level for the source context based on IConfiguration (appsettings.json). + /// + /// The source context to look for in the configuration. + /// IConfiguration instance. + /// LogEventLevel enum. + private static LogEventLevel GetLogEventLevel(string sourceContext, IConfiguration configuration) + { + var logLevels = configuration.GetSection("Logging:LogLevel"); + + // If this specific source context has an override level defined in appsettings.json, return that. + var logLevel = logLevels.GetChildren() + .FirstOrDefault(ll => sourceContext.Contains(ll.Key, StringComparison.OrdinalIgnoreCase)); + if (logLevel != null) + { + return Enum.Parse(logLevel.Value ?? "Information", true); + } + + // If there is no specific override, use the default. + var defaultLevel = logLevels["Default"] ?? "Information"; + return Enum.Parse(defaultLevel, true); + } +} diff --git a/src/Utilities/AliasVault.RazorComponents/AliasVault.RazorComponents.csproj b/src/Utilities/AliasVault.RazorComponents/AliasVault.RazorComponents.csproj new file mode 100644 index 000000000..d5a1d414c --- /dev/null +++ b/src/Utilities/AliasVault.RazorComponents/AliasVault.RazorComponents.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Utilities/AliasVault.RazorComponents/Paginator.razor b/src/Utilities/AliasVault.RazorComponents/Paginator.razor new file mode 100644 index 000000000..180692bdb --- /dev/null +++ b/src/Utilities/AliasVault.RazorComponents/Paginator.razor @@ -0,0 +1,95 @@ +@if (TotalRecords > PageSize) +{ + + +} + +@code { + /// + /// The current page number. + /// + [Parameter] public int CurrentPage { get; set; } = 1; + + /// + /// The number of items to display per page. + /// + [Parameter] public int PageSize { get; set; } = 10; + + /// + /// The total number of records in the dataset. + /// + [Parameter] public int TotalRecords { get; set; } + + /// + /// Event callback triggered when the page is changed. + /// + [Parameter] public EventCallback OnPageChanged { get; set; } + + /// + /// Calculates the total number of pages based on TotalRecords and PageSize. + /// + private int PageCount => (TotalRecords + PageSize - 1) / PageSize; + + /// + /// Changes the current page by the specified amount. + /// + /// The number of pages to change by (positive or negative). + private void ChangePage(int change) + { + SetPage(CurrentPage + change); + } + + /// + /// Sets the current page to the specified page number. + /// + /// The page number to set. + private void SetPage(int pageNumber) + { + if (pageNumber < 1) + { + CurrentPage = 1; + } + else if (pageNumber > PageCount) + { + CurrentPage = PageCount; + } + else + { + CurrentPage = pageNumber; + } + + OnPageChanged.InvokeAsync(CurrentPage); + } +} diff --git a/src/Utilities/AliasVault.RazorComponents/_Imports.razor b/src/Utilities/AliasVault.RazorComponents/_Imports.razor new file mode 100644 index 000000000..77285129d --- /dev/null +++ b/src/Utilities/AliasVault.RazorComponents/_Imports.razor @@ -0,0 +1 @@ +@using Microsoft.AspNetCore.Components.Web diff --git a/src/Utilities/AliasVault.WorkerStatus/AliasVault.WorkerStatus.csproj b/src/Utilities/AliasVault.WorkerStatus/AliasVault.WorkerStatus.csproj new file mode 100644 index 000000000..ab33d772d --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/AliasVault.WorkerStatus.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + + + bin\Debug\net8.0\AliasVault.WorkerStatus.xml + + + + bin\Release\net8.0\AliasVault.WorkerStatus.xml + + + + + stylecop.json + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Utilities/AliasVault.WorkerStatus/Database/IWorkerStatusDbContext.cs b/src/Utilities/AliasVault.WorkerStatus/Database/IWorkerStatusDbContext.cs new file mode 100644 index 000000000..02eabdeb0 --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/Database/IWorkerStatusDbContext.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus.Database; + +using Microsoft.EntityFrameworkCore; + +/// +/// Interface for the WorkerStatusDbContext. Inherit from this interface to include the WorkerServiceStatus DbSet +/// which is used to store the status of worker services. +/// +public interface IWorkerStatusDbContext : IDisposable +{ + /// + /// Gets or sets the WorkerServiceStatus DbSet. + /// + public DbSet WorkerServiceStatuses { get; set; } + + /// + /// Save changes to the database. + /// + /// Count of records affected. + public int SaveChanges(); + + /// + /// Save changes to the database asynchronously. + /// + /// CancellationToken instance. + /// Task. + public Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Utilities/AliasVault.WorkerStatus/Database/WorkerServiceStatus.cs b/src/Utilities/AliasVault.WorkerStatus/Database/WorkerServiceStatus.cs new file mode 100644 index 000000000..349ac2efe --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/Database/WorkerServiceStatus.cs @@ -0,0 +1,47 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus.Database; + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +/// +/// Represents the status of a worker service for monitoring and control. +/// +public class WorkerServiceStatus +{ + /// + /// Gets or sets the unique identifier for the service status. + /// + public int Id { get; set; } + + /// + /// Gets or sets the name of the service. + /// + [Required] + [StringLength(255)] + [Column(TypeName = "varchar")] + public string ServiceName { get; set; } = null!; + + /// + /// Gets or sets the current status of the service. + /// + [StringLength(50)] + public string CurrentStatus { get; set; } = null!; + + /// + /// Gets or sets the desired status of the service. + /// + [StringLength(50)] + public string DesiredStatus { get; set; } = null!; + + /// + /// Gets or sets the last heartbeat timestamp of the service. + /// + public DateTime Heartbeat { get; set; } +} diff --git a/src/Utilities/AliasVault.WorkerStatus/Database/WorkerStatusDbContext.cs b/src/Utilities/AliasVault.WorkerStatus/Database/WorkerStatusDbContext.cs new file mode 100644 index 000000000..b07de35c5 --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/Database/WorkerStatusDbContext.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus.Database; + +using Microsoft.EntityFrameworkCore; + +/// +/// WorkerStatusDbContext class. +/// +public class WorkerStatusDbContext : DbContext, IWorkerStatusDbContext +{ + /// + /// Initializes a new instance of the class. + /// + public WorkerStatusDbContext() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// DbContextOptions instance. + public WorkerStatusDbContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Gets or sets the WorkerServiceStatus DbSet. + /// + public DbSet WorkerServiceStatuses { get; set; } +} diff --git a/src/Utilities/AliasVault.WorkerStatus/Extensions/StatusExtensions.cs b/src/Utilities/AliasVault.WorkerStatus/Extensions/StatusExtensions.cs new file mode 100644 index 000000000..d0309a93a --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/Extensions/StatusExtensions.cs @@ -0,0 +1,25 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +// ----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus.Extensions; + +/// +/// Extension methods for the Status enum. +/// +internal static class StatusExtensions +{ + /// + /// String to Status enum conversion. + /// + /// Status enum. + /// String as status enum. + /// Thrown if string is not a known status enum. + public static Status ToStatusEnum(this string status) + { + return Enum.TryParse(status, out Status result) ? result : throw new ArgumentException("Invalid status value"); + } +} diff --git a/src/Utilities/AliasVault.WorkerStatus/GlobalServiceStatus.cs b/src/Utilities/AliasVault.WorkerStatus/GlobalServiceStatus.cs new file mode 100644 index 000000000..72b075150 --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/GlobalServiceStatus.cs @@ -0,0 +1,76 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus; + +using System.Collections.Concurrent; + +/// +/// Global service status class for monitoring and control. +/// +public class GlobalServiceStatus +{ + private readonly ConcurrentDictionary _workerStatuses = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Name of the service that we are keeping track of. + public GlobalServiceStatus(string serviceName) + { + ServiceName = serviceName; + } + + /// + /// Gets or sets the status of the service. + /// + public string Status { get; set; } = "Stopped"; + + /// + /// Gets or sets the current status of the service. + /// + public string CurrentStatus { get; set; } = "Stopped"; + + /// + /// Gets or sets the ServiceName in order to identify the service and its workers in the database. + /// + public string ServiceName { get; set; } + + /// + /// Register a worker with the service. + /// + /// Name of the worker. + public void RegisterWorker(string workerName) + { + _workerStatuses[workerName] = false; + } + + /// + /// Set the status of a worker. + /// + /// Name of the worker. + /// Boolean which indicates if worker is currently running. + public void SetWorkerStatus(string workerName, bool isRunning) + { + if (_workerStatuses.ContainsKey(workerName)) + { + _workerStatuses[workerName] = isRunning; + } + } + + /// + /// Returns boolean indicating if all workers are running. + /// + /// Boolean which indicates if all workers are started. + public bool AreAllWorkersRunning() => _workerStatuses.All(w => w.Value); + + /// + /// Returns boolean indicating if all workers are stopped. + /// + /// Boolean which indicates if all workers are stopped. + public bool AreAllWorkersStopped() => _workerStatuses.All(w => !w.Value); +} diff --git a/src/Utilities/AliasVault.WorkerStatus/ServiceExtensions/ServiceCollectionExtensions.cs b/src/Utilities/AliasVault.WorkerStatus/ServiceExtensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..8675a3a8e --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/ServiceExtensions/ServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus.ServiceExtensions; + +using AliasVault.WorkerStatus.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +/// +/// ServiceCollectionExtensions class. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Add a HostedService that is monitored by the StatusWorker. + /// + /// IServiceCollection. + /// Worker type to add. + /// DBContext type to use for persisting and retrieving the status data. + /// The unique service name through which the worker processes + /// can be triggered to start or stop. + /// IServiceCollection instance. + public static IServiceCollection AddStatusHostedService(this IServiceCollection services, string serviceName) + where TWorker : class, IHostedService + where TContext : DbContext, IWorkerStatusDbContext + { + services.TryAddSingleton(); // Register the inner service + services.TryAddEnumerable(ServiceDescriptor.Singleton>()); // Register the status hosted service + + // Only add these required helper services if they are not already registered. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddSingleton(new GlobalServiceStatus(serviceName)); + + // Register the DbContext factory + services.TryAddSingleton>(sp => + { + var factory = sp.GetRequiredService>(); + return () => factory.CreateDbContext(); + }); + + // Set HostOptions to ignore background service exceptions as we are handling them in the StatusWorker. + services.Configure(options => + { + options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore; + }); + + return services; + } +} diff --git a/src/Utilities/AliasVault.WorkerStatus/ServiceExtensions/StatusHostedService.cs b/src/Utilities/AliasVault.WorkerStatus/ServiceExtensions/StatusHostedService.cs new file mode 100644 index 000000000..cb2a6f9e9 --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/ServiceExtensions/StatusHostedService.cs @@ -0,0 +1,158 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus.ServiceExtensions; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +/// +/// StatusHostedService class. +/// +/// The HostedService to add. +public class StatusHostedService(ILogger> logger, GlobalServiceStatus globalServiceStatus, T innerService) : BackgroundService + where T : IHostedService +{ + /// + /// A minimum delay that is used to wait before restarting the worker after a fault in the innerService. + /// This delay is increased exponentially with a maximum delay of . + /// + private const int _restartMinDelayInMs = 1000; + + /// + /// Maximum delay before restarting the worker. + /// + private const int _restartMaxDelayInMs = 300000; + + /// + /// Lock object to prevent multiple tasks from starting the worker at the same time. + /// + private readonly object _taskLock = new(); + + /// + /// Current delay before restarting the worker. + /// + private int _restartDelayInMs = _restartMinDelayInMs; + + /// + /// Default entry point called by the host. + /// + /// Cancellation token. + /// Task. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("StatusHostedService<{ServiceType}> ExecuteAsync called.", typeof(T).Name); + + // Register the service with the global service status so the StatusWorker will monitor it. + globalServiceStatus.RegisterWorker(typeof(T).Name); + + while (!stoppingToken.IsCancellationRequested) + { + // Add a second cancellationToken linked to the parent cancellation token. + // When the parent gets canceled this gets canceled as well. However, this one can also + // be canceled with a signal from the StatusWorker. + var workerCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + + // Start the inner while loop with the second cancellationToken. + await ExecuteInnerAsync(workerCancellationTokenSource); + + if (!stoppingToken.IsCancellationRequested) + { + // If the parent service was not stopped, wait for a second before attempting to restart the worker. + await Task.Delay(1000, stoppingToken); + } + } + } + + /// + /// Start the inner while loop which adds a second cancellationToken that is controlled by the StatusWorker. + /// + /// Cancellation token. + private async Task ExecuteInnerAsync(CancellationTokenSource workerCancellationTokenSource) + { + Task? workerTask = null; + + while (!workerCancellationTokenSource.IsCancellationRequested) + { + if (globalServiceStatus.CurrentStatus == "Started" || globalServiceStatus.CurrentStatus == "Starting") + { + lock (_taskLock) + { + if (workerTask == null) + { + globalServiceStatus.SetWorkerStatus(typeof(T).Name, true); + workerTask = Task.Run(() => WorkerLogic(workerCancellationTokenSource.Token), workerCancellationTokenSource.Token); + } + } + } + else if (globalServiceStatus.CurrentStatus == "Stopping") + { + // If the StatusWorker has given us the signal to stop, cancel the inner worker. + await workerCancellationTokenSource.CancelAsync(); + globalServiceStatus.SetWorkerStatus(typeof(T).Name, false); + } + else if (globalServiceStatus.CurrentStatus == "Stopped") + { + break; + } + } + } + + /// + /// The worker logic that is executed by the inner service. This wraps the actual inner service logic + /// in a try/catch block to catch any exceptions and to set the worker status to false when the worker stops. + /// + /// Cancellation token. + private async Task WorkerLogic(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + globalServiceStatus.SetWorkerStatus(typeof(T).Name, true); + + await innerService.StartAsync(cancellationToken); + + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (OperationCanceledException ex) + { + // Expected so we only log information. + logger.LogInformation(ex, "StatusHostedService<{ServiceType}> is stopping due to a cancellation request.", typeof(T).Name); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred in StatusHostedService<{ServiceType}>", typeof(T).Name); + } + finally + { + logger.LogWarning("StatusHostedService<{ServiceType}> stopped at: {Time}", typeof(T).Name, DateTimeOffset.Now); + globalServiceStatus.SetWorkerStatus(typeof(T).Name, false); + } + + // If a fault occurred in the innerService but it was not canceled, + // wait for a second before attempting to auto-restart the worker. + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(_restartDelayInMs, cancellationToken); + break; // Exit the loop if delay is successful + } + catch (TaskCanceledException) + { + // If the delay is canceled, exit the loop + break; + } + } + + // Exponential backoff with a maximum delay + _restartDelayInMs = Math.Min(_restartDelayInMs * 2, _restartMaxDelayInMs); + } + } +} diff --git a/src/Utilities/AliasVault.WorkerStatus/Status.cs b/src/Utilities/AliasVault.WorkerStatus/Status.cs new file mode 100644 index 000000000..3870f232e --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/Status.cs @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +// ----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus; + +/// +/// Enumeration of possible statuses for a worker service. +/// +public enum Status +{ + /// + /// Indicates that the worker service has started. + /// + Started, + + /// + /// Indicates that the worker service is starting. + /// + Starting, + + /// + /// Indicates that the worker service is stopping. + /// + Stopping, + + /// + /// Indicates that the worker service has stopped. + /// + Stopped, +} diff --git a/src/Utilities/AliasVault.WorkerStatus/StatusWorker.cs b/src/Utilities/AliasVault.WorkerStatus/StatusWorker.cs new file mode 100644 index 000000000..2535ebd2e --- /dev/null +++ b/src/Utilities/AliasVault.WorkerStatus/StatusWorker.cs @@ -0,0 +1,176 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.WorkerStatus; + +using AliasVault.WorkerStatus.Database; +using AliasVault.WorkerStatus.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +/// +/// StatusWorker class for monitoring and controlling the status of the worker services. +/// +public class StatusWorker(ILogger logger, Func createDbContext, GlobalServiceStatus globalServiceStatus) : BackgroundService +{ + private IWorkerStatusDbContext _dbContext = null!; + + /// + /// Worker service execution method. + /// + /// CancellationToken. + /// Task. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + _dbContext = createDbContext(); + + try + { + var statusEntry = await GetServiceStatus(); + switch (statusEntry.CurrentStatus.ToStatusEnum()) + { + case Status.Started: + // Ensure that all workers are running, if not, revert to "Starting" CurrentStatus. + if (!globalServiceStatus.AreAllWorkersRunning()) + { + await SetServiceStatus(statusEntry, Status.Starting.ToString()); + logger.LogInformation( + "Status was set to Started but not all workers are running (yet). Reverting to Starting."); + } + + break; + case Status.Starting: + await WaitForAllWorkersToStart(stoppingToken); + await SetServiceStatus(statusEntry, Status.Started.ToString()); + logger.LogInformation("All workers started."); + break; + case Status.Stopping: + await WaitForAllWorkersToStop(stoppingToken); + await SetServiceStatus(statusEntry, Status.Stopped.ToString()); + logger.LogInformation("All workers stopped."); + break; + case Status.Stopped: + logger.LogInformation("Service is (soft) stopped."); + break; + } + } + catch (TaskCanceledException) + { + // Ignore exception, this is expected when the service is stopped. + } + catch (Exception e) + { + logger.LogError(e, "StatusWorker exception"); + } + + await Task.Delay(5000, stoppingToken); + } + + // If we reach this point, the service is hard stopping: not in software but on OS level. + // Mark the service as stopped. + _dbContext = createDbContext(); + await SetServiceStatus(await GetServiceStatus(), "Stopped"); + } + + /// + /// Gets the current status record of the service from database. + /// + /// New current status. + private async Task GetServiceStatus() + { + var entry = await GetOrCreateInitialStatusRecord(); + + if (!string.IsNullOrEmpty(entry.DesiredStatus) && entry.CurrentStatus != entry.DesiredStatus) + { + entry.CurrentStatus = entry.DesiredStatus.ToStatusEnum() switch + { + Status.Started => Status.Starting.ToString(), + Status.Stopped => Status.Stopping.ToString(), + _ => entry.CurrentStatus, + }; + } + + globalServiceStatus.Status = entry.CurrentStatus; + globalServiceStatus.CurrentStatus = entry.CurrentStatus; + + entry.Heartbeat = DateTime.Now; + await _dbContext.SaveChangesAsync(); + + return entry; + } + + /// + /// Updates the status of the service. + /// + /// The WorkerServiceStatus entry to update. + /// The new status. + /// New current status. + private async Task SetServiceStatus(WorkerServiceStatus statusEntry, string newStatus = "") + { + if (!string.IsNullOrEmpty(newStatus) && statusEntry.CurrentStatus != newStatus) + { + statusEntry.CurrentStatus = newStatus; + } + + var status = statusEntry.CurrentStatus; + globalServiceStatus.Status = status; + globalServiceStatus.CurrentStatus = status; + + statusEntry.Heartbeat = DateTime.Now; + await _dbContext.SaveChangesAsync(); + } + + /// + /// Waits for all workers to start. + /// + /// CancellationToken. + private async Task WaitForAllWorkersToStart(CancellationToken stoppingToken) + { + while (!globalServiceStatus.AreAllWorkersRunning()) + { + logger.LogInformation("Waiting for all workers to start..."); + await Task.Delay(1000, stoppingToken); + } + } + + /// + /// Waits for all workers to stop. + /// + /// CancellationToken. + private async Task WaitForAllWorkersToStop(CancellationToken stoppingToken) + { + while (!globalServiceStatus.AreAllWorkersStopped()) + { + logger.LogInformation("Waiting for all workers to stop..."); + await Task.Delay(1000, stoppingToken); + } + } + + /// + /// Retrieves status record or creates an initial status record if it does not exist. + /// + private async Task GetOrCreateInitialStatusRecord() + { + var entry = _dbContext.WorkerServiceStatuses.FirstOrDefault(x => x.ServiceName == globalServiceStatus.ServiceName); + if (entry != null) + { + return entry; + } + + entry = new WorkerServiceStatus + { + ServiceName = globalServiceStatus.ServiceName, + CurrentStatus = Status.Started.ToString(), + DesiredStatus = string.Empty, + }; + await _dbContext.WorkerServiceStatuses.AddAsync(entry); + + return entry; + } +} diff --git a/src/Utilities/Cryptography/Cryptography.csproj b/src/Utilities/Cryptography/Cryptography.csproj index c25f54394..4466c730b 100644 --- a/src/Utilities/Cryptography/Cryptography.csproj +++ b/src/Utilities/Cryptography/Cryptography.csproj @@ -22,7 +22,7 @@ - + all diff --git a/src/Utilities/InitializationCLI/Dockerfile b/src/Utilities/InitializationCLI/Dockerfile new file mode 100644 index 000000000..6daa6b86f --- /dev/null +++ b/src/Utilities/InitializationCLI/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Copy csproj files and restore as distinct layers +COPY ["src/Utilities/InitializationCLI/InitializationCLI.csproj", "src/Utilities/InitializationCLI/"] +COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"] +RUN dotnet restore "src/Utilities/InitializationCLI/InitializationCLI.csproj" + +# Copy the entire source code +COPY . . + +# Build the project +RUN dotnet build "src/Utilities/InitializationCLI/InitializationCLI.csproj" -c "$BUILD_CONFIGURATION" -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "src/Utilities/InitializationCLI/InitializationCLI.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "InitializationCLI.dll"] diff --git a/src/Utilities/InitializationCLI/InitializationCLI.csproj b/src/Utilities/InitializationCLI/InitializationCLI.csproj new file mode 100644 index 000000000..009d3ac39 --- /dev/null +++ b/src/Utilities/InitializationCLI/InitializationCLI.csproj @@ -0,0 +1,44 @@ + + + + Exe + net8.0 + enable + enable + + + + bin\Debug\net8.0\InitializationCLI.xml + true + + + + bin\Release\net8.0\InitializationCLI.xml + true + + + + + .dockerignore + Dockerfile + + + + + + + + + + stylecop.json + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Utilities/InitializationCLI/Program.cs b/src/Utilities/InitializationCLI/Program.cs new file mode 100644 index 000000000..39b7ad8ce --- /dev/null +++ b/src/Utilities/InitializationCLI/Program.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +using AliasServerDb; +using Microsoft.AspNetCore.Identity; + +if (args.Length == 0) +{ + Console.WriteLine("Please provide a password as an argument."); + return; +} + +var password = args[0]; +var hasher = new PasswordHasher(); +var user = new AdminUser(); +var hashedPassword = hasher.HashPassword(user, password); + +Console.WriteLine($"{hashedPassword}"); diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 000000000..963b978f1 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,108 @@ +#!/bin/sh + +# Define colors for CLI output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Define verbose flag +VERBOSE=false + +# Function to parse command-line arguments +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --verbose) + VERBOSE=true + ;; + *) + printf "${RED}Unknown argument: $1${NC}\n" + exit 1 + ;; + esac + shift + done +} + +# Function to print the CLI logo +print_logo() { + printf "${MAGENTA}\n" + printf "=========================================================\n" + printf " _ _ __ __ _ _ \n" + printf " /\ | (_) \ \ / / | | | \n" + printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n" + printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n" + printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n" + printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n" + printf "\n" + printf " Uninstall Script\n" + printf "=========================================================\n" + printf "${NC}\n" +} + +# Function to stop and remove Docker containers +stop_and_remove_containers() { + printf "${CYAN}> Stopping and removing Docker containers...${NC}\n" + if [ "$VERBOSE" = true ]; then + docker-compose down -v + else + docker-compose down -v > /dev/null 2>&1 + fi + printf "${GREEN}> Docker containers stopped and removed.${NC}\n" +} + +# Function to remove Docker images +remove_docker_images() { + printf "${CYAN}> Removing Docker images...${NC}\n" + if [ "$VERBOSE" = true ]; then + docker-compose down --rmi all + else + docker-compose down --rmi all > /dev/null 2>&1 + fi + printf "${GREEN}> Docker images removed.${NC}\n" +} + +# Function to prune Docker system +prune_docker_system() { + printf "${CYAN}> Pruning Docker system...${NC}\n" + if [ "$VERBOSE" = true ]; then + docker system prune -af + else + docker system prune -af > /dev/null 2>&1 + fi + printf "${GREEN}> Docker system pruned.${NC}\n" +} + +# Main execution flow +main() { + parse_args "$@" + print_logo + + printf "${YELLOW}+++ Uninstalling AliasVault +++${NC}\n" + printf "\n" + + stop_and_remove_containers + remove_docker_images + prune_docker_system + + printf "\n" + printf "${MAGENTA}=========================================================${NC}\n" + printf "\n" + printf "AliasVault has been successfully uninstalled!\n" + printf "\n" + printf "All Docker containers and images related to AliasVault have been removed.\n" + printf "The current directory, including logs and .env files, has been left intact.\n" + printf "\n" + printf "If you wish to remove the remaining files, you can do so manually.\n" + printf "\n" + printf "Thank you for using AliasVault!\n" + printf "\n" + printf "${MAGENTA}=========================================================${NC}\n" +} + +# Run the main function +main "$@"