mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-06 22:36:27 -04:00
Merge pull request #123 from lanedirt/113-add-blazor-server-admin-project-for-user-and-smtp-management
Add blazor server admin project for user and smtp management
This commit is contained in:
20
.github/workflows/docker-compose-build.yml
vendored
20
.github/workflows/docker-compose-build.yml
vendored
@@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
36
README.md
36
README.md
@@ -4,8 +4,9 @@
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-unit-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=integration tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-e2e-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
|
||||
[<img src="https://img.shields.io/sonar/coverage/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=test code coverage">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
</div>
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
110
docs/setup/1-manually-setup-docker.md
Normal file
110
docs/setup/1-manually-setup-docker.md
Normal file
@@ -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 "<your_prefered_admin_password_here>"
|
||||
```
|
||||
|
||||
Add the password hash and generation timestamp to the .env file:
|
||||
```
|
||||
ADMIN_PASSWORD_HASH=<output_of_step_above>
|
||||
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.
|
||||
118
init.sh
118
init.sh
@@ -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"
|
||||
326
install.sh
Executable file
326
install.sh
Executable file
@@ -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 "$@"
|
||||
@@ -4,7 +4,7 @@
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
namespace AliasGenerators.Implementations;
|
||||
namespace AliasGenerators.Password;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for password generators.
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasGenerators.Password.Implementations;
|
||||
|
||||
using AliasGenerators.Implementations;
|
||||
using AliasGenerators.Password;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of IPasswordGenerator which generates passwords using the SpamOK library.
|
||||
|
||||
51
src/AliasVault.Admin/AliasVault.Admin.csproj
Normal file
51
src/AliasVault.Admin/AliasVault.Admin.csproj
Normal file
@@ -0,0 +1,51 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>aspnet-AliasVault.Admin-1DAADE35-C01B-43BB-B440-AA5E1E0B672D</UserSecretsId>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<DocumentationFile>bin\Debug\net8.0\AliasVault.Admin.xml</DocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<DocumentationFile>bin\Release\net8.0\AliasVault.Admin.xml</DocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="..\stylecop.json" Link="stylecop.json" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
41
src/AliasVault.Admin/Auth/Components/InputTextField.razor
Normal file
41
src/AliasVault.Admin/Auth/Components/InputTextField.razor
Normal file
@@ -0,0 +1,41 @@
|
||||
@using System.Linq.Expressions
|
||||
|
||||
<InputText @attributes="AdditionalAttributes"
|
||||
id="@Id"
|
||||
Value="@Value"
|
||||
ValueChanged="ValueChanged"
|
||||
ValueExpression="ValueExpression"
|
||||
placeholder="@Placeholder"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the input field.
|
||||
/// </summary>
|
||||
[Parameter] public string Id { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value of the input field.
|
||||
/// </summary>
|
||||
[Parameter] public string Value { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the event callback that is triggered when the value changes.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expression that identifies the value property.
|
||||
/// </summary>
|
||||
[Parameter] public Expression<Func<string>> ValueExpression { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the placeholder text for the input field.
|
||||
/// </summary>
|
||||
[Parameter] public string Placeholder { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional attributes for the input field.
|
||||
/// </summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object?>? AdditionalAttributes { get; set; } = new();
|
||||
}
|
||||
5
src/AliasVault.Admin/Auth/Components/Logo.razor
Normal file
5
src/AliasVault.Admin/Auth/Components/Logo.razor
Normal file
@@ -0,0 +1,5 @@
|
||||
<a href="/" class="flex items-center justify-center mb-8 text-2xl font-semibold lg:mb-10 dark:text-white">
|
||||
<img src="horizontal-logo-cropped.png" alt="AliasVault" class="img-fluid" style="max-width: 330px;"/>
|
||||
<span class="ps-2 self-center hidden sm:flex text-lg font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
|
||||
|
||||
</a>
|
||||
36
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor
Normal file
36
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor
Normal file
@@ -0,0 +1,36 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using AliasVault.Admin.Auth.Components
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-6 pt-8 mx-auto md:h-screen pt:mt-0 dark:bg-gray-900">
|
||||
<Logo />
|
||||
<div class="w-full max-w-xl p-6 space-y-4 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
18
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor.css
Normal file
18
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor.css
Normal file
@@ -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;
|
||||
}
|
||||
71
src/AliasVault.Admin/Auth/Pages/AuthBase.cs
Normal file
71
src/AliasVault.Admin/Auth/Pages/AuthBase.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="AuthBase.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class AuthBase : OwningComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the logger.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected ILogger<Login> Logger { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the navigation service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected NavigationService NavigationService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sign in manager.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected SignInManager<AdminUser> SignInManager { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user manager.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected UserManager<AdminUser> UserManager { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authentication state provider.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets object which holds server validation errors to show in the UI.
|
||||
/// </summary>
|
||||
protected ServerValidationErrors ServerValidationErrors { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/AliasVault.Admin/Auth/Pages/ForgotPassword.razor
Normal file
8
src/AliasVault.Admin/Auth/Pages/ForgotPassword.razor
Normal file
@@ -0,0 +1,8 @@
|
||||
@page "/user/forgot-password"
|
||||
|
||||
<LayoutPageTitle>Forgot your password?</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Forgot your password?
|
||||
</h2>
|
||||
<p>If you have forgotten your password, please consult with the server admin.</p>
|
||||
8
src/AliasVault.Admin/Auth/Pages/Lockout.razor
Normal file
8
src/AliasVault.Admin/Auth/Pages/Lockout.razor
Normal file
@@ -0,0 +1,8 @@
|
||||
@page "/user/lockout"
|
||||
|
||||
<LayoutPageTitle>Locked out</LayoutPageTitle>
|
||||
|
||||
<header>
|
||||
<h1 class="text-danger">Locked out</h1>
|
||||
<p class="text-danger">This account has been locked out, please try again later.</p>
|
||||
</header>
|
||||
97
src/AliasVault.Admin/Auth/Pages/Login.razor
Normal file
97
src/AliasVault.Admin/Auth/Pages/Login.razor
Normal file
@@ -0,0 +1,97 @@
|
||||
@page "/user/login"
|
||||
|
||||
<LayoutPageTitle>Log in</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Sign in to AliasVault Admin
|
||||
</h2>
|
||||
|
||||
<ServerValidationErrors @ref="ServerValidationErrors" />
|
||||
|
||||
<EditForm Model="Input" FormName="LoginForm" OnValidSubmit="LoginUser" class="mt-8 space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<div>
|
||||
<label asp-for="Input.UserName" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your username</label>
|
||||
<InputTextField id="username" @bind-Value="Input.UserName" placeholder="username" />
|
||||
<ValidationMessage For="() => Input.UserName"/>
|
||||
</div>
|
||||
<div>
|
||||
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
|
||||
<InputTextField id="password" @bind-Value="Input.Password" type="password" placeholder="••••••••" />
|
||||
<ValidationMessage For="() => Input.Password"/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="remember" aria-describedby="remember" name="remember" type="checkbox" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="remember" class="font-medium text-gray-900 dark:text-white">Remember me</label>
|
||||
</div>
|
||||
<a href="/user/forgot-password" class="ml-auto text-sm text-primary-700 hover:underline dark:text-primary-500">Lost Password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Login to your account</button>
|
||||
</EditForm>
|
||||
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs in the user.
|
||||
/// </summary>
|
||||
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<string, object?> { ["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; }
|
||||
}
|
||||
|
||||
}
|
||||
98
src/AliasVault.Admin/Auth/Pages/LoginWith2fa.razor
Normal file
98
src/AliasVault.Admin/Auth/Pages/LoginWith2fa.razor
Normal file
@@ -0,0 +1,98 @@
|
||||
@page "/user/loginWith2fa"
|
||||
|
||||
<LayoutPageTitle>Two-factor authentication</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Two-factor authentication
|
||||
</h2>
|
||||
|
||||
<ServerValidationErrors @ref="ServerValidationErrors" />
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">Your login is protected with an authenticator app. Enter your authenticator code below.</p>
|
||||
<div class="w-full max-w-md">
|
||||
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
|
||||
<input type="hidden" name="ReturnUrl" value="@ReturnUrl"/>
|
||||
<input type="hidden" name="RememberMe" value="@RememberMe"/>
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="two-factor-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authenticator code</label>
|
||||
<InputText @bind-Value="Input.TwoFactorCode" id="two-factor-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off"/>
|
||||
<ValidationMessage For="() => Input.TwoFactorCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<InputCheckbox @bind-Value="Input.RememberMachine" id="remember-machine" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-primary-600 dark:ring-offset-gray-800"/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="remember-machine" class="font-medium text-gray-900 dark:text-white">Remember this machine</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-gray-700 dark:text-gray-300">
|
||||
Don't have access to your authenticator device? You can
|
||||
<a href="user/loginWithRecoveryCode?ReturnUrl=@ReturnUrl" class="text-primary-600 hover:underline dark:text-primary-500">log in with a recovery code</a>.
|
||||
</p>
|
||||
|
||||
@code {
|
||||
private AdminUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery] private bool RememberMe { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits the form.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
}
|
||||
81
src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor
Normal file
81
src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor
Normal file
@@ -0,0 +1,81 @@
|
||||
@page "/user/loginWithRecoveryCode"
|
||||
|
||||
<LayoutPageTitle>Recovery code verification</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Recovery code verification
|
||||
</h2>
|
||||
|
||||
<ServerValidationErrors @ref="ServerValidationErrors" />
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
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.
|
||||
</p>
|
||||
<div class="w-full max-w-md">
|
||||
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="recovery-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Recovery Code</label>
|
||||
<InputText @bind-Value="Input.RecoveryCode" id="recovery-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off" placeholder="Enter your recovery code"/>
|
||||
<ValidationMessage For="() => Input.RecoveryCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AdminUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits the form.
|
||||
/// </summary>
|
||||
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; } = "";
|
||||
}
|
||||
|
||||
}
|
||||
35
src/AliasVault.Admin/Auth/Pages/Logout.razor
Normal file
35
src/AliasVault.Admin/Auth/Pages/Logout.razor
Normal file
@@ -0,0 +1,35 @@
|
||||
@page "/user/logout"
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/AliasVault.Admin/Auth/Pages/_Imports.razor
Normal file
11
src/AliasVault.Admin/Auth/Pages/_Imports.razor
Normal file
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="RevalidatingAuthenticationStateProvider.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
|
||||
/// every 30 minutes an interactive circuit is connected.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">ILoggerFactory instance.</param>
|
||||
/// <param name="scopeFactory">IServiceScopeFactory instance.</param>
|
||||
/// <param name="options">IOptions instance.</param>
|
||||
internal sealed class RevalidatingAuthenticationStateProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<IdentityOptions> options)
|
||||
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the revalidation interval.
|
||||
/// </summary>
|
||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Validate the authentication state.
|
||||
/// </summary>
|
||||
/// <param name="authenticationState">AuthenticationState instance.</param>
|
||||
/// <param name="cancellationToken">CancellationToken.</param>
|
||||
/// <returns>Boolean indicating whether the currently logged on user is still valid.</returns>
|
||||
protected override async Task<bool> 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<UserManager<AdminUser>>();
|
||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<AdminUser> 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;
|
||||
}
|
||||
}
|
||||
12
src/AliasVault.Admin/Auth/_Imports.razor
Normal file
12
src/AliasVault.Admin/Auth/_Imports.razor
Normal file
@@ -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
|
||||
26
src/AliasVault.Admin/Config.cs
Normal file
26
src/AliasVault.Admin/Config.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="Config.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration class for the Admin project with values loaded from environment variables.
|
||||
/// </summary>
|
||||
public class Config
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string AdminPasswordHash { get; set; } = "false";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public DateTime LastPasswordChanged { get; set; } = DateTime.MinValue;
|
||||
}
|
||||
32
src/AliasVault.Admin/Dockerfile
Normal file
32
src/AliasVault.Admin/Dockerfile
Normal file
@@ -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"]
|
||||
72
src/AliasVault.Admin/Main/App.razor
Normal file
72
src/AliasVault.Admin/Main/App.razor
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="css/tailwind.css"/>
|
||||
<link rel="stylesheet" href="css/app.css"/>
|
||||
<link rel="stylesheet" href="AliasVault.Admin.styles.css"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<HeadOutlet @rendermode="RenderModeForPage"/>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 dark:bg-gray-800">
|
||||
<Routes @rendermode="RenderModeForPage"/>
|
||||
<script src="lib/qrcode.min.js?v=dev"></script>
|
||||
<script src="js/dark-mode.js?v=dev"></script>
|
||||
<script src="js/utilities.js?v=dev"></script>
|
||||
|
||||
<script>
|
||||
window.initTopMenu = function() {
|
||||
initDarkModeSwitcher();
|
||||
};
|
||||
|
||||
window.registerClickOutsideHandler = (dotNetHelper) => {
|
||||
document.addEventListener('click', (event) => {
|
||||
const menu = document.getElementById('userMenuDropdown');
|
||||
const menuButton = document.getElementById('userMenuDropdownButton');
|
||||
if (menu && !menu.contains(event.target) && !menuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
const mobileMenuButton = document.getElementById('toggleMobileMenuButton');
|
||||
if (mobileMenu && !mobileMenu.contains(event.target) && !mobileMenuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.clipboardCopy = {
|
||||
copyText: function (text) {
|
||||
navigator.clipboard.writeText(text).then(function () { })
|
||||
.catch(function (error) {
|
||||
alert(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.isFunctionDefined = function(functionName) {
|
||||
return typeof window[functionName] === 'function';
|
||||
};
|
||||
|
||||
// Primarily used by E2E tests.
|
||||
window.blazorNavigate = (url) => {
|
||||
Blazor.navigateTo(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
private IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/user")
|
||||
? null
|
||||
: InteractiveServer;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@inherits ComponentBase
|
||||
|
||||
@if (Message == string.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 border-2" role="alert">
|
||||
@Message
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The message to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@inherits ComponentBase
|
||||
|
||||
@if (Message == string.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400 border-2" role="alert">
|
||||
@Message
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The message to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@implements IDisposable
|
||||
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "success")
|
||||
{
|
||||
<AlertMessageSuccess Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "error")
|
||||
{
|
||||
<AlertMessageError Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<KeyValuePair<string, string>> Messages { get; set; } = new();
|
||||
private bool _onChangeSubscribed = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// We unsubscribe from the OnChange event of the PortalMessageService when the component is disposed
|
||||
if (_onChangeSubscribed)
|
||||
{
|
||||
GlobalNotificationService.OnChange -= RefreshAddMessages;
|
||||
_onChangeSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the messages by adding any new messages from the PortalMessageService.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@if (_errors.Any())
|
||||
{
|
||||
@foreach (var error in _errors)
|
||||
{
|
||||
<AlertMessageError Message="@error" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly List<string> _errors = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds a server validation error.
|
||||
/// </summary>
|
||||
public void AddError(string error)
|
||||
{
|
||||
_errors.Add(error);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the server validation errors.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_errors.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
53
src/AliasVault.Admin/Main/Components/Layout/Breadcrumb.razor
Normal file
53
src/AliasVault.Admin/Main/Components/Layout/Breadcrumb.razor
Normal file
@@ -0,0 +1,53 @@
|
||||
@inherits ComponentBase
|
||||
|
||||
<nav class="flex mb-5">
|
||||
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
|
||||
<li class="inline-flex items-center">
|
||||
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-500">
|
||||
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
@foreach (var item in BreadcrumbItems)
|
||||
{
|
||||
@if (item.Url is not null)
|
||||
{
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
<a href="@item.Url" class="ml-1 text-gray-700 hover:text-primary-600 md:ml-2 dark:text-gray-300 dark:hover:text-primary-500">@item.DisplayName</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
@item.DisplayName
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
<GlobalNotificationDisplay />
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the list of breadcrumb items.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<BreadcrumbItem> BreadcrumbItems { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<PageTitle>@ChildContent - AliasVault Admin</PageTitle>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Child content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment ChildContent { get; set; } = default!;
|
||||
}
|
||||
17
src/AliasVault.Admin/Main/Components/RedirectToLogin.razor
Normal file
17
src/AliasVault.Admin/Main/Components/RedirectToLogin.razor
Normal file
@@ -0,0 +1,17 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@using System.Timers
|
||||
|
||||
<button @onclick="HandleClick"
|
||||
disabled="@IsRefreshing"
|
||||
class="@GetButtonClasses()">
|
||||
<svg class="@GetIconClasses()" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<span class="ml-2">@ButtonText</span>
|
||||
</button>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The event to call in the parent when the button is clicked.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnRefresh { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The text to display on the button.
|
||||
/// </summary>
|
||||
[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" : "")}";
|
||||
}
|
||||
}
|
||||
156
src/AliasVault.Admin/Main/Components/WorkerStatus/Services.razor
Normal file
156
src/AliasVault.Admin/Main/Components/WorkerStatus/Services.razor
Normal file
@@ -0,0 +1,156 @@
|
||||
@using AliasVault.WorkerStatus.Database
|
||||
@inherits MainBase
|
||||
|
||||
<button @onclick="SmtpClick"
|
||||
class="@GetSmtpButtonClasses() mx-3 inline-flex items-center justify-center rounded-xl px-8 py-2 text-white">
|
||||
<span>SmtpService</span>
|
||||
@if (smtpPending)
|
||||
{
|
||||
<svg class="animate-spin ml-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
@code {
|
||||
private List<WorkerServiceStatus> 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..
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await InitPage();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service statuses.
|
||||
/// </summary>
|
||||
public async Task<List<WorkerServiceStatus>> GetServiceStatuses()
|
||||
{
|
||||
return await DbContext.WorkerServiceStatuses.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the service statuses.
|
||||
/// </summary>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the SMTP service status.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdateSmtpStatus(bool newStatus)
|
||||
{
|
||||
return await UpdateServiceStatus("AliasVault.SmtpService", newStatus);
|
||||
}
|
||||
}
|
||||
10
src/AliasVault.Admin/Main/Layout/Footer.razor
Normal file
10
src/AliasVault.Admin/Main/Layout/Footer.razor
Normal file
@@ -0,0 +1,10 @@
|
||||
<footer class="md:flex md:items-center md:justify-between px-4 2xl:px-0 py-6 md:py-10">
|
||||
<p class="text-sm text-center text-gray-500 mb-4 md:mb-0">
|
||||
© 2024 AliasVault. All rights reserved.
|
||||
</p>
|
||||
<ul class="flex flex-wrap items-center justify-center">
|
||||
<li><a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">Terms and conditions</a></li>
|
||||
<li><a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">License</a></li>
|
||||
<li><a href="https://github.com/lanedirt/AliasVault" target="_blank" class="text-sm font-normal text-gray-500 hover:underline dark:text-gray-400">GitHub</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
39
src/AliasVault.Admin/Main/Layout/MainLayout.razor
Normal file
39
src/AliasVault.Admin/Main/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,39 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<TopMenu />
|
||||
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||
<main>
|
||||
@Body
|
||||
</main>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
18
src/AliasVault.Admin/Main/Layout/MainLayout.razor.css
Normal file
18
src/AliasVault.Admin/Main/Layout/MainLayout.razor.css
Normal file
@@ -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;
|
||||
}
|
||||
146
src/AliasVault.Admin/Main/Layout/TopMenu.razor
Normal file
146
src/AliasVault.Admin/Main/Layout/TopMenu.razor
Normal file
@@ -0,0 +1,146 @@
|
||||
@inherits MainBase
|
||||
@implements IDisposable
|
||||
|
||||
<header>
|
||||
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4 bg-primary-100">
|
||||
<div class="flex justify-between items-center max-w-screen-2xl mx-auto">
|
||||
<div class="flex justify-start items-center">
|
||||
<a href="/" class="flex mr-14">
|
||||
<img src="/icon-trimmed.png" class="mr-3 h-8" alt="AliasVault Logo">
|
||||
<span class="self-center hidden sm:flex text-2xl font-semibold whitespace-nowrap dark:text-white">AliasVault</span>
|
||||
<span class="ps-2 self-center hidden sm:flex text-sm font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
|
||||
</a>
|
||||
|
||||
<div class="hidden justify-between items-center w-full lg:flex lg:w-auto lg:order-1">
|
||||
<ul class="flex flex-col mt-4 space-x-6 text-sm font-medium lg:flex-row xl:space-x-8 lg:mt-0">
|
||||
<NavLink href="/emails" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Emails
|
||||
</NavLink>
|
||||
<NavLink href="/logs" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Logs
|
||||
</NavLink>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end items-center lg:order-2">
|
||||
<Services />
|
||||
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
<div id="tooltip-toggle" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip" data-popper-placement="bottom" style="position: absolute; inset: 0px auto auto 0px; margin: 0px; transform: translate3d(1377px, 60px, 0px);">
|
||||
Toggle dark mode
|
||||
<div class="tooltip-arrow" data-popper-arrow="" style="position: absolute; left: 0px; transform: translate3d(68.5px, 0px, 0px);"></div>
|
||||
</div>
|
||||
|
||||
<button @onclick="ToggleMenu" type="button" class="flex mx-3 text-sm bg-gray-800 rounded-full md:mr-0 flex-shrink-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="userMenuDropdownButton" aria-expanded="false" data-dropdown-toggle="userMenuDropdown">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<img class="w-8 h-8 rounded-full" src="/img/avatar.webp" alt="user photo">
|
||||
</button>
|
||||
|
||||
@if (isMenuOpen)
|
||||
{
|
||||
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom">
|
||||
<div class="py-3 px-4">
|
||||
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@_username</span>
|
||||
</div>
|
||||
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
|
||||
<li>
|
||||
<a href="account/manage" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Account settings</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
|
||||
<li>
|
||||
<a href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white">Sign out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button @onclick="ToggleMobileMenu" type="button" id="toggleMobileMenuButton" class="items-center p-2 text-gray-500 rounded-lg md:ml-2 lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600">
|
||||
<span class="sr-only">Open menu</span>
|
||||
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@if (isMobileMenuOpen)
|
||||
{
|
||||
<nav class="bg-white dark:bg-gray-900">
|
||||
<ul id="mobileMenu" class="flex-col mt-0 pt-16 w-full text-sm font-medium lg:hidden">
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="/" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Home
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="/credentials" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Credentials
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
</header>
|
||||
|
||||
@code {
|
||||
private bool isMenuOpen = false;
|
||||
private bool isMobileMenuOpen = false;
|
||||
private string _username { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Close the menu.
|
||||
/// </summary>
|
||||
[JSInvokable]
|
||||
public void CloseMenu()
|
||||
{
|
||||
isMenuOpen = false;
|
||||
isMobileMenuOpen = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose method.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationService.LocationChanged -= LocationChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
_username = GetUsername();
|
||||
NavigationService.LocationChanged += LocationChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (firstRender)
|
||||
{
|
||||
await Js.InvokeVoidAsync("window.initTopMenu");
|
||||
DotNetObjectReference<TopMenu> 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;
|
||||
}
|
||||
}
|
||||
24
src/AliasVault.Admin/Main/Models/BreadcrumbItem.cs
Normal file
24
src/AliasVault.Admin/Main/Models/BreadcrumbItem.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="BreadcrumbItem.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Breadcrumb item model.
|
||||
/// </summary>
|
||||
public class BreadcrumbItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL.
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
@page "/account/manage/change-password"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<ChangePassword> Logger
|
||||
|
||||
<LayoutPageTitle>Change password</LayoutPageTitle>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Change password</h3>
|
||||
<EditForm Model="Input" FormName="change-password" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="old-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Old password</label>
|
||||
<InputText type="password" @bind-Value="Input.OldPassword" id="old-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password."/>
|
||||
<ValidationMessage For="() => Input.OldPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">New password</label>
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" id="new-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password."/>
|
||||
<ValidationMessage For="() => Input.NewPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Confirm password</label>
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="confirm-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md">
|
||||
Update password
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@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; } = "";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<h3 class="text-lg font-medium">Recovery codes</h3>
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4" role="alert">
|
||||
<p class="font-semibold">
|
||||
Put these codes in a safe place.
|
||||
</p>
|
||||
<p>
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1">
|
||||
@foreach (var recoveryCode in RecoveryCodes)
|
||||
{
|
||||
<div>
|
||||
<code class="block p-2 bg-primary-200 rounded">@recoveryCode</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The recovery codes to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string[] RecoveryCodes { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@page "/account/manage/disable-2fa"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<Disable2fa> Logger
|
||||
|
||||
<LayoutPageTitle>Disable two-factor authentication (2FA)</LayoutPageTitle>
|
||||
|
||||
<h3 class="text-xl font-bold mb-4">Disable two-factor authentication (2FA)</h3>
|
||||
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
|
||||
<p class="font-bold mb-2">
|
||||
This action only disables 2FA.
|
||||
</p>
|
||||
<p>
|
||||
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
|
||||
used in an authenticator app you should <a href="account/manage/reset-authenticator" class="text-primary-600 hover:text-primary-800 underline">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form @formname="disable-2fa" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken/>
|
||||
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" type="submit">Disable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<AdminUser> UserManager
|
||||
@inject UrlEncoder UrlEncoder
|
||||
@inject ILogger<EnableAuthenticator> Logger
|
||||
|
||||
<LayoutPageTitle>Configure authenticator app</LayoutPageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Configure authenticator app</h3>
|
||||
<div class="space-y-6">
|
||||
<p class="text-gray-700 dark:text-gray-300">To use an authenticator app go through the following steps:</p>
|
||||
<ol class="list-decimal space-y-4">
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Download a two-factor authenticator app like Microsoft Authenticator for
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825072" class="text-blue-600 hover:underline dark:text-blue-400">Android</a> and
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825073" class="text-blue-600 hover:underline dark:text-blue-400">iOS</a> or
|
||||
Google Authenticator for
|
||||
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en" class="text-blue-600 hover:underline dark:text-blue-400">Android</a> and
|
||||
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8" class="text-blue-600 hover:underline dark:text-blue-400">iOS</a>.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">Scan the QR Code or enter this key <kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500">@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
|
||||
<div id="authenticator-uri" data-url="@authenticatorUri" class="mt-4"></div>
|
||||
</li>
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
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.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<EditForm Model="Input" FormName="send-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-4">
|
||||
<DataAnnotationsValidator/>
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Verification Code</label>
|
||||
<InputText @bind-Value="Input.Code" id="code" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="off" placeholder="Please enter the code."/>
|
||||
<ValidationMessage For="() => Input.Code" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md dark:bg-primary-500 dark:hover:bg-primary-600">
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
</EditForm>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
|
||||
|
||||
private string? sharedKey;
|
||||
private string? authenticatorUri;
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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; } = "";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@page "/account/manage/generate-recovery-codes"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<GenerateRecoveryCodes> Logger
|
||||
|
||||
<LayoutPageTitle>Generate two-factor authentication (2FA) recovery codes</LayoutPageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h3 class="text-xl font-bold mb-4">Generate two-factor authentication (2FA) recovery codes</h3>
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
|
||||
<p class="mb-2">
|
||||
<svg class="inline w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<strong>Put these codes in a safe place.</strong>
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
<p>
|
||||
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
|
||||
used in an authenticator app you should <a href="account/manage/reset-authenticator" class="text-primary-600 hover:text-primary-800 underline">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" @onclick="GenerateCodes" type="submit">Generate Recovery Codes</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
69
src/AliasVault.Admin/Main/Pages/Account/Manage/Index.razor
Normal file
69
src/AliasVault.Admin/Main/Pages/Account/Manage/Index.razor
Normal file
@@ -0,0 +1,69 @@
|
||||
@page "/account/manage"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
|
||||
<LayoutPageTitle>Profile</LayoutPageTitle>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Profile</h3>
|
||||
|
||||
<EditForm Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" class="space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="username" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Username</label>
|
||||
<input type="text" value="@username" id="username" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 bg-gray-100 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:text-gray-400" placeholder="Please choose your username." disabled/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone-number" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Phone number</label>
|
||||
<InputText @bind-Value="Input.PhoneNumber" id="phone-number" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder="Please enter your phone number."/>
|
||||
<ValidationMessage For="() => Input.PhoneNumber" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md dark:bg-primary-500 dark:hover:bg-primary-600">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? username;
|
||||
private string? phoneNumber;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@page "/account/manage/reset-authenticator"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<ResetAuthenticator> Logger
|
||||
|
||||
<LayoutPageTitle>Reset authenticator key</LayoutPageTitle>
|
||||
|
||||
<h3 class="text-xl font-bold mb-4">Reset authenticator key</h3>
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
|
||||
<p class="mb-2">
|
||||
<svg class="inline w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
|
||||
</p>
|
||||
<p>
|
||||
This process disables 2FA until you verify your authenticator app.
|
||||
If you do not complete your authenticator app configuration you may lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<form @formname="reset-authenticator" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken/>
|
||||
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" type="submit">Reset authenticator key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@page "/account/manage/2fa"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject SignInManager<AdminUser> SignInManager
|
||||
|
||||
<LayoutPageTitle>Two-factor authentication (2FA)</LayoutPageTitle>
|
||||
|
||||
@if (is2FaEnabled)
|
||||
{
|
||||
<div class="mx-auto mt-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Two-factor authentication (2FA)</h3>
|
||||
|
||||
@if (recoveryCodesLeft == 0)
|
||||
{
|
||||
<div class="mb-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700 dark:bg-red-900 dark:text-red-100">
|
||||
<p class="font-bold">You have no recovery codes left.</p>
|
||||
<p>You must <a href="account/manage/generate-recovery-codes" class="text-red-800 dark:text-red-200 underline">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft == 1)
|
||||
{
|
||||
<div class="mb-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700 dark:bg-red-900 dark:text-red-100">
|
||||
<p class="font-bold">You have 1 recovery code left.</p>
|
||||
<p>You can <a href="account/manage/generate-recovery-codes" class="text-red-800 dark:text-red-200 underline">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft <= 3)
|
||||
{
|
||||
<div class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-100">
|
||||
<p class="font-bold">You have @recoveryCodesLeft recovery codes left.</p>
|
||||
<p>You should <a href="account/manage/generate-recovery-codes" class="text-yellow-800 dark:text-yellow-200 underline">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<a href="account/manage/disable-2fa" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Disable 2FA</a>
|
||||
<a href="account/manage/generate-recovery-codes" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Reset recovery codes</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Authenticator app</h4>
|
||||
<div class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
@if (!hasAuthenticator)
|
||||
{
|
||||
<a href="account/manage/enable-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Add authenticator app
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="account/manage/enable-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Set up authenticator app
|
||||
</a>
|
||||
<a href="account/manage/reset-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Reset authenticator app
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool hasAuthenticator;
|
||||
private int recoveryCodesLeft;
|
||||
private bool is2FaEnabled;
|
||||
|
||||
/// <inheritdoc />
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
26
src/AliasVault.Admin/Main/Pages/Account/ManageLayout.razor
Normal file
26
src/AliasVault.Admin/Main/Pages/Account/ManageLayout.razor
Normal file
@@ -0,0 +1,26 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using AliasVault.Admin.Main.Layout
|
||||
@layout MainLayout
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<GlobalNotificationDisplay />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Manage account</h1>
|
||||
</div>
|
||||
<p>Manage your profile here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<hr class="mb-6 border-t border-gray-300"/>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="w-full md:w-1/4 mb-6 md:mb-0">
|
||||
<ManageNavMenu/>
|
||||
</div>
|
||||
<div class="w-full md:w-3/4 md:pl-8">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
15
src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor
Normal file
15
src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor
Normal file
@@ -0,0 +1,15 @@
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject SignInManager<AdminUser> SignInManager
|
||||
|
||||
<ul class="flex flex-col space-y-1">
|
||||
<li>
|
||||
<NavLink href="account/manage" Match="NavLinkMatch.All" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Profile</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Password</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="account/manage/2fa" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Two-factor authentication</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
104
src/AliasVault.Admin/Main/Pages/Emails.razor
Normal file
104
src/AliasVault.Admin/Main/Pages/Emails.razor
Normal file
@@ -0,0 +1,104 @@
|
||||
@page "/emails"
|
||||
@using AliasVault.RazorComponents
|
||||
@using Azure
|
||||
|
||||
<LayoutPageTitle>Emails</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Emails</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of recently received mails by this AliasVault server.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overflow-x-auto px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border mt-8">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Time</th>
|
||||
<th scope="col" class="px-4 py-3">From</th>
|
||||
<th scope="col" class="px-4 py-3">To</th>
|
||||
<th scope="col" class="px-4 py-3">Subject</th>
|
||||
<th scope="col" class="px-4 py-3">Preview</th>
|
||||
<th scope="col" class="px-4 py-3">Attachments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var email in EmailList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@email.Id</td>
|
||||
<td class="px-4 py-3">@email.DateSystem.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium">@email.FromLocal</span>@@@email.FromDomain
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium">@email.ToLocal</span>@@@email.ToDomain
|
||||
</td>
|
||||
<td class="px-4 py-3">@email.Subject</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="line-clamp-1">@email.MessagePreview</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@email.Attachments.Count
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Email> 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; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
}
|
||||
37
src/AliasVault.Admin/Main/Pages/Error.razor
Normal file
37
src/AliasVault.Admin/Main/Pages/Error.razor
Normal file
@@ -0,0 +1,37 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<LayoutPageTitle>Error</LayoutPageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
|
||||
}
|
||||
15
src/AliasVault.Admin/Main/Pages/Home.razor
Normal file
15
src/AliasVault.Admin/Main/Pages/Home.razor
Normal file
@@ -0,0 +1,15 @@
|
||||
@page "/"
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Home</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">AliasVault Admin</h1>
|
||||
</div>
|
||||
<p>Welcome to the AliasVault admin portal.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
152
src/AliasVault.Admin/Main/Pages/Logs.razor
Normal file
152
src/AliasVault.Admin/Main/Pages/Logs.razor
Normal file
@@ -0,0 +1,152 @@
|
||||
@page "/logs"
|
||||
@using AliasVault.RazorComponents
|
||||
|
||||
<LayoutPageTitle>Logs</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Logs</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of recent system logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Time</th>
|
||||
<th scope="col" class="px-4 py-3">Application</th>
|
||||
<th scope="col" class="px-4 py-3">Level</th>
|
||||
<th scope="col" class="px-4 py-3">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logTableBody">
|
||||
@foreach (var log in LogList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@log.Id</td>
|
||||
<td class="px-4 py-3">@log.TimeStamp.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@log.Application</td>
|
||||
|
||||
@{
|
||||
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;
|
||||
}
|
||||
}
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 rounded-full text-white @bgColor">
|
||||
@log.Level
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 line-clamp-1" title="@log.Exception">@log.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Log> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
}
|
||||
99
src/AliasVault.Admin/Main/Pages/MainBase.cs
Normal file
99
src/AliasVault.Admin/Main/Pages/MainBase.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="MainBase.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class MainBase : OwningComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the NavigationService instance responsible for handling navigation, replaces the default NavigationManager.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public NavigationService NavigationService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UserService instance responsible for handling user data.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected UserService UserService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the global notification service for showing notifications throughout the app.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected GlobalNotificationService GlobalNotificationService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JS invoke service for calling JS functions from C#.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected JsInvokeService JsInvokeService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the AliasServerDbContext instance.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected AliasServerDbContext DbContext { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the AliasServerDbContextFactory instance.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected IDbContextFactory<AliasServerDbContext> DbContextFactory { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the injected JSRuntime instance.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected IJSRuntime Js { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the breadcrumb items for the page. A default set of breadcrumbs is added in the parent OnInitialized method.
|
||||
/// </summary>
|
||||
protected List<BreadcrumbItem> BreadcrumbItems { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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 });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the username from the authentication state asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>The username.</returns>
|
||||
protected string GetUsername()
|
||||
{
|
||||
return UserService.User().UserName ?? "[Unknown]";
|
||||
}
|
||||
}
|
||||
10
src/AliasVault.Admin/Main/Routes.razor
Normal file
10
src/AliasVault.Admin/Main/Routes.razor
Normal file
@@ -0,0 +1,10 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin/>
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
25
src/AliasVault.Admin/Main/_Imports.razor
Normal file
25
src/AliasVault.Admin/Main/_Imports.razor
Normal file
@@ -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
|
||||
|
||||
135
src/AliasVault.Admin/Program.cs
Normal file
135
src/AliasVault.Admin/Program.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="Program.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
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<UserService>();
|
||||
builder.Services.AddScoped<JsInvokeService>();
|
||||
builder.Services.AddScoped<GlobalNotificationService>();
|
||||
builder.Services.AddScoped<NavigationService>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingAuthenticationStateProvider>();
|
||||
|
||||
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<DbConnection>(container =>
|
||||
{
|
||||
var connection = new SqliteConnection(builder.Configuration.GetConnectionString("AliasServerDbContext"));
|
||||
connection.Open();
|
||||
|
||||
return connection;
|
||||
});
|
||||
|
||||
builder.Services.AddDbContextFactory<AliasServerDbContext>((container, options) =>
|
||||
{
|
||||
var connection = container.GetRequiredService<DbConnection>();
|
||||
options.UseSqlite(connection).UseLazyLoadingProxies();
|
||||
});
|
||||
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
builder.Services.AddIdentityCore<AdminUser>(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<AdminRole>()
|
||||
.AddEntityFrameworkStores<AliasServerDbContext>()
|
||||
.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<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var container = scope.ServiceProvider;
|
||||
var db = container.GetRequiredService<AliasServerDbContext>();
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider);
|
||||
await StartupTasks.SetAdminUser(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
namespace AliasVault.Admin
|
||||
{
|
||||
/// <summary>
|
||||
/// Explicit program class definition. This is required in order to start the Admin project
|
||||
/// in-memory from E2ETests project via WebApplicationFactory.
|
||||
/// </summary>
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
}
|
||||
40
src/AliasVault.Admin/Properties/launchSettings.json
Normal file
40
src/AliasVault.Admin/Properties/launchSettings.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/AliasVault.Admin/Services/GlobalNotificationService.cs
Normal file
103
src/AliasVault.Admin/Services/GlobalNotificationService.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="GlobalNotificationService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class GlobalNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Allow other components to subscribe to changes in the event object.
|
||||
/// </summary>
|
||||
public event Action? OnChange;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets success messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
protected List<string> SuccessMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets error messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
protected List<string> ErrorMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds a success message to the list of messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to add.</param>
|
||||
/// <param name="notifyStateChanged">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).</param>
|
||||
public void AddSuccessMessage(string message, bool notifyStateChanged = false)
|
||||
{
|
||||
SuccessMessages.Add(message);
|
||||
|
||||
// Notify subscribers that a message has been added.
|
||||
if (notifyStateChanged)
|
||||
{
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an error message to the list of messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to add.</param>
|
||||
/// <param name="notifyStateChanged">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).</param>
|
||||
public void AddErrorMessage(string message, bool notifyStateChanged = false)
|
||||
{
|
||||
ErrorMessages.Add(message);
|
||||
|
||||
// Notify subscribers that a message has been added.
|
||||
if (notifyStateChanged)
|
||||
{
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a dictionary with messages that should be displayed to the user. After this method is called,
|
||||
/// the messages are automatically cleared.
|
||||
/// </summary>
|
||||
/// <returns>Dictionary with messages that are ready to be displayed on the next page load.</returns>
|
||||
public List<KeyValuePair<string, string>> GetMessagesForDisplay()
|
||||
{
|
||||
var messages = new List<KeyValuePair<string, string>>();
|
||||
foreach (var message in SuccessMessages)
|
||||
{
|
||||
messages.Add(new KeyValuePair<string, string>("success", message));
|
||||
}
|
||||
|
||||
foreach (var message in ErrorMessages)
|
||||
{
|
||||
messages.Add(new KeyValuePair<string, string>("error", message));
|
||||
}
|
||||
|
||||
// Clear messages
|
||||
SuccessMessages.Clear();
|
||||
ErrorMessages.Clear();
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all messages.
|
||||
/// </summary>
|
||||
public void ClearMessages()
|
||||
{
|
||||
SuccessMessages.Clear();
|
||||
ErrorMessages.Clear();
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnChange?.Invoke();
|
||||
}
|
||||
53
src/AliasVault.Admin/Services/JSInvokeService.cs
Normal file
53
src/AliasVault.Admin/Services/JSInvokeService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="JSInvokeService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
/// <summary>
|
||||
/// Service for invoking JavaScript functions from C#.
|
||||
/// </summary>
|
||||
public class JsInvokeService(IJSRuntime js)
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoke a JavaScript function with retry and exponential backoff.
|
||||
/// </summary>
|
||||
/// <param name="functionName">The JS function name to call.</param>
|
||||
/// <param name="initialDelay">Initial delay before calling the function.</param>
|
||||
/// <param name="maxAttempts">Maximum attempts before giving up.</param>
|
||||
/// <param name="args">Arguments to pass on to the javascript function.</param>
|
||||
/// <returns>Async Task.</returns>
|
||||
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<bool>("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
|
||||
}
|
||||
}
|
||||
102
src/AliasVault.Admin/Services/NavigationService.cs
Normal file
102
src/AliasVault.Admin/Services/NavigationService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="NavigationService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation helper service.
|
||||
/// </summary>
|
||||
public class NavigationService
|
||||
{
|
||||
private readonly NavigationManager _navigationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NavigationService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="navigationManager">NavigationManager instance.</param>
|
||||
public NavigationService(NavigationManager navigationManager)
|
||||
{
|
||||
_navigationManager = navigationManager;
|
||||
_navigationManager.LocationChanged += (sender, args) => { LocationChanged?.Invoke(sender, args); };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Location changed event.
|
||||
/// </summary>
|
||||
public event EventHandler<LocationChangedEventArgs>? LocationChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Base URI.
|
||||
/// </summary>
|
||||
public string BaseUri => _navigationManager.BaseUri;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI.
|
||||
/// </summary>
|
||||
public string Uri => _navigationManager.Uri;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current path.
|
||||
/// </summary>
|
||||
private string CurrentPath => _navigationManager.ToAbsoluteUri(_navigationManager.Uri).GetLeftPart(UriPartial.Path);
|
||||
|
||||
/// <summary>
|
||||
/// Redirect to the current page.
|
||||
/// </summary>
|
||||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
|
||||
|
||||
/// <summary>
|
||||
/// Redirect to the specified URI.
|
||||
/// </summary>
|
||||
/// <param name="uri">The uri to redirect to.</param>
|
||||
/// <param name="forceLoad">Force load true/false.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redirect to the specified URI with query parameters.
|
||||
/// </summary>
|
||||
/// <param name="uri">URI to redirect to.</param>
|
||||
/// <param name="queryParameters">Optional querystring parameters to add to the URL.</param>
|
||||
/// <param name="forceLoad">Force load true/false.</param>
|
||||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters, bool forceLoad = false)
|
||||
{
|
||||
var uriWithoutQuery = _navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
|
||||
var newUri = _navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
|
||||
RedirectTo(newUri, forceLoad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a URI constructed from <paramref name="uri" /> except with multiple parameters
|
||||
/// added, updated, or removed.
|
||||
/// </summary>
|
||||
/// <param name="uri">The URI with the query to modify.</param>
|
||||
/// <param name="parameters">The values to add, update, or remove.</param>
|
||||
/// <returns>The URI with the query modified.</returns>
|
||||
public string GetUriWithQueryParameters(string uri, IReadOnlyDictionary<string, object?> parameters) => _navigationManager.GetUriWithQueryParameters(uri, parameters);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a relative URI into an absolute one (by resolving it
|
||||
/// relative to the current absolute URI).
|
||||
/// </summary>
|
||||
/// <param name="relativeUri">The relative URI.</param>
|
||||
/// <returns>The absolute URI.</returns>
|
||||
public Uri ToAbsoluteUri(string relativeUri) => _navigationManager.ToAbsoluteUri(relativeUri);
|
||||
}
|
||||
331
src/AliasVault.Admin/Services/UserService.cs
Normal file
331
src/AliasVault.Admin/Services/UserService.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="UserService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using AliasServerDb;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// User service for managing users.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">AliasServerDbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
/// <param name="httpContextAccessor">HttpContextManager instance.</param>
|
||||
public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser> userManager, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
private const string AdminRole = "Admin";
|
||||
private AdminUser? _user;
|
||||
|
||||
/// <summary>
|
||||
/// The roles of the current user.
|
||||
/// </summary>
|
||||
private List<string> _userRoles = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the current user is an admin or not.
|
||||
/// </summary>
|
||||
private bool _isAdmin;
|
||||
|
||||
/// <summary>
|
||||
/// Allow other components to subscribe to changes in the event object.
|
||||
/// </summary>
|
||||
public event Action OnChange = () => { };
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the User is loaded and available, false if not. Use this before accessing User() method.
|
||||
/// </summary>
|
||||
public bool UserLoaded => _user != null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns all users.
|
||||
/// </summary>
|
||||
/// <returns>List of users.</returns>
|
||||
public async Task<List<AdminUser>> GetAllUsersAsync()
|
||||
{
|
||||
var userList = await userManager.Users.ToListAsync();
|
||||
return userList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="userId">User ID.</param>
|
||||
/// <returns>AdminUser object.</returns>
|
||||
public async Task<AdminUser> GetUserByIdUserManagerAsync(Guid userId)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentException($"User with id {userId} not found.");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns inner User EF object.
|
||||
/// </summary>
|
||||
/// <returns>User object.</returns>
|
||||
public AdminUser User()
|
||||
{
|
||||
if (_user == null)
|
||||
{
|
||||
throw new ArgumentException("Trying to access User object which is null.");
|
||||
}
|
||||
|
||||
return _user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether current user is admin or not.
|
||||
/// </summary>
|
||||
/// <returns>Boolean which indicates if user is admin.</returns>
|
||||
public bool CurrentUserIsAdmin()
|
||||
{
|
||||
return _isAdmin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns current logged on user based on HttpContext.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns current logged on user roles based on HttpContext.
|
||||
/// </summary>
|
||||
/// <returns>List of roles.</returns>
|
||||
public async Task<List<string>> GetCurrentUserRolesAsync()
|
||||
{
|
||||
var roles = await userManager.GetRolesAsync(User());
|
||||
|
||||
return roles.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search for users based on search term.
|
||||
/// </summary>
|
||||
/// <param name="searchTerm">Search term.</param>
|
||||
/// <returns>List of users matching the search term.</returns>
|
||||
public async Task<List<AdminUser>> SearchUsersAsync(string searchTerm)
|
||||
{
|
||||
return await userManager.Users.Where(x => x.UserName != null && x.UserName.Contains(searchTerm)).Take(5).ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">Password.</param>
|
||||
/// <param name="roles">Roles.</param>
|
||||
/// <returns>List of errors if there are any.</returns>
|
||||
public async Task<List<string>> CreateUserAsync(AdminUser user, string password, List<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="newPassword">Optional parameter for new password for the user.</param>
|
||||
/// <returns>List of errors if any.</returns>
|
||||
public async Task<List<string>> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update user roles. This is a separate method because it is called from both CreateUserAsync and UpdateUserAsync.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="roles">New roles for the user.</param>
|
||||
/// <returns>List of errors if any.</returns>
|
||||
public async Task<List<string>> UpdateUserRolesAsync(AdminUser user, List<string> roles)
|
||||
{
|
||||
List<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if supplied password is correct for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">The password to check.</param>
|
||||
/// <returns>Boolean indicating whether supplied password is valid and matches what is stored in the database..</returns>
|
||||
public async Task<bool> CheckPasswordAsync(AdminUser user, string password)
|
||||
{
|
||||
if (password.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await userManager.CheckPasswordAsync(user, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate if user object contents conform to the requirements.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">Password for the user.</param>
|
||||
/// <param name="isUpdate">Boolean indicating whether the user is being updated or not.</param>
|
||||
/// <returns>List of strings.</returns>
|
||||
private async Task<List<string>> 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<string>();
|
||||
|
||||
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();
|
||||
}
|
||||
82
src/AliasVault.Admin/StartupTasks.cs
Normal file
82
src/AliasVault.Admin/StartupTasks.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="StartupTasks.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin;
|
||||
|
||||
using AliasServerDb;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Startup tasks that should be run when the application starts.
|
||||
/// </summary>
|
||||
public static class StartupTasks
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the roles if they do not exist.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">IServiceProvider instance.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public static async Task CreateRolesIfNotExist(IServiceProvider serviceProvider)
|
||||
{
|
||||
var roleManager = serviceProvider.GetRequiredService<RoleManager<AdminRole>>();
|
||||
|
||||
const string adminRole = "Admin";
|
||||
|
||||
if (!await roleManager.RoleExistsAsync(adminRole))
|
||||
{
|
||||
await roleManager.CreateAsync(new AdminRole(adminRole));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the admin user if it does not exist.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">IServiceProvider instance.</param>
|
||||
/// <returns>Async Task.</returns>
|
||||
public static async Task SetAdminUser(IServiceProvider serviceProvider)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AdminUser>>();
|
||||
var adminUser = await userManager.FindByNameAsync("admin");
|
||||
var config = serviceProvider.GetRequiredService<Config>();
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/AliasVault.Admin/appsettings.Development.json
Normal file
8
src/AliasVault.Admin/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/AliasVault.Admin/appsettings.json
Normal file
12
src/AliasVault.Admin/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
1425
src/AliasVault.Admin/package-lock.json
generated
Normal file
1425
src/AliasVault.Admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
src/AliasVault.Admin/package.json
Normal file
18
src/AliasVault.Admin/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
src/AliasVault.Admin/postcss.config.js
Normal file
6
src/AliasVault.Admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
58
src/AliasVault.Admin/tailwind.config.js
Normal file
58
src/AliasVault.Admin/tailwind.config.js
Normal file
@@ -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: [
|
||||
],
|
||||
}
|
||||
3
src/AliasVault.Admin/tailwind.css
Normal file
3
src/AliasVault.Admin/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
41
src/AliasVault.Admin/wwwroot/css/app.css
Normal file
41
src/AliasVault.Admin/wwwroot/css/app.css
Normal file
@@ -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;
|
||||
}
|
||||
2055
src/AliasVault.Admin/wwwroot/css/tailwind.css
Normal file
2055
src/AliasVault.Admin/wwwroot/css/tailwind.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/AliasVault.Admin/wwwroot/favicon.png
Normal file
BIN
src/AliasVault.Admin/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 936 B |
BIN
src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png
Normal file
BIN
src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
src/AliasVault.Admin/wwwroot/icon-trimmed.png
Normal file
BIN
src/AliasVault.Admin/wwwroot/icon-trimmed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
src/AliasVault.Admin/wwwroot/img/avatar.webp
Normal file
BIN
src/AliasVault.Admin/wwwroot/img/avatar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
src/AliasVault.Admin/wwwroot/img/service-placeholder.webp
Normal file
BIN
src/AliasVault.Admin/wwwroot/img/service-placeholder.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
53
src/AliasVault.Admin/wwwroot/js/dark-mode.js
Normal file
53
src/AliasVault.Admin/wwwroot/js/dark-mode.js
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
53
src/AliasVault.Admin/wwwroot/js/utilities.js
Normal file
53
src/AliasVault.Admin/wwwroot/js/utilities.js
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
1
src/AliasVault.Admin/wwwroot/lib/qrcode.min.js
vendored
Normal file
1
src/AliasVault.Admin/wwwroot/lib/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@
|
||||
<RootNamespace>AliasVault.Api</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
<DefineConstants Condition="'$(E2ETEST)' == 'true'">$(DefineConstants);E2ETEST</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
@@ -20,13 +21,13 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.6.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.6.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -38,6 +39,7 @@
|
||||
<ProjectReference Include="..\AliasGenerators\AliasGenerators.csproj" />
|
||||
<ProjectReference Include="..\AliasVault.Shared\AliasVault.Shared.csproj" />
|
||||
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\Cryptography.csproj" />
|
||||
<ProjectReference Include="..\Utilities\FaviconExtractor\FaviconExtractor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Auth controller for handling authentication.
|
||||
/// </summary>
|
||||
/// <param name="context">AliasServerDbContext instance.</param>
|
||||
/// <param name="dbContextFactory">AliasServerDbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
/// <param name="signInManager">SignInManager instance.</param>
|
||||
/// <param name="configuration">IConfiguration instance.</param>
|
||||
@@ -35,7 +36,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[ApiController]
|
||||
[ApiVersion("1")]
|
||||
public class AuthController(AliasServerDbContext context, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider) : ControllerBase
|
||||
public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Error message for invalid email or password.
|
||||
@@ -114,6 +115,8 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> 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<AliasVault
|
||||
// Check if the refresh token is valid.
|
||||
// Remove any existing refresh tokens for this user and device.
|
||||
var deviceIdentifier = GenerateDeviceIdentifier(Request);
|
||||
var existingToken = context.AspNetUserRefreshTokens.Where(t => 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<AliasVault
|
||||
[HttpPost("revoke")]
|
||||
public async Task<IActionResult> 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<AliasVault
|
||||
|
||||
// Check if the refresh token is valid.
|
||||
var deviceIdentifier = GenerateDeviceIdentifier(Request);
|
||||
var existingToken = context.AspNetUserRefreshTokens.Where(t => 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, UserManager<AliasVault
|
||||
/// <returns>TokenModel which includes new access and refresh token.</returns>
|
||||
private async Task<TokenModel> 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<AliasVault
|
||||
|
||||
// Save refresh token to database.
|
||||
// Remove any existing refresh tokens for this user and device.
|
||||
var existingTokens = context.AspNetUserRefreshTokens.Where(t => 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<AliasVault
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return new TokenModel() { Token = token, RefreshToken = refreshToken };
|
||||
return new TokenModel { Token = token, RefreshToken = refreshToken };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/")]
|
||||
public class RootController : ControllerBase
|
||||
public class RootController(IDbContextFactory<AliasServerDbContext> dbContextFactory) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Root endpoint that returns a 200 OK if the database connection is successful
|
||||
@@ -26,24 +26,23 @@ public class RootController : ControllerBase
|
||||
[HttpGet]
|
||||
[ProducesResponseType<int>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<int>(StatusCodes.Status500InternalServerError)]
|
||||
public IActionResult Get()
|
||||
public async Task<IActionResult> 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
|
||||
{
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* 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<AliasVaultUser> userManager) : Authentic
|
||||
/// <summary>
|
||||
/// Authenticated test request.
|
||||
/// </summary>
|
||||
/// <returns>List of aliases in JSON format.</returns>
|
||||
/// <returns>Static OK.</returns>
|
||||
[HttpGet("")]
|
||||
public IActionResult TestCall()
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test request that throws an exception. Used for testing error handling.
|
||||
/// </summary>
|
||||
/// <returns>Static OK.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("Error")]
|
||||
public IActionResult TestCallError()
|
||||
{
|
||||
// Throw an exception here to test error handling.
|
||||
throw new ApplicationException("Test error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// <summary>
|
||||
/// Vault controller for handling CRUD operations on the database for encrypted vault entities.
|
||||
/// </summary>
|
||||
/// <param name="context">DbContext instance.</param>
|
||||
/// <param name="dbContextFactory">DbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
/// <param name="timeProvider">ITimeProvider instance.</param>
|
||||
[ApiVersion("1")]
|
||||
public class VaultController(AliasServerDbContext context, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
|
||||
public class VaultController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Default retention policy for vaults.
|
||||
@@ -46,6 +46,8 @@ public class VaultController(AliasServerDbContext context, UserManager<AliasVaul
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> 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<AliasVaul
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Update([FromBody] Shared.Models.WebApi.Vault model)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
if (user == null)
|
||||
{
|
||||
|
||||
@@ -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<ITimeProvider, SystemTimeProvider>();
|
||||
builder.Services.AddScoped<TimeValidationJwtBearerEvents>();
|
||||
@@ -34,18 +38,13 @@ builder.Services.AddLogging(logging =>
|
||||
|
||||
builder.Services.AddSingleton<DbConnection>(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<AliasServerDbContext>((container, options) =>
|
||||
builder.Services.AddDbContextFactory<AliasServerDbContext>((container, options) =>
|
||||
{
|
||||
var connection = container.GetRequiredService<DbConnection>();
|
||||
options.UseSqlite(connection).UseLazyLoadingProxies();
|
||||
@@ -57,7 +56,7 @@ builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
|
||||
options.TokenLifespan = TimeSpan.FromDays(30);
|
||||
options.Name = "AliasVault";
|
||||
});
|
||||
builder.Services.AddIdentity<AliasVaultUser, IdentityRole>(options =>
|
||||
builder.Services.AddIdentity<AliasVaultUser, AliasVaultRole>(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<AliasServerDbContext>();
|
||||
var db = await container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>().CreateDbContextAsync();
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Debug"
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Jwt": {
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.6" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.7" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@using AliasVault.Shared.Models.WebApi
|
||||
|
||||
@if (_errors.Any())
|
||||
{
|
||||
@foreach (var error in _errors)
|
||||
|
||||
@@ -34,9 +34,15 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Attachments to be uploaded.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<Attachment> Attachments { get; set; } = new List<Attachment>();
|
||||
public List<Attachment> Attachments { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Callback that is invoked when the attachments are changed.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<List<Attachment>> AttachmentsChanged { get; set; }
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The attachments to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
@using AliasVault.Client.Services
|
||||
|
||||
<!-- CopyPasteFormRow.razor -->
|
||||
@inject ClipboardCopyService ClipboardCopyService
|
||||
@inject ClipboardCopyService ClipboardCopyService
|
||||
@inject IJSRuntime JsRuntime
|
||||
@implements IDisposable
|
||||
|
||||
@@ -17,12 +14,22 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "Value";
|
||||
[Parameter] public string Value { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The label for the input.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Label { get; set; } = "Value";
|
||||
|
||||
/// <summary>
|
||||
/// The value to copy to the clipboard.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId;
|
||||
private readonly string _inputId = Guid.NewGuid().ToString();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ClipboardCopyService.OnCopy += HandleCopy;
|
||||
@@ -46,6 +53,7 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
ClipboardCopyService.OnCopy -= HandleCopy;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<PageTitle>@ChildContent - AliasVault</PageTitle>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The content to display as prefix in the page title.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment ChildContent { get; set; } = default!;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@inherits MainBase
|
||||
@inject CredentialService CredentialService
|
||||
@using System.Globalization
|
||||
@using AliasGenerators.Implementations
|
||||
@using AliasGenerators.Password
|
||||
@using AliasGenerators.Password.Implementations
|
||||
|
||||
@if (EditMode)
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
private DbServiceState.DatabaseState CurrentDbState { get; set; } = new();
|
||||
private const int MinimumLoadingTimeMs = 800;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
@@ -118,6 +119,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
DbService.GetState().StateChanged -= OnDatabaseStateChanged;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user