mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-24 06:39:12 -05:00
Compare commits
242 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cfc8d528d | ||
|
|
7a4e1721c8 | ||
|
|
11d79c4874 | ||
|
|
7cd35b0a92 | ||
|
|
d0f62a26c0 | ||
|
|
01198502a3 | ||
|
|
229ad109a7 | ||
|
|
837b16d971 | ||
|
|
4010d1b93f | ||
|
|
f7ce60ae68 | ||
|
|
5e61bd5db2 | ||
|
|
a2e8a438de | ||
|
|
92904dcf55 | ||
|
|
e4f2ca630b | ||
|
|
ed80ad24c1 | ||
|
|
0c368ab84b | ||
|
|
dee2044ed6 | ||
|
|
f6f6072b3f | ||
|
|
4bfe72d750 | ||
|
|
330f59dc10 | ||
|
|
a20d981427 | ||
|
|
bd2274db75 | ||
|
|
6cfa6f4ef5 | ||
|
|
8a40d2b1b9 | ||
|
|
237958ba0f | ||
|
|
79db3a54c7 | ||
|
|
2029745f8b | ||
|
|
ea4d498502 | ||
|
|
05838f5dca | ||
|
|
79872163e2 | ||
|
|
35d0f77dd6 | ||
|
|
6660cd20bd | ||
|
|
e236ba454f | ||
|
|
6ec66e4d64 | ||
|
|
14898c0c83 | ||
|
|
d08bec9df7 | ||
|
|
9107dfa789 | ||
|
|
351f6f4d16 | ||
|
|
aca607e579 | ||
|
|
ed053422ba | ||
|
|
955b8638ce | ||
|
|
1d8883cc94 | ||
|
|
48281f92e6 | ||
|
|
f19db2c010 | ||
|
|
f0d397c8af | ||
|
|
fafa51d787 | ||
|
|
202151e4f1 | ||
|
|
c123edccd4 | ||
|
|
50cab3a2f3 | ||
|
|
0184e32e6d | ||
|
|
d73d4e90e0 | ||
|
|
06d38842f5 | ||
|
|
b0748316ff | ||
|
|
8f8b4af3c9 | ||
|
|
11bf183cbb | ||
|
|
ac64dba715 | ||
|
|
d2f9b225d0 | ||
|
|
d7f1df3252 | ||
|
|
fdce8bddd1 | ||
|
|
be4a105709 | ||
|
|
dfa2f84570 | ||
|
|
450ca6a6f4 | ||
|
|
4c31912d73 | ||
|
|
367be5a409 | ||
|
|
b6cf46ab91 | ||
|
|
0da0bd0b17 | ||
|
|
d8ccaad806 | ||
|
|
656210e4f6 | ||
|
|
c3c85bc10e | ||
|
|
693ad0b581 | ||
|
|
94ad51059e | ||
|
|
9374780a5b | ||
|
|
3263360be5 | ||
|
|
1151089d59 | ||
|
|
d39ecf69e8 | ||
|
|
9caea03460 | ||
|
|
32879e09a8 | ||
|
|
d3518eca6c | ||
|
|
329ae185ad | ||
|
|
888054e8ed | ||
|
|
0d141e2c7c | ||
|
|
33b930b58a | ||
|
|
ad9eb79e9e | ||
|
|
cd46578576 | ||
|
|
e577d6fee4 | ||
|
|
9d1923d3ea | ||
|
|
d77c28184c | ||
|
|
180de219c8 | ||
|
|
17e4f614d8 | ||
|
|
747e0910cb | ||
|
|
fc85f34218 | ||
|
|
f0e0e9c03e | ||
|
|
2631a1f0b1 | ||
|
|
254104e12d | ||
|
|
a75d5c7a34 | ||
|
|
bf40539e92 | ||
|
|
cb330219ab | ||
|
|
4f5e822722 | ||
|
|
96997c7d8d | ||
|
|
f959b7dc91 | ||
|
|
59599f43a3 | ||
|
|
b5e575051c | ||
|
|
433664d85d | ||
|
|
82b2b75127 | ||
|
|
577e02d761 | ||
|
|
26b1c4e044 | ||
|
|
3872678039 | ||
|
|
80cc72eb22 | ||
|
|
141a291ace | ||
|
|
3b5e944417 | ||
|
|
65553e0918 | ||
|
|
a7502d42e4 | ||
|
|
4d43acb53f | ||
|
|
14ac94b78a | ||
|
|
361f4b8817 | ||
|
|
7a62ddcf6a | ||
|
|
6b59200df2 | ||
|
|
6a0699318c | ||
|
|
f6e2648a53 | ||
|
|
4b8e4c907e | ||
|
|
30804cc973 | ||
|
|
8edfc3d0d6 | ||
|
|
4fb5087c82 | ||
|
|
cf454d2bb8 | ||
|
|
0a577873ee | ||
|
|
32c8e48d45 | ||
|
|
564ae54de8 | ||
|
|
d9f4f8d121 | ||
|
|
728b20b489 | ||
|
|
219f0bc9cc | ||
|
|
9735df0436 | ||
|
|
78a872a67d | ||
|
|
77a48ea4e9 | ||
|
|
22538ae000 | ||
|
|
db632c3edb | ||
|
|
1c53addcaa | ||
|
|
817404cd08 | ||
|
|
9062cdc701 | ||
|
|
e45866fa67 | ||
|
|
8fbd10caaa | ||
|
|
54d54f28b4 | ||
|
|
3116aa5a1f | ||
|
|
eb45358532 | ||
|
|
03fd047cb4 | ||
|
|
6a7fc9c5ba | ||
|
|
62700de9ad | ||
|
|
edeaa77299 | ||
|
|
84b93924f5 | ||
|
|
400e702753 | ||
|
|
147f8db5d1 | ||
|
|
8e1470bc1b | ||
|
|
50853bf011 | ||
|
|
07dd90a705 | ||
|
|
a7a7d6d82b | ||
|
|
249efe54b0 | ||
|
|
20eb3e5ff4 | ||
|
|
ba15c446d9 | ||
|
|
5ea9f4ee08 | ||
|
|
1e7b7b172a | ||
|
|
35f6565c8b | ||
|
|
8cb99c997d | ||
|
|
16cf528b7f | ||
|
|
8ad3eb9bd5 | ||
|
|
2d59c40e24 | ||
|
|
33283e07be | ||
|
|
fbf5880370 | ||
|
|
be4a74ef3c | ||
|
|
a759091755 | ||
|
|
8dc99c09a8 | ||
|
|
b9ec4baf66 | ||
|
|
71ed62cdcb | ||
|
|
2bbad8c75c | ||
|
|
f02b841eea | ||
|
|
f6fc5af8ac | ||
|
|
1d1155bf0e | ||
|
|
2632211af6 | ||
|
|
05cca6998e | ||
|
|
c4a8a20a62 | ||
|
|
f2c6af9ccb | ||
|
|
e94201acda | ||
|
|
9e03473208 | ||
|
|
0c5b2fb1da | ||
|
|
a5c4a7618d | ||
|
|
70220cecbb | ||
|
|
c63faa352f | ||
|
|
7e261a05c9 | ||
|
|
545ec5576e | ||
|
|
73dcbe5860 | ||
|
|
13917444b9 | ||
|
|
119e13a9dd | ||
|
|
7d656e9a9a | ||
|
|
8bd05b5c2e | ||
|
|
1e65f14323 | ||
|
|
2c7543889d | ||
|
|
63c5483208 | ||
|
|
2586d61651 | ||
|
|
c7a32cf0e9 | ||
|
|
46cc6527aa | ||
|
|
ef291bffc1 | ||
|
|
94f6199e27 | ||
|
|
5ababf3bf3 | ||
|
|
b47e735e8f | ||
|
|
de17303085 | ||
|
|
635136d257 | ||
|
|
832e340b1b | ||
|
|
4e0b6b5adf | ||
|
|
18be105350 | ||
|
|
9bea01fbf8 | ||
|
|
a33fd08cb4 | ||
|
|
25f5660f81 | ||
|
|
0923936f7c | ||
|
|
3c0905d0b0 | ||
|
|
97fd3beeaa | ||
|
|
3195ad86ce | ||
|
|
d147639a83 | ||
|
|
9e0716d32e | ||
|
|
3a05b1e5c3 | ||
|
|
9628861186 | ||
|
|
2b541dc28d | ||
|
|
e655dcedb0 | ||
|
|
9b8bbebb44 | ||
|
|
bbc99ebf16 | ||
|
|
23690f4e9b | ||
|
|
6286034a9d | ||
|
|
2ea684061e | ||
|
|
973abc8917 | ||
|
|
65304b0f84 | ||
|
|
ca4dd89e89 | ||
|
|
fccf10dc82 | ||
|
|
b845245728 | ||
|
|
e46357d603 | ||
|
|
6568ed8059 | ||
|
|
236718c76e | ||
|
|
17ef816fa3 | ||
|
|
db33a0a1da | ||
|
|
7a97bbf716 | ||
|
|
0c4ab8c1b6 | ||
|
|
6ee19d57bf | ||
|
|
dcb92c8dad | ||
|
|
968d3cfcf1 | ||
|
|
8e9c12f6e7 | ||
|
|
3c8f32e67a |
@@ -22,4 +22,17 @@
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
README.md
|
||||
|
||||
# Exclude AliasVault data directories
|
||||
database/
|
||||
logs/
|
||||
certificates/
|
||||
|
||||
# Exclude git directory
|
||||
.git/
|
||||
|
||||
# Exclude development files
|
||||
*.log
|
||||
*.env
|
||||
*.env.*
|
||||
@@ -6,3 +6,5 @@ ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
|
||||
PRIVATE_EMAIL_DOMAINS=
|
||||
SMTP_TLS_ENABLED=false
|
||||
LETSENCRYPT_ENABLED=false
|
||||
POSTGRES_PASSWORD=
|
||||
SUPPORT_EMAIL=
|
||||
|
||||
11
.github/workflows/docker-compose-build.yml
vendored
11
.github/workflows/docker-compose-build.yml
vendored
@@ -18,23 +18,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Create .env file with custom SMTP port as port 25 is not allowed in GitHub Actions
|
||||
run: |
|
||||
echo "SMTP_PORT=2525" > .env
|
||||
|
||||
- name: Set permissions and run install.sh
|
||||
run: |
|
||||
chmod +x install.sh
|
||||
./install.sh build --verbose
|
||||
|
||||
- name: Set up Docker Compose
|
||||
run: |
|
||||
sed -i 's/25\:25/2525\:25/g' docker-compose.yml
|
||||
docker compose -f docker-compose.yml up -d
|
||||
|
||||
- name: Test if services are responding
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 5
|
||||
command: |
|
||||
sleep 5
|
||||
sleep 15
|
||||
|
||||
# Array of endpoints to test
|
||||
declare -A endpoints=(
|
||||
|
||||
34
.github/workflows/docker-compose-pull.yml
vendored
34
.github/workflows/docker-compose-pull.yml
vendored
@@ -20,8 +20,16 @@ jobs:
|
||||
- name: Get repository and branch information
|
||||
id: repo-info
|
||||
run: |
|
||||
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
|
||||
# Check if this is a PR from a fork
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
|
||||
# If PR is from a fork, use main branch from lanedirt/AliasVault
|
||||
echo "REPO_FULL_NAME=lanedirt/AliasVault" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=main" >> $GITHUB_ENV
|
||||
else
|
||||
# Otherwise use the current repository and branch
|
||||
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Download install script from current branch
|
||||
run: |
|
||||
@@ -29,16 +37,30 @@ jobs:
|
||||
echo "Downloading install script from: $INSTALL_SCRIPT_URL"
|
||||
curl -f -o install.sh "$INSTALL_SCRIPT_URL"
|
||||
|
||||
- name: Create .env file with custom SMTP port as port 25 is not allowed in GitHub Actions
|
||||
run: |
|
||||
echo "SMTP_PORT=2525" > .env
|
||||
|
||||
- name: Set permissions and run install.sh
|
||||
id: install_script
|
||||
continue-on-error: true
|
||||
run: |
|
||||
chmod +x install.sh
|
||||
./install.sh install --verbose
|
||||
|
||||
- name: Set up Docker Compose
|
||||
- name: Check if failure was due to version mismatch
|
||||
if: steps.install_script.outcome == 'failure'
|
||||
run: |
|
||||
# Change the exposed host port of the SmtpService from 25 to 2525 because port 25 is not allowed in GitHub Actions
|
||||
sed -i 's/25\:25/2525\:25/g' docker-compose.yml
|
||||
docker compose -f docker-compose.yml up -d
|
||||
if grep -q "Install script needs updating to match version" <<< "$(./install.sh install --verbose 2>&1)"; then
|
||||
echo "Test skipped: Install script version is newer than latest release version. This is expected behavior if the install script is run on a branch that is ahead of the latest release."
|
||||
exit 0
|
||||
else
|
||||
echo "Test failed due to an unexpected error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Compose
|
||||
run: docker compose -f docker-compose.yml up -d
|
||||
|
||||
- name: Wait for services to be up
|
||||
run: |
|
||||
|
||||
3
.github/workflows/dotnet-e2e-admin-tests.yml
vendored
3
.github/workflows/dotnet-e2e-admin-tests.yml
vendored
@@ -25,6 +25,9 @@ jobs:
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Start dev database
|
||||
run: ./install.sh configure-dev-db start
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install --with-deps
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ jobs:
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Start dev database
|
||||
run: ./install.sh configure-dev-db start
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install --with-deps
|
||||
|
||||
|
||||
3
.github/workflows/dotnet-e2e-misc-tests.yml
vendored
3
.github/workflows/dotnet-e2e-misc-tests.yml
vendored
@@ -25,6 +25,9 @@ jobs:
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Start dev database
|
||||
run: ./install.sh configure-dev-db start
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install --with-deps
|
||||
|
||||
|
||||
@@ -25,5 +25,8 @@ jobs:
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Start dev database
|
||||
run: ./install.sh configure-dev-db start
|
||||
|
||||
- name: Run integration tests
|
||||
run: dotnet test src/Tests/AliasVault.IntegrationTests --no-build --verbosity normal
|
||||
|
||||
46
.github/workflows/publish-docker-images.yml
vendored
46
.github/workflows/publish-docker-images.yml
vendored
@@ -21,6 +21,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Convert repository name to lowercase
|
||||
run: |
|
||||
echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
|
||||
@@ -38,11 +44,21 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}
|
||||
|
||||
- name: Build and push Postgres image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/Databases/AliasServerDb/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/AliasVault.Api/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:${{ github.ref_name }}
|
||||
|
||||
@@ -51,6 +67,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: src/AliasVault.Client/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:${{ github.ref_name }}
|
||||
|
||||
@@ -59,29 +76,42 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: src/AliasVault.Admin/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push SMTP image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/Services/AliasVault.SmtpService/Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push Reverse Proxy image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push SMTP image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/Services/AliasVault.SmtpService/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push TaskRunner image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/Services/AliasVault.TaskRunner/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push InstallCli image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/Utilities/AliasVault.InstallCli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:${{ github.ref_name }}
|
||||
|
||||
23
.github/workflows/sonarcloud-code-analysis.yml
vendored
23
.github/workflows/sonarcloud-code-analysis.yml
vendored
@@ -1,10 +1,13 @@
|
||||
# This workflow will perform a SonarCloud code analysis on every push to the main branch or when a pull request is opened, synchronized, or reopened.
|
||||
# This workflow will perform a SonarCloud code analysis on every push to the main branch or
|
||||
# when a pull request is opened, synchronized, or reopened. The "pull_request_target" event is
|
||||
# used to ensure that the analysis is done on the source branch of the pull request which has
|
||||
# access to the SonarCloud token secret.
|
||||
name: SonarCloud code analysis
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,11 +26,13 @@ jobs:
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'zulu' # Alternative distribution options are available.
|
||||
distribution: 'zulu'
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- name: Checkout code of PR branch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v3
|
||||
@@ -57,7 +62,11 @@ jobs:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
shell: powershell
|
||||
run: |
|
||||
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
|
||||
if ('${{ github.event_name }}' -eq 'pull_request_target') {
|
||||
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
|
||||
} else {
|
||||
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
|
||||
}
|
||||
dotnet build
|
||||
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'
|
||||
.\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
.\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}"
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -272,6 +272,10 @@ ServiceFabricBackup/
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# SQL files
|
||||
*.sql
|
||||
*.sql.gz
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
@@ -407,3 +411,10 @@ certificates/letsencrypt/**
|
||||
docs/_site
|
||||
docs/vendor
|
||||
docs/.bundle
|
||||
|
||||
# Database files
|
||||
database/postgres
|
||||
database/postgres-dev
|
||||
|
||||
# Temp files
|
||||
temp
|
||||
|
||||
103
CONTRIBUTING.md
103
CONTRIBUTING.md
@@ -1,101 +1,14 @@
|
||||
# Contributing
|
||||
This document is a work-in-progress and will be expanded as time goes on. If you have any questions feel free to open a issue on GitHub.
|
||||
# Contributing to the source code
|
||||
We welcome contributions to AliasVault. Please read the guidelines on the official AliasVault docs website on how to get your local development environment setup and the general contribution guidelines:
|
||||
|
||||
Note: all instructions below are based on MacOS. If you are using a different operating system, you may need to adjust the commands accordingly.
|
||||
https://docs.aliasvault.net/misc/dev/contributing.html
|
||||
|
||||
## Getting Started
|
||||
In order to contribute to this project follow these instructions to setup your local environment:
|
||||
> Tip: if the URL above is not available, the raw doc pages can also be found in the `docs` folder in this repository.
|
||||
|
||||
### 1. Clone the repository
|
||||
## Contributing to the documentation
|
||||
The docs are built using Jekyll and automatically deploy to GitHub Pages via GitHub Actions. You can build the docs locally by running `docker compose up` in in the `./docs` folder.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lanedirt/AliasVault.git
|
||||
cd AliasVault
|
||||
```
|
||||
|
||||
### 2. Copy pre-commit hook script to .git/hooks directory
|
||||
**Important**: All commits in this repo are required to contain a reference to a GitHub issue in the format of "your commit message (#123)" where "123" references the GitHub issue number.
|
||||
|
||||
The pre-commit hook script below will check the commit message before allowing the commit to proceed. If the commit message is invalid, the commit will be aborted.
|
||||
|
||||
```bash
|
||||
# Copy the commit-msg hook script to the .git/hooks directory
|
||||
cp .github/hooks/commit-msg .git/hooks/commit-msg
|
||||
|
||||
# Make the script executable
|
||||
chmod +x .git/hooks/commit-msg
|
||||
```
|
||||
|
||||
### 3. Install the latest version of .NET SDK 8
|
||||
|
||||
```bash
|
||||
# Install .NET SDK 8
|
||||
|
||||
# On MacOS via brew:
|
||||
brew install --cask dotnet-sdk
|
||||
|
||||
# On Windows via winget
|
||||
winget install Microsoft.DotNet.SDK.8
|
||||
```
|
||||
|
||||
### 4. Install dotnet CLI EF Tools
|
||||
|
||||
```bash
|
||||
# Install dotnet EF tools globally
|
||||
dotnet tool install --global dotnet-ef
|
||||
# Include dotnet tools in your PATH
|
||||
nano ~/.zshrc
|
||||
# Add the following line to your .zshrc file
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
# Start a new terminal and test that this command works:
|
||||
dotnet ef
|
||||
```
|
||||
|
||||
### 5. Run Tailwind CSS compiler while changing HTML files to update compiled CSS
|
||||
|
||||
```bash
|
||||
npm run build:css
|
||||
```
|
||||
|
||||
### 6. Install Playwright in order to locally run NUnit E2E (end-to-end) tests
|
||||
|
||||
```bash
|
||||
# First install PowerShell for Mac (if you don't have it already)
|
||||
brew install powershell/tap/powershell
|
||||
# Install Playwright
|
||||
dotnet tool install --global Microsoft.Playwright.CLI
|
||||
# Run Playwright install script to download local browsers
|
||||
# Note: make sure the E2E test project has been built at least once so the bin dir exists.
|
||||
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install
|
||||
```
|
||||
|
||||
### 7. Create AliasVault.Client appsettings.Development.json
|
||||
The WASM client app supports a development specific appsettings.json file. This appsettings file is optional but can override various options to make debugging easier.
|
||||
The docs site is based on the open-source template called Just The Docs. Find more information about how this template works in the [official docs](https://just-the-docs.github.io/just-the-docs/).
|
||||
|
||||
|
||||
1. Copy `wwwroot/appsettings.json` to `wwwroot/appsettings.Development.json`
|
||||
|
||||
Here is an example file with the various options explained:
|
||||
|
||||
```
|
||||
{
|
||||
"ApiUrl": "http://localhost:5092",
|
||||
"PrivateEmailDomains": ["example.tld"],
|
||||
"SupportEmail": "support@example.tld",
|
||||
"UseDebugEncryptionKey": "true",
|
||||
"CryptographyOverrideType" : "Argon2Id",
|
||||
"CryptographyOverrideSettings" : "{\"DegreeOfParallelism\":1,\"MemorySize\":1024,\"Iterations\":1}"
|
||||
}
|
||||
```
|
||||
|
||||
- UseDebugEncryptionKey
|
||||
- This setting will use a static encryption key so that if you login as a user you can refresh the page without needing to unlock the database again. This speeds up development when changing things in the WebApp WASM project. Note: the project needs to be run in "Development" mode for this setting to be used.
|
||||
|
||||
- CryptographyOverrideType
|
||||
- This setting allows overriding the default encryption type (Argon2id) with a different encryption type. This is useful for testing different encryption types without having to change code.
|
||||
|
||||
- CryptographyOverrideSettings
|
||||
- This setting allows overriding the default encryption settings (Argon2id) with different settings. This is useful for testing different encryption settings without having to change code. The default Argon2id settings
|
||||
are defined in the project as `Utilities/Cryptography/Cryptography.Client/Defaults.cs`. These default settings
|
||||
are focused on security but NOT performance. Normally for key derivation purposes the slower/heavier the algorithm
|
||||
the better protection against attackers. For production builds this is what we want, however in case of automated testing or debugging extra performance can be gained by tweaking (lowering) these settings.
|
||||
To make changes to the AliasVault documentation please make a PR that directly edits the `docs` markdown files in this repository.
|
||||
|
||||
14
README.md
14
README.md
@@ -1,14 +1,16 @@
|
||||
<div align="center">
|
||||
|
||||
🌟 **If you find this project useful, please consider giving it a star!** 🌟
|
||||
|
||||
<h1><img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="40" /> AliasVault</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://app.aliasvault.net">Live demo 🔥</a> • <a href="https://aliasvault.net?utm_source=gh-readme">Website 🌐</a> • <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation 📚</a> • <a href="#installation">Installation ⚙️</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">
|
||||
Open-source password and alias manager
|
||||
</h3>
|
||||
<p align="center">
|
||||
<strong>Open-source password and alias manager</strong>
|
||||
</p>
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/AliasVault/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)
|
||||
@@ -25,7 +27,7 @@ Open-source password and alias manager
|
||||
|
||||
</div>
|
||||
|
||||
AliasVault is an open-source password and alias manager built with C# ASP.NET technology. AliasVault can be self-hosted on your own server with Docker, providing a secure and private solution for managing your online identities and passwords.
|
||||
AliasVault is an end-to-end encrypted password and alias manager that protects your privacy by creating alternative identities, passwords and email addresses for every website you use. The core of AliasVault is built with C# ASP.NET Blazor WASM technology. AliasVault can be self-hosted on your own server with Docker.
|
||||
|
||||
### What makes AliasVault unique:
|
||||
- **Zero-knowledge architecture**: All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
|
||||
@@ -50,7 +52,7 @@ This method uses pre-built Docker images and works on minimal hardware specifica
|
||||
|
||||
- Linux VM with root access (Ubuntu or RHEL based distros recommended)
|
||||
- 1 vCPU
|
||||
- 512MB RAM
|
||||
- 1GB RAM
|
||||
- 16GB disk space
|
||||
- Docker installed
|
||||
|
||||
@@ -69,7 +71,7 @@ The install script will output the URL where the app is available. By default th
|
||||
- Client: https://localhost
|
||||
- Admin portal: https://localhost/admin
|
||||
|
||||
> Note: If you want to change the default AliasVault ports you can do so in the `docker-compose.yml` file for the `nginx` (reverse-proxy) container.
|
||||
> Note: If you want to change the default AliasVault ports you can do so in the `.env` file.
|
||||
|
||||
## Detailed documentation
|
||||
For more detailed information about the installation process and other topics, please see the official documentation website:
|
||||
|
||||
@@ -67,6 +67,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{DD359F
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Shared.Core", "src\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj", "{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.TaskRunner", "src\Services\AliasVault.TaskRunner\AliasVault.TaskRunner.csproj", "{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Shared.Server", "src\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj", "{34FADEB6-4B56-463B-B359-F844B43D76D9}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -169,6 +173,14 @@ Global
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -197,6 +209,8 @@ Global
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
|
||||
{15EFE0D0-F41B-47D7-86B7-8F840335CB82} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
|
||||
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1} = {8A477241-B96C-4174-968D-D40CB77F1ECD}
|
||||
{34FADEB6-4B56-463B-B359-F844B43D76D9} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
services:
|
||||
reverse-proxy:
|
||||
image: aliasvault-reverse-proxy
|
||||
postgres:
|
||||
image: aliasvault-postgres
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: src/Databases/AliasServerDb/Dockerfile
|
||||
|
||||
client:
|
||||
image: aliasvault-client
|
||||
@@ -23,8 +23,20 @@ services:
|
||||
context: .
|
||||
dockerfile: src/AliasVault.Admin/Dockerfile
|
||||
|
||||
reverse-proxy:
|
||||
image: aliasvault-reverse-proxy
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
smtp:
|
||||
image: aliasvault-smtp
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/Services/AliasVault.SmtpService/Dockerfile
|
||||
dockerfile: src/Services/AliasVault.SmtpService/Dockerfile
|
||||
|
||||
task-runner:
|
||||
image: aliasvault-task-runner
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/Services/AliasVault.TaskRunner/Dockerfile
|
||||
|
||||
20
docker-compose.dev.yml
Normal file
20
docker-compose.dev.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
postgres-dev:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- ./database/postgres-dev:/var/lib/postgresql/data:rw
|
||||
- ./src/Databases/AliasServerDb/postgresql.conf:/etc/postgresql/postgresql.conf
|
||||
environment:
|
||||
- POSTGRES_DB=aliasvault
|
||||
- POSTGRES_USER=aliasvault
|
||||
- POSTGRES_PASSWORD=password
|
||||
restart: "no"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U aliasvault"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
|
||||
@@ -1,21 +1,17 @@
|
||||
services:
|
||||
reverse-proxy:
|
||||
image: ghcr.io/lanedirt/aliasvault-reverse-proxy:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
postgres:
|
||||
image: ghcr.io/lanedirt/aliasvault-postgres:latest
|
||||
volumes:
|
||||
- ./certificates/ssl:/etc/nginx/ssl:rw
|
||||
- ./certificates/letsencrypt:/etc/nginx/ssl-letsencrypt:rw
|
||||
- ./certificates/letsencrypt/www:/var/www/certbot:rw
|
||||
depends_on:
|
||||
- admin
|
||||
- client
|
||||
- api
|
||||
- smtp
|
||||
restart: always
|
||||
- ./database/postgres:/var/lib/postgresql/data:rw
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U aliasvault"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
client:
|
||||
image: ghcr.io/lanedirt/aliasvault-client:latest
|
||||
@@ -35,9 +31,14 @@ services:
|
||||
- ./database:/database:rw
|
||||
- ./certificates/app:/certificates/app:rw
|
||||
- ./logs:/logs:rw
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ConnectionStrings__AliasServerDbContext: "Host=postgres;Database=aliasvault;Username=aliasvault;Password=${POSTGRES_PASSWORD}"
|
||||
|
||||
admin:
|
||||
image: ghcr.io/lanedirt/aliasvault-admin:latest
|
||||
@@ -50,15 +51,57 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ConnectionStrings__AliasServerDbContext: "Host=postgres;Database=aliasvault;Username=aliasvault;Password=${POSTGRES_PASSWORD}"
|
||||
|
||||
reverse-proxy:
|
||||
image: ghcr.io/lanedirt/aliasvault-reverse-proxy:latest
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./certificates/ssl:/etc/nginx/ssl:rw
|
||||
- ./certificates/letsencrypt:/etc/nginx/ssl-letsencrypt:rw
|
||||
- ./certificates/letsencrypt/www:/var/www/certbot:rw
|
||||
depends_on:
|
||||
- admin
|
||||
- client
|
||||
- api
|
||||
- smtp
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
smtp:
|
||||
image: ghcr.io/lanedirt/aliasvault-smtp:latest
|
||||
ports:
|
||||
- "25:25"
|
||||
- "587:587"
|
||||
- "${SMTP_PORT:-25}:25"
|
||||
- "${SMTP_TLS_PORT:-587}:587"
|
||||
volumes:
|
||||
- ./database:/database:rw
|
||||
- ./logs:/logs:rw
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ConnectionStrings__AliasServerDbContext: "Host=postgres;Database=aliasvault;Username=aliasvault;Password=${POSTGRES_PASSWORD}"
|
||||
|
||||
task-runner:
|
||||
image: ghcr.io/lanedirt/aliasvault-task-runner:latest
|
||||
volumes:
|
||||
- ./database:/database:rw
|
||||
- ./logs:/logs:rw
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ConnectionStrings__AliasServerDbContext: "Host=postgres;Database=aliasvault;Username=aliasvault;Password=${POSTGRES_PASSWORD}"
|
||||
1
docs/.gitignore
vendored
Normal file
1
docs/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!release
|
||||
@@ -9,7 +9,7 @@ nav_order: 1
|
||||
Instead of using the pre-built Docker images, you can also build the images from source yourself. This allows you to build a specific version of AliasVault and/or to make changes to the source code.
|
||||
|
||||
Building from source requires more resources:
|
||||
- Minimum 2GB RAM (more RAM will speed up build time)
|
||||
- Minimum 4GB RAM (more RAM will speed up build time)
|
||||
- At least 1 vCPU
|
||||
- 40GB+ disk space (for dependencies and build artifacts)
|
||||
- Docker installed
|
||||
|
||||
22
docs/installation/advanced/database.md
Normal file
22
docs/installation/advanced/database.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
layout: default
|
||||
title: Database Backup
|
||||
parent: Advanced
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# Database Backup
|
||||
|
||||
In order to backup the database, you can use the `install.sh` script. This script will stop all services, export the database to a file, and then restart the services.
|
||||
|
||||
```bash
|
||||
./install.sh db-backup > backup.sql.gz
|
||||
```
|
||||
|
||||
# Database Restore
|
||||
|
||||
To restore the database, you can use the `install.sh` script. This script will stop all services, drop the database, import the database from a file, and then restart the services.
|
||||
|
||||
```bash
|
||||
./install.sh db-restore < backup.sql.gz
|
||||
```
|
||||
@@ -2,7 +2,7 @@
|
||||
layout: default
|
||||
title: Manual Setup
|
||||
parent: Advanced
|
||||
nav_order: 2
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Manual Setup
|
||||
@@ -20,7 +20,7 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
|
||||
Create the following directories in your project root:
|
||||
```bash
|
||||
mkdir -p certificates/ssl certificates/app database logs/msbuild
|
||||
mkdir -p certificates/ssl certificates/app database/postgres
|
||||
```
|
||||
|
||||
2. **Create .env file**
|
||||
@@ -61,7 +61,21 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
DATA_PROTECTION_CERT_PASS=your_generated_password_here
|
||||
```
|
||||
|
||||
6. **Set PRIVATE_EMAIL_DOMAINS**
|
||||
6. **Configure PostgreSQL Settings**
|
||||
|
||||
Set the following PostgreSQL-related variables in your .env file:
|
||||
```bash
|
||||
# Database name (default: aliasvault)
|
||||
POSTGRES_DB=aliasvault
|
||||
|
||||
# Database user (default: aliasvault)
|
||||
POSTGRES_USER=aliasvault
|
||||
|
||||
# Generate a secure password for PostgreSQL
|
||||
POSTGRES_PASSWORD=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
7. **Set PRIVATE_EMAIL_DOMAINS**
|
||||
|
||||
Update the .env file with allowed email domains. Use DISABLED.TLD to disable email support:
|
||||
```bash
|
||||
@@ -72,14 +86,14 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
PRIVATE_EMAIL_DOMAINS=DISABLED.TLD
|
||||
```
|
||||
|
||||
7. **Set SUPPORT_EMAIL (Optional)**
|
||||
8. **Set SUPPORT_EMAIL (Optional)**
|
||||
|
||||
Add a support email address if desired:
|
||||
```bash
|
||||
SUPPORT_EMAIL=support@yourdomain.com
|
||||
```
|
||||
|
||||
8. **Generate admin password**
|
||||
9. **Generate admin password**
|
||||
|
||||
Build the Docker image for password hashing:
|
||||
```bash
|
||||
@@ -97,7 +111,7 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
|
||||
```
|
||||
|
||||
9. **Build and start Docker containers**
|
||||
10. **Build and start Docker containers**
|
||||
|
||||
Build the Docker Compose stack:
|
||||
```bash
|
||||
@@ -109,22 +123,23 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
10. **Access AliasVault**
|
||||
11. **Access AliasVault**
|
||||
|
||||
AliasVault should now be running. You can access it at:
|
||||
|
||||
- Admin Panel: https://localhost/admin
|
||||
- Username: admin
|
||||
- Password: [Use the password you set in step 8]
|
||||
- Password: [Use the password you set in step 9]
|
||||
|
||||
- Client Website: https://localhost/
|
||||
- Create your own account from here
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Make sure to save the admin password you used in step 8 in a secure location.
|
||||
- If you need to reset the admin password in the future, repeat step 8 and restart the Docker containers.
|
||||
- Make sure to save both the admin password and PostgreSQL password in a secure location.
|
||||
- If you need to reset the admin password in the future, repeat step 9 and restart the Docker containers.
|
||||
- Always keep your .env file secure and do not share it, as it contains sensitive information.
|
||||
- The PostgreSQL data is persisted in the `database/postgres` directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -134,7 +149,11 @@ If you encounter any issues during the setup:
|
||||
```bash
|
||||
docker compose logs
|
||||
```
|
||||
2. Ensure all required ports (80 and 443) are available and not being used by other services.
|
||||
2. Ensure all required ports (80, 443, and 5432) are available and not being used by other services.
|
||||
3. Verify that all environment variables in the .env file are set correctly.
|
||||
4. Check PostgreSQL container logs specifically:
|
||||
```bash
|
||||
docker compose logs postgres
|
||||
```
|
||||
|
||||
For further assistance, please refer to the project documentation or seek support through the appropriate channels.
|
||||
|
||||
@@ -20,7 +20,7 @@ To get AliasVault up and running quickly, run the install script to pull pre-bui
|
||||
### Hardware requirements
|
||||
- Linux VM with root access (Ubuntu or RHEL based distros recommended)
|
||||
- 1 vCPU
|
||||
- 512MB RAM
|
||||
- 1GB RAM
|
||||
- 16GB disk space
|
||||
- Docker installed
|
||||
|
||||
@@ -37,7 +37,7 @@ chmod +x install.sh
|
||||
```bash
|
||||
./install.sh install
|
||||
```
|
||||
> **Note**: AliasVault binds to ports 80 and 443 by default. If you want to change the default AliasVault ports you can do so in the `docker-compose.yml` file for the `reverse-proxy` (nginx) container. Afterwards re-run the `./install.sh install` command to restart the containers with the new port settings.
|
||||
> **Note**: AliasVault binds to ports 80 and 443 by default. If you want to change the default AliasVault ports you can do so in the `.env` file. Afterwards re-run the `./install.sh install` command to restart the containers with the new port settings.
|
||||
|
||||
3. After the script completes, you can access AliasVault at:
|
||||
- Client: `https://localhost`
|
||||
@@ -128,7 +128,21 @@ If you encounter any issues, feel free to open an issue on the [GitHub repositor
|
||||
|
||||
---
|
||||
|
||||
## 4. Troubleshooting
|
||||
## 4. Configure Account Registration
|
||||
|
||||
By default, AliasVault is configured to allow public registration of new accounts. This means that anyone can create a new account on your server.
|
||||
|
||||
If you want to disable public registration, you can do so by running the install script with the `configure-registration` option and then choosing option 2.
|
||||
|
||||
```bash
|
||||
./install.sh configure-registration
|
||||
```
|
||||
|
||||
> Note: disabling public registration means the ability to create new accounts in the AliasVault client is disabled for everyone, including administrators. Accounts cannot be created outside of the client because of the end-to-end encryption employed by AliasVault. So make sure you have created your own account(s) before disabling public registration.
|
||||
|
||||
---
|
||||
|
||||
## 5. Troubleshooting
|
||||
|
||||
### Resetting the admin password
|
||||
If you have lost your admin password, you can reset it by running the install script with the `reset-password` option. This will generate a new random password and update the .env file with it. After that it will restart the AliasVault containers to apply the changes.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Update
|
||||
parent: Installation Guide
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Updating AliasVault
|
||||
To update AliasVault to the latest version, run the install script with the `update` option. This will pull the latest version of AliasVault from GitHub and restart all containers.
|
||||
|
||||
You can see the latest available version of AliasVault on [GitHub](https://github.com/lanedirt/AliasVault/releases).
|
||||
|
||||
{: .warning }
|
||||
Before updating, it's recommended to backup your database and other important data. You can do this by making
|
||||
a copy of the `database` and `certificates` directories.
|
||||
|
||||
## Updating to the latest available version
|
||||
To update to the latest version, run the install script with the `update` option. The script will check for the latest version and prompt you to confirm the update. Follow the prompts to complete the update.
|
||||
|
||||
```bash
|
||||
./install.sh update
|
||||
```
|
||||
|
||||
## Installing a specific version
|
||||
To install a specific version and skip the automatic version checks, run the install script with the `install` option and specify the version you want to install.
|
||||
|
||||
```bash
|
||||
./install.sh install <version>
|
||||
|
||||
# Example:
|
||||
./install.sh install 0.7.0
|
||||
```
|
||||
60
docs/installation/update/index.md
Normal file
60
docs/installation/update/index.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
layout: default
|
||||
title: Update
|
||||
parent: Installation Guide
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Updating AliasVault
|
||||
{: .no_toc }
|
||||
|
||||
<details open markdown="block">
|
||||
<summary>
|
||||
Table of contents
|
||||
</summary>
|
||||
{: .text-delta }
|
||||
1. TOC
|
||||
{:toc}
|
||||
</details>
|
||||
|
||||
## Before You Begin
|
||||
You can see the latest available version of AliasVault on [GitHub](https://github.com/lanedirt/AliasVault/releases).
|
||||
|
||||
{: .warning }
|
||||
Before updating, it's recommended to backup your database and other important data. You can do this by making
|
||||
a copy of the `database` and `certificates` directories.
|
||||
|
||||
## Standard Update Process
|
||||
For most version updates, you can use the standard update process:
|
||||
|
||||
```bash
|
||||
./install.sh update
|
||||
```
|
||||
|
||||
> Tip: to skip the confirmation prompts and automatically proceed with the update, use the `-y` flag: `./install.sh update -y`
|
||||
|
||||
## Version-Specific Upgrade Guides
|
||||
Some versions require additional steps during upgrade. If you are upgrading from an older version, please check the relevant upgrade guide below:
|
||||
|
||||
- [Updating to v0.10.0](v0-10-0.html) - SQLite to PostgreSQL migration
|
||||
|
||||
## Additional Update Options
|
||||
|
||||
### Updating the installer script
|
||||
The installer script can check for and apply updates to itself. This is done as part of the `update` command. However you can also update the installer script separately with the `update-installer` command. This is useful if you want to update the installer script without updating AliasVault itself, e.g. as a separate step during CI/CD pipeline.
|
||||
|
||||
```bash
|
||||
./install.sh update-installer
|
||||
```
|
||||
|
||||
> Tip: to skip the confirmation prompts and automatically proceed with the update, use the `-y` flag: `./install.sh update-installer -y`
|
||||
|
||||
### Installing a specific version
|
||||
To install a specific version and skip the automatic version checks, run the install script with the `install` option and specify the version you want to install. Note that downgrading is not supported officially and may lead to unexpected issues.
|
||||
|
||||
```bash
|
||||
./install.sh install <version>
|
||||
|
||||
# Example:
|
||||
./install.sh install 0.7.0
|
||||
```
|
||||
39
docs/installation/update/v0.10.0.md
Normal file
39
docs/installation/update/v0.10.0.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
layout: default
|
||||
title: Update to v0.10.0
|
||||
parent: Update
|
||||
grand_parent: Installation Guide
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Upgrading to v0.10.0
|
||||
{: .no_toc }
|
||||
|
||||
This guide covers the upgrade process from version < v0.10.0 to v0.10.0 or newer, which includes a one-time database migration from SQLite to PostgreSQL.
|
||||
|
||||
The v0.10.0 release introduces a new database backend, PostgreSQL, which replaces SQLite. This change is required because SQLite is not suitable for environments with concurrent writes that AliasVault requires.
|
||||
|
||||
A built-in database migration tool is included in the installer script to help you migrate your data from SQLite to PostgreSQL.
|
||||
|
||||
## Update Steps
|
||||
|
||||
1. First, backup your existing SQLite database:
|
||||
```bash
|
||||
cp database/AliasServerDb.sqlite database/AliasServerDb.sqlite.backup
|
||||
```
|
||||
2. Update AliasVault to the latest version:
|
||||
```bash
|
||||
./install.sh update
|
||||
```
|
||||
3. Run the database migration tool:
|
||||
```bash
|
||||
./install.sh migrate-db
|
||||
```
|
||||
4. After the migration has completed successfully, restart all AliasVault containers:
|
||||
```bash
|
||||
./install.sh restart
|
||||
```
|
||||
|
||||
5. Test the upgrade by logging in to the admin panel and checking that your data is intact.
|
||||
|
||||
If you encounter any issues during the upgrade, please create an issue on the [GitHub repository](https://github.com/lanedirt/AliasVault/issues) or contact via Discord.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Configure SQLite for use with WebAssembly
|
||||
parent: Development
|
||||
grand_parent: Miscellaneous
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# Configure SQLite for use with WebAssembly
|
||||
To configure SQLite for use with WebAssembly follow these steps:
|
||||
|
||||
1. Add NuGet package
|
||||
```
|
||||
dotnet add package SQLitePCLRaw.bundle_e_sqlite3
|
||||
```
|
||||
|
||||
2. Modify .csproj and add the following:
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<WasmBuildNative>true</WasmBuildNative>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
3. Make sure the "wasm-tools" workload is installed on the local machine in order to build the project:
|
||||
```
|
||||
dotnet workload install wasm-tools
|
||||
```
|
||||
114
docs/misc/dev/contributing.md
Normal file
114
docs/misc/dev/contributing.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
layout: default
|
||||
title: Contributing
|
||||
parent: Development
|
||||
grand_parent: Miscellaneous
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Contributing
|
||||
This document is a work-in-progress and will be expanded as time goes on. If you have any questions feel free to open a issue on GitHub.
|
||||
|
||||
Note: all instructions below are based on MacOS. If you are using a different operating system, you may need to adjust the commands accordingly.
|
||||
|
||||
## Getting Started
|
||||
In order to contribute to this project follow these instructions to setup your local environment:
|
||||
|
||||
### 1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/lanedirt/AliasVault.git
|
||||
cd AliasVault
|
||||
```
|
||||
|
||||
### 2. Copy pre-commit hook script to .git/hooks directory
|
||||
{: .note }
|
||||
All commits in this repo are required to contain a reference to a GitHub issue in the format of "your commit message (#123)" where "123" references the GitHub issue number.
|
||||
|
||||
The pre-commit hook script below will check the commit message before allowing the commit to proceed. If the commit message is invalid, the commit will be aborted.
|
||||
|
||||
```bash
|
||||
# Copy the commit-msg hook script to the .git/hooks directory
|
||||
cp .github/hooks/commit-msg .git/hooks/commit-msg
|
||||
|
||||
# Make the script executable
|
||||
chmod +x .git/hooks/commit-msg
|
||||
```
|
||||
|
||||
### 3. Install the latest version of .NET SDK 9
|
||||
```bash
|
||||
# Install .NET SDK 9
|
||||
|
||||
# On MacOS via brew:
|
||||
brew install --cask dotnet-sdk
|
||||
|
||||
# On Windows via winget
|
||||
winget install Microsoft.DotNet.SDK.9
|
||||
```
|
||||
|
||||
### 4. Install dotnet CLI EF Tools
|
||||
```bash
|
||||
# Install dotnet EF tools globally
|
||||
dotnet tool install --global dotnet-ef
|
||||
# Include dotnet tools in your PATH
|
||||
nano ~/.zshrc
|
||||
# Add the following line to your .zshrc file
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
# Start a new terminal and test that this command works:
|
||||
dotnet ef
|
||||
```
|
||||
|
||||
### 5. Install dev database
|
||||
AliasVault uses PostgreSQL as its database. In order to run the project locally from Visual Studio / Rider you will need to install the dev database. You can do this by running the following command. This will start a separate PostgreSQL instance on port 5433 accessible via the `localhost:5433` address.
|
||||
|
||||
```bash
|
||||
./install.sh configure-dev-db
|
||||
```
|
||||
|
||||
After the database is running you can start the project from Visual Studio / Rider in run or debug mode and it should be able to connect to the dev database.
|
||||
|
||||
### 6. Run Tailwind CSS compiler when changing HTML files to update compiled CSS
|
||||
```bash
|
||||
# For Admin project (in the admin project directory)
|
||||
npm run build:admin-css
|
||||
# For Client project (in the client project directory)
|
||||
npm run build:client-css
|
||||
```
|
||||
|
||||
### 7. Install Playwright in order to locally run NUnit E2E (end-to-end) tests
|
||||
```bash
|
||||
# First install PowerShell for Mac (if you don't have it already)
|
||||
brew install powershell/tap/powershell
|
||||
# Install Playwright
|
||||
dotnet tool install --global Microsoft.Playwright.CLI
|
||||
# Run Playwright install script to download local browsers
|
||||
# Note: make sure the E2E test project has been built at least once so the bin dir exists.
|
||||
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install
|
||||
```
|
||||
|
||||
### 8. Create AliasVault.Client appsettings.Development.json
|
||||
The WASM client app supports a development specific appsettings.json file. This appsettings file is optional but can override various options to make debugging easier.
|
||||
|
||||
1. Copy `wwwroot/appsettings.json` to `wwwroot/appsettings.Development.json`
|
||||
|
||||
Here is an example file with the various options explained:
|
||||
|
||||
```json
|
||||
{
|
||||
"ApiUrl": "http://localhost:5092",
|
||||
"PrivateEmailDomains": ["example.tld"],
|
||||
"SupportEmail": "support@example.tld",
|
||||
"UseDebugEncryptionKey": "true",
|
||||
"CryptographyOverrideType" : "Argon2Id",
|
||||
"CryptographyOverrideSettings" : "{\"DegreeOfParallelism\":1,\"MemorySize\":1024,\"Iterations\":1}"
|
||||
}
|
||||
```
|
||||
|
||||
- **UseDebugEncryptionKey**
|
||||
- This setting will use a static encryption key so that if you login as a user you can refresh the page without needing to unlock the database again. This speeds up development when changing things in the WebApp WASM project. Note: the project needs to be run in "Development" mode for this setting to be used.
|
||||
|
||||
- **CryptographyOverrideType**
|
||||
- This setting allows overriding the default encryption type (Argon2id) with a different encryption type. This is useful for testing different encryption types without having to change code.
|
||||
|
||||
- **CryptographyOverrideSettings**
|
||||
- This setting allows overriding the default encryption settings (Argon2id) with different settings. This is useful for testing different encryption settings without having to change code. The default Argon2id settings are defined in the project as `Utilities/Cryptography/Cryptography.Client/Defaults.cs`. These default settings are focused on security but NOT performance. Normally for key derivation purposes the slower/heavier the algorithm the better protection against attackers. For production builds this is what we want, however in case of automated testing or debugging extra performance can be gained by tweaking (lowering) these settings.
|
||||
```
|
||||
@@ -3,13 +3,11 @@ layout: default
|
||||
title: Enable WebAuthn
|
||||
parent: Development
|
||||
grand_parent: Miscellaneous
|
||||
nav_order: 1
|
||||
nav_order: 9
|
||||
---
|
||||
|
||||
# WebAuthn
|
||||
The webauthn implementation in order to quick unlock the vault requires the use of a FIDO2 authenticator.
|
||||
|
||||
This can be either the built-in browser authenticator or an external authenticator like a Yubikey.
|
||||
Webauthn allows to quick unlock the vault. This can be either the built-in browser authenticator or an external authenticator like a Yubikey.
|
||||
|
||||
At the time of writing (2024-10-04), only some browsers support the required PRF extension. In order to make it work in Chrome, you need to enable the PRF extension in the browser settings.
|
||||
|
||||
@@ -19,3 +17,4 @@ At the time of writing (2024-10-04), only some browsers support the required PRF
|
||||
2. Enable the `Experimental Web Platform features` flag.
|
||||
3. Restart the browser.
|
||||
4. Now it should be possible to use the built-in chrome password manager to unlock the vault.
|
||||
5. Go to Menu -> Security Settings -> Quick Vault Unlock and enable it.
|
||||
|
||||
51
docs/misc/dev/postgresql-commands.md
Normal file
51
docs/misc/dev/postgresql-commands.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
layout: default
|
||||
title: PostgreSQL Commands
|
||||
parent: Development
|
||||
grand_parent: Miscellaneous
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# PostgreSQL Commands
|
||||
|
||||
## Backup database to file
|
||||
To backup the database to a file, you can use the following command:
|
||||
|
||||
```bash
|
||||
docker compose exec postgres pg_dump -U aliasvault aliasvault | gzip > aliasvault.sql.gz
|
||||
```
|
||||
|
||||
## Import database from file
|
||||
To drop the existing database and restore the database from a file, you can use the following command:
|
||||
|
||||
{: .warning }
|
||||
Executing this command will drop the existing database and restore the database from the file. Make sure to have a backup of the existing database before running this command.
|
||||
|
||||
```bash
|
||||
docker compose exec postgres psql -U aliasvault postgres -c "DROP DATABASE aliasvault;" && \
|
||||
docker compose exec postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault;" && \
|
||||
gunzip < aliasvault.sql.gz | docker compose exec -iT postgres psql -U aliasvault aliasvault
|
||||
```
|
||||
|
||||
## Change master password
|
||||
By default during initial installation the PostgreSQL master password is set to a random string that is
|
||||
stored in the `.env` file with the `POSTGRES_PASSWORD` variable.
|
||||
|
||||
If you wish to change the master password, you can do so by running the following command:
|
||||
|
||||
1. Open a terminal and navigate to the root of the AliasVault repository.
|
||||
2. Run the following command to connect to the PostgreSQL container:
|
||||
```bash
|
||||
docker compose exec -it postgres psql -U aliasvault -d aliasvault
|
||||
```
|
||||
3. Once connected to the database, you can change the master password by running the following command:
|
||||
```sql
|
||||
ALTER USER aliasvault WITH PASSWORD 'new_password';
|
||||
```
|
||||
4. Press Enter to confirm the changes.
|
||||
5. Exit the PostgreSQL shell by running `\q`.
|
||||
6. Manually update the `.env` file variable `POSTGRES_PASSWORD` with the new password.
|
||||
7. Restart the AliasVault containers by running the following command:
|
||||
```bash
|
||||
docker compose restart
|
||||
```
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: 1. Run GitHub Actions Locally
|
||||
title: Run GitHub Actions Locally
|
||||
parent: Development
|
||||
grand_parent: Miscellaneous
|
||||
nav_order: 1
|
||||
nav_order: 9
|
||||
---
|
||||
|
||||
# Run GitHub Actions Locally
|
||||
|
||||
@@ -19,4 +19,4 @@ To upgrade the AliasClientDb EF model, follow these steps:
|
||||
dotnet ef migrations add "1.0.0-<migration-name>"
|
||||
```
|
||||
4. On the next login of a user, they will be prompted (required) to upgrade their database schema to the latest version.
|
||||
Make sure to manually test this.
|
||||
Make sure to manually test that the migration works as expected.
|
||||
|
||||
22
docs/misc/dev/upgrade-ef-server-model.md
Normal file
22
docs/misc/dev/upgrade-ef-server-model.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
layout: default
|
||||
title: Upgrade the AliasServerDb EF model
|
||||
parent: Development
|
||||
grand_parent: Miscellaneous
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Upgrade the AliasServerDb EF model
|
||||
|
||||
The AliasServerDb EF model has migrations for both the SQLite and PostgreSQL databases. This means
|
||||
that when you make changes to the EF model, you need to create migrations for both databases.
|
||||
|
||||
1. Make migration for PostgreSQL database:
|
||||
```bash
|
||||
dotnet ef migrations add InitialMigration --context AliasServerDbContextPostgresql --output-dir Migrations/PostgresqlMigrations
|
||||
```
|
||||
|
||||
2. Make migration for SQLite database:
|
||||
```bash
|
||||
dotnet ef migrations add InitialMigration --context AliasServerDbContextSqlite --output-dir Migrations/SqliteMigrations
|
||||
```
|
||||
28
docs/misc/release/create-new-release.md
Normal file
28
docs/misc/release/create-new-release.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
layout: default
|
||||
title: Create a new release
|
||||
parent: Release
|
||||
grand_parent: Miscellaneous
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Release Preparation Checklist
|
||||
|
||||
Follow the steps in the checklist below to prepare a new release.
|
||||
|
||||
## Versioning
|
||||
- [ ] Update ./src/Shared/AliasVault.Shared.Core/AppInfo.cs and update major/minor/patch to the new version. This version will be shown in the client and admin app footer.
|
||||
- [ ] Update ./install.sh `@version` in header if the install script has changed. This allows the install script to self-update when running the `./install.sh update` command on default installations.
|
||||
|
||||
## Docker Images
|
||||
If docker containers have been added or removed:
|
||||
- [ ] Verify that `.github/workflows/publish-docker-images.yml` contains references to all docker images that need to be published.
|
||||
- [ ] Update `install.sh` and verify that the `images=()` array that takes care of pulling the images from the GitHub Container Registry is updated.
|
||||
|
||||
## Manual Testing (since v0.10.0+)
|
||||
- [ ] Verify that the db migration from SQLite to PostgreSQL works. This needs to be tested manually until the SQLite support is removed. Test with: `./install.sh db-migrate` on an existing installation that has a SQLite database in `./database/AliasServerDb.sqlite`.
|
||||
|
||||
## Documentation
|
||||
- [ ] Update /docs instructions if any changes have been made to the setup process
|
||||
- [ ] Update README screenshots if applicable
|
||||
- [ ] Update README current/upcoming features
|
||||
6
docs/misc/release/index.md
Normal file
6
docs/misc/release/index.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
layout: default
|
||||
title: Release
|
||||
parent: Miscellaneous
|
||||
nav_order: 1
|
||||
---
|
||||
943
install.sh
943
install.sh
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@
|
||||
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Auth\AliasVault.Auth.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj" />
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
var user = await UserManager.FindByNameAsync(Input.UserName);
|
||||
if (user == null)
|
||||
{
|
||||
|
||||
await AuthLoggingService.LogAuthEventFailAsync(Input.UserName, AuthEventType.Login, AuthFailureReason.InvalidUsername);
|
||||
ServerValidationErrors.AddError("Error: Invalid login attempt.");
|
||||
return;
|
||||
@@ -75,7 +74,7 @@
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(Input.UserName, AuthEventType.Login);
|
||||
Logger.LogInformation("User logged in.");
|
||||
NavigationService.RedirectTo(ReturnUrl ?? "/");
|
||||
NavigationService.RedirectTo(ReturnUrl ?? "./");
|
||||
}
|
||||
else if (result.RequiresTwoFactor)
|
||||
{
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
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
|
||||
// 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
|
||||
{
|
||||
await UserService.LoadCurrentUserAsync();
|
||||
var username = UserService.User().UserName;
|
||||
|
||||
try
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
@@ -20,12 +22,12 @@
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(username!, AuthEventType.Logout);
|
||||
|
||||
// Redirect to the home page with hard refresh.
|
||||
NavigationService.RedirectTo("/", true);
|
||||
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
|
||||
// the sign-out will fail because it tries to mutate cookies which is only possible when the server
|
||||
// session is not started yet.
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(username!, AuthEventType.Logout);
|
||||
NavigationService.RedirectTo(NavigationService.Uri, true);
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
WORKDIR /app
|
||||
EXPOSE 3002
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG TARGETARCH
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["src/AliasVault.Admin/AliasVault.Admin.csproj", "src/AliasVault.Admin/"]
|
||||
RUN dotnet restore "src/AliasVault.Admin/AliasVault.Admin.csproj"
|
||||
RUN dotnet restore "src/AliasVault.Admin/AliasVault.Admin.csproj" -a "$TARGETARCH"
|
||||
COPY . .
|
||||
|
||||
WORKDIR "/src/src/AliasVault.Admin"
|
||||
RUN dotnet publish "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
|
||||
RUN dotnet publish "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" \
|
||||
-a "$TARGETARCH" \
|
||||
-o /app/publish \
|
||||
/p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
@using AliasVault.WorkerStatus.Database
|
||||
@inherits MainBase
|
||||
|
||||
@foreach (var service in Services)
|
||||
{
|
||||
<button @onclick="() => ServiceClick(service.Name)"
|
||||
class="@GetServiceButtonClasses(service) mx-3 inline-flex items-center justify-center rounded-xl px-8 py-2 text-white"
|
||||
disabled="@(!service.IsHeartBeatValid)"
|
||||
title="@GetButtonTooltip(service)">
|
||||
<span>@service.DisplayName</span>
|
||||
@if (service.IsHeartBeatValid && service.CurrentStatus != service.DesiredStatus && !string.IsNullOrEmpty(service.DesiredStatus))
|
||||
{
|
||||
<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 {
|
||||
/// <summary>
|
||||
/// The names of the services to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<string> ServiceNames { get; set; } = ["AliasVault.SmtpService", "AliasVault.TaskRunner"];
|
||||
|
||||
/// <summary>
|
||||
/// The display names of the services to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Dictionary<string, string> ServiceDisplayNames { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The statuses of the services.
|
||||
/// </summary>
|
||||
private List<WorkerServiceStatus> ServiceStatus = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the page is initializing.
|
||||
/// </summary>
|
||||
private bool InitInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// The interval to refresh the page.
|
||||
/// </summary>
|
||||
private readonly int AutoRefreshInterval = 5000;
|
||||
private CancellationTokenSource? _timerCancellationTokenSource;
|
||||
|
||||
/// <summary>
|
||||
/// The state of a service.
|
||||
/// </summary>
|
||||
private sealed class ServiceState
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string CurrentStatus { get; set; } = "";
|
||||
public string DesiredStatus { get; set; } = "";
|
||||
public bool IsHeartBeatValid { get; set; }
|
||||
}
|
||||
|
||||
private List<ServiceState> Services { get; set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Services = ServiceNames.Select(name => new ServiceState
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = ServiceDisplayNames.GetValueOrDefault(name, name)
|
||||
}).ToList();
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
_timerCancellationTokenSource = new CancellationTokenSource();
|
||||
_ = RunPeriodicRefreshAsync(_timerCancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_timerCancellationTokenSource?.Cancel();
|
||||
_timerCancellationTokenSource?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the heartbeat is valid (within the last 5 minutes).
|
||||
/// </summary>
|
||||
private static bool IsHeartbeatValid(DateTime lastHeartbeat)
|
||||
{
|
||||
return DateTime.UtcNow <= lastHeartbeat.AddMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CSS classes for a service button based on its current state.
|
||||
/// </summary>
|
||||
private static string GetServiceButtonClasses(ServiceState service)
|
||||
{
|
||||
string buttonClass = "cursor-pointer ";
|
||||
|
||||
if (!service.IsHeartBeatValid)
|
||||
{
|
||||
buttonClass += "bg-gray-600";
|
||||
}
|
||||
else if (service.CurrentStatus == "Started" && (service.DesiredStatus == string.Empty || service.DesiredStatus == "Started"))
|
||||
{
|
||||
buttonClass += "bg-green-600";
|
||||
}
|
||||
else if (service.CurrentStatus == "Stopping" || (service.DesiredStatus == "Stopped" && service.CurrentStatus != service.DesiredStatus))
|
||||
{
|
||||
buttonClass += "bg-red-500";
|
||||
}
|
||||
else if (service.CurrentStatus == "Starting" || (service.DesiredStatus == "Started" && service.CurrentStatus != service.DesiredStatus))
|
||||
{
|
||||
buttonClass += "bg-emerald-500";
|
||||
}
|
||||
else if (service.DesiredStatus == "Stopped" && (service.DesiredStatus == string.Empty || service.DesiredStatus == "Stopped"))
|
||||
{
|
||||
buttonClass += "bg-red-600";
|
||||
}
|
||||
|
||||
return buttonClass;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tooltip text for a service button based on its last heartbeat.
|
||||
/// </summary>
|
||||
private static string GetButtonTooltip(ServiceState service)
|
||||
{
|
||||
if (!service.IsHeartBeatValid)
|
||||
{
|
||||
return "Heartbeat offline";
|
||||
}
|
||||
|
||||
var statusMessages = new Dictionary<string, string>
|
||||
{
|
||||
{ "Started", "Service is running" },
|
||||
{ "Starting", "Service is starting..." },
|
||||
{ "Stopped", "Service is stopped" },
|
||||
{ "Stopping", "Service is stopping..." }
|
||||
};
|
||||
|
||||
return statusMessages.GetValueOrDefault(service.CurrentStatus, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a click on a service button.
|
||||
/// </summary>
|
||||
private async Task ServiceClick(string serviceName)
|
||||
{
|
||||
var service = Services.First(s => s.Name == serviceName);
|
||||
|
||||
if (!service.IsHeartBeatValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If service not started and not starting, clicking should start it. Otherwise, stop it.
|
||||
if (service.CurrentStatus == "Started" || service.DesiredStatus == "Started")
|
||||
{
|
||||
service.DesiredStatus = "Stopped";
|
||||
}
|
||||
else
|
||||
{
|
||||
service.DesiredStatus = "Started";
|
||||
}
|
||||
StateHasChanged();
|
||||
|
||||
await UpdateServiceStatus(serviceName, service.DesiredStatus);
|
||||
service.CurrentStatus = service.DesiredStatus;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the page.
|
||||
/// </summary>
|
||||
private async Task InitPage()
|
||||
{
|
||||
if (InitInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InitInProgress = true;
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync();
|
||||
|
||||
foreach (var service in Services)
|
||||
{
|
||||
var entry = ServiceStatus.Find(x => x.ServiceName == service.Name);
|
||||
if (entry != null)
|
||||
{
|
||||
service.IsHeartBeatValid = IsHeartbeatValid(entry.Heartbeat);
|
||||
service.CurrentStatus = entry.CurrentStatus;
|
||||
service.DesiredStatus = entry.DesiredStatus;
|
||||
}
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InitInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a service.
|
||||
/// </summary>
|
||||
private async Task<bool> UpdateServiceStatus(string serviceName, string desiredStatus)
|
||||
{
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var entry = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync();
|
||||
if (entry != null)
|
||||
{
|
||||
entry.DesiredStatus = desiredStatus;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var timeout = DateTime.UtcNow.AddSeconds(30);
|
||||
while (true)
|
||||
{
|
||||
if (DateTime.UtcNow > timeout)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await using var dbContextInner = await DbContextFactory.CreateDbContextAsync();
|
||||
var check = await dbContextInner.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync();
|
||||
if (check.CurrentStatus == entry.DesiredStatus)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the service status periodically.
|
||||
/// </summary>
|
||||
private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await InitPage();
|
||||
await Task.Delay(AutoRefreshInterval, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
@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"
|
||||
disabled="@(!IsHeartbeatValid())"
|
||||
title="@GetButtonTooltip()">
|
||||
<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 SmtpStatus;
|
||||
private bool SmtpPending;
|
||||
private DateTime LastHeartbeat;
|
||||
|
||||
/// <summary>
|
||||
/// The interval in milliseconds for refreshing the service status.
|
||||
/// </summary>
|
||||
private readonly int AutoRefreshInterval = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// CancellationTokenSource for the timer.
|
||||
/// </summary>
|
||||
private CancellationTokenSource? _timerCancellationTokenSource;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
_timerCancellationTokenSource = new CancellationTokenSource();
|
||||
_ = RunPeriodicRefreshAsync(_timerCancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the service status periodically while waiting for specified amount of ms in between.
|
||||
/// </summary>
|
||||
private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await InitPage();
|
||||
await Task.Delay(AutoRefreshInterval, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_timerCancellationTokenSource?.Cancel();
|
||||
_timerCancellationTokenSource?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CSS classes for the SMTP button based on its current state.
|
||||
/// </summary>
|
||||
/// <returns>A string containing the CSS classes for the button.</returns>
|
||||
private string GetSmtpButtonClasses()
|
||||
{
|
||||
string buttonClass = "cursor-pointer ";
|
||||
|
||||
if (!IsHeartbeatValid())
|
||||
{
|
||||
buttonClass += "bg-gray-600";
|
||||
}
|
||||
else if (SmtpStatus)
|
||||
{
|
||||
buttonClass += "bg-green-600";
|
||||
}
|
||||
else
|
||||
{
|
||||
buttonClass += "bg-red-600";
|
||||
}
|
||||
|
||||
return buttonClass;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tooltip text for the SMTP button.
|
||||
/// </summary>
|
||||
/// <returns>A string containing the tooltip text.</returns>
|
||||
private string GetButtonTooltip()
|
||||
{
|
||||
return IsHeartbeatValid() ? "" : "Heartbeat offline";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the heartbeat is valid (within the last 5 minutes).
|
||||
/// </summary>
|
||||
/// <returns>True if the heartbeat is valid, false otherwise.</returns>
|
||||
private bool IsHeartbeatValid()
|
||||
{
|
||||
return DateTime.Now <= LastHeartbeat.AddMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the click event for the SMTP button.
|
||||
/// </summary>
|
||||
private async void SmtpClick()
|
||||
{
|
||||
if (!IsHeartbeatValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SmtpPending = true;
|
||||
StateHasChanged();
|
||||
|
||||
SmtpStatus = !SmtpStatus;
|
||||
await UpdateSmtpStatus(SmtpStatus);
|
||||
|
||||
SmtpPending = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the page by fetching service statuses and updating the SMTP status.
|
||||
/// </summary>
|
||||
private async Task InitPage()
|
||||
{
|
||||
if (InitInProgress || SmtpPending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InitInProgress = true;
|
||||
var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync();
|
||||
|
||||
var smtpEntry = ServiceStatus.Find(x => x.ServiceName == "AliasVault.SmtpService");
|
||||
if (smtpEntry != null)
|
||||
{
|
||||
LastHeartbeat = smtpEntry.Heartbeat;
|
||||
SmtpStatus = IsHeartbeatValid() && smtpEntry.CurrentStatus == "Started";
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InitInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the service statuses.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdateServiceStatus(string serviceName, bool newStatus)
|
||||
{
|
||||
// Refresh the DbContext to ensure we get the latest data.
|
||||
var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var entry = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync();
|
||||
if (entry != null)
|
||||
{
|
||||
string newDesiredStatus = newStatus ? "Started" : "Stopped";
|
||||
entry.DesiredStatus = newDesiredStatus;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Wait for service to have updated its status.
|
||||
var timeout = DateTime.Now.AddSeconds(30);
|
||||
while (true)
|
||||
{
|
||||
if (DateTime.Now > timeout)
|
||||
{
|
||||
// Timeout
|
||||
return false;
|
||||
}
|
||||
|
||||
dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var check = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync();
|
||||
if (check.CurrentStatus == newDesiredStatus)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,20 @@
|
||||
<NavLink href="logging/auth" 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">
|
||||
Auth logs
|
||||
</NavLink>
|
||||
<NavLink href="settings/server" 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">
|
||||
Server settings
|
||||
</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">
|
||||
<ServiceControl ServiceNames="@(new List<string> { "AliasVault.SmtpService", "AliasVault.TaskRunner" })"
|
||||
ServiceDisplayNames="@(new Dictionary<string, string>
|
||||
{
|
||||
{ "AliasVault.SmtpService", "Smtp" },
|
||||
{ "AliasVault.TaskRunner", "Tasks" }
|
||||
})" />
|
||||
<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>
|
||||
@@ -52,7 +60,7 @@
|
||||
</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>
|
||||
<a href="account/manage/change-password" 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">
|
||||
@@ -99,6 +107,11 @@
|
||||
Auth logs
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="settings/server" 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">
|
||||
Server settings
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
49
src/AliasVault.Admin/Main/Models/UserEmailClaimWithCount.cs
Normal file
49
src/AliasVault.Admin/Main/Models/UserEmailClaimWithCount.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="UserEmailClaimWithCount.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>
|
||||
/// User email claim view model with count.
|
||||
/// </summary>
|
||||
public class UserEmailClaimWithCount
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the id.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the address.
|
||||
/// </summary>
|
||||
public string Address { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the address local.
|
||||
/// </summary>
|
||||
public string AddressLocal { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the address domain.
|
||||
/// </summary>
|
||||
public string AddressDomain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the created at timestamp.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the updated at timestamp.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the email count.
|
||||
/// </summary>
|
||||
public int EmailCount { get; set; }
|
||||
}
|
||||
@@ -32,6 +32,11 @@ public class UserViewModel
|
||||
/// </summary>
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user is blocked.
|
||||
/// </summary>
|
||||
public bool Blocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vault count.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
@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">
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<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/>
|
||||
@@ -41,15 +40,13 @@
|
||||
|
||||
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);
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
// Clear the password fields
|
||||
Input.OldPassword = "";
|
||||
Input.NewPassword = "";
|
||||
Input.ConfirmPassword = "";
|
||||
var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
|
||||
|
||||
if (!changePasswordResult.Succeeded)
|
||||
{
|
||||
@@ -57,10 +54,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
user.LastPasswordChanged = DateTime.UtcNow;
|
||||
await UserManager.UpdateAsync(user);
|
||||
|
||||
Input.OldPassword = "";
|
||||
Input.NewPassword = "";
|
||||
Input.ConfirmPassword = "";
|
||||
|
||||
Logger.LogInformation("User changed their password successfully.");
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your password has been changed.");
|
||||
|
||||
NavigationService.RedirectToCurrentPage();
|
||||
}
|
||||
|
||||
@@ -82,5 +84,4 @@
|
||||
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,7 +31,13 @@
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!await UserManager.GetTwoFactorEnabledAsync(UserService.User()))
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
if (!await UserManager.GetTwoFactorEnabledAsync(user))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
|
||||
}
|
||||
@@ -39,7 +45,13 @@
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false);
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(user, false);
|
||||
if (!disable2FaResult.Succeeded)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(UserService.User().UserName!, AuthEventType.TwoFactorAuthDisable, AuthFailureReason.Unknown);
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
|
||||
<LayoutPageTitle>Configure authenticator app</LayoutPageTitle>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
return;
|
||||
}
|
||||
|
||||
@if (RecoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="RecoveryCodes.ToArray()"/>
|
||||
@@ -69,15 +75,20 @@ else
|
||||
private string? SharedKey { get; set; }
|
||||
private string? AuthenticatorUri { get; set; }
|
||||
private IEnumerable<string>? RecoveryCodes { get; set; }
|
||||
private bool _isLoading = true;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
/// <inheritdoc/>
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
await LoadSharedKeyAndQrCodeUriAsync(UserService.User());
|
||||
await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri");
|
||||
if (firstRender)
|
||||
{
|
||||
await LoadSharedKeyAndQrCodeUriAsync();
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
@@ -85,8 +96,13 @@ else
|
||||
// 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);
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
var is2FaTokenValid = await UserManager.VerifyTwoFactorTokenAsync(user, UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
|
||||
|
||||
if (!is2FaTokenValid)
|
||||
{
|
||||
@@ -94,25 +110,31 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
await UserManager.SetTwoFactorEnabledAsync(UserService.User(), true);
|
||||
await UserManager.SetTwoFactorEnabledAsync(user, true);
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(UserService.User().UserName!, AuthEventType.TwoFactorAuthEnable);
|
||||
Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", UserService.User().Id);
|
||||
GlobalNotificationService.AddSuccessMessage("Your authenticator app has been verified.");
|
||||
|
||||
if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0)
|
||||
if (await UserManager.CountRecoveryCodesAsync(user) == 0)
|
||||
{
|
||||
RecoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
|
||||
RecoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Navigate back to the two factor authentication page.
|
||||
// Navigate back to the two-factor authentication page.
|
||||
NavigationService.RedirectTo("account/manage/2fa", forceLoad: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask LoadSharedKeyAndQrCodeUriAsync(AdminUser user)
|
||||
private async ValueTask LoadSharedKeyAndQrCodeUriAsync()
|
||||
{
|
||||
// Load the authenticator key & QR code URI to display on the form
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
// Load the authenticator key & QR code URI to display on the form.
|
||||
var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
if (string.IsNullOrEmpty(unformattedKey))
|
||||
{
|
||||
@@ -126,7 +148,7 @@ else
|
||||
AuthenticatorUri = GenerateQrCodeUri(username!, unformattedKey!);
|
||||
}
|
||||
|
||||
private string FormatKey(string unformattedKey)
|
||||
private static string FormatKey(string unformattedKey)
|
||||
{
|
||||
var result = new StringBuilder();
|
||||
int currentPosition = 0;
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
<LayoutPageTitle>Generate two-factor authentication (2FA) recovery codes</LayoutPageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
@if (_recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
|
||||
<ShowRecoveryCodes RecoveryCodes="_recoveryCodes.ToArray()"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -35,14 +35,20 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
private IEnumerable<string>? _recoveryCodes;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User());
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
|
||||
if (!isTwoFactorEnabled)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled.");
|
||||
@@ -51,11 +57,16 @@ else
|
||||
|
||||
private async Task GenerateCodes()
|
||||
{
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
_recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||
GlobalNotificationService.AddSuccessMessage("You have generated new recovery codes.");
|
||||
|
||||
Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
|
||||
Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", UserService.User().Id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
@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>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</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; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,15 +30,19 @@
|
||||
@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);
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
await UserManager.SetTwoFactorEnabledAsync(user, false);
|
||||
await UserManager.ResetAuthenticatorKeyAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", UserService.User().Id);
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key.");
|
||||
|
||||
NavigationService.RedirectTo(
|
||||
"account/manage/2fa");
|
||||
NavigationService.RedirectTo("account/manage/2fa");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
@page "/account/manage/2fa"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject SignInManager<AdminUser> SignInManager
|
||||
|
||||
<LayoutPageTitle>Two-factor authentication (2FA)</LayoutPageTitle>
|
||||
|
||||
@if (is2FaEnabled)
|
||||
@if (_is2FaEnabled)
|
||||
{
|
||||
<div class="mx-auto mt-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Two-factor authentication (2FA)</h3>
|
||||
|
||||
@if (recoveryCodesLeft == 0)
|
||||
@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)
|
||||
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)
|
||||
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 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>
|
||||
}
|
||||
@@ -41,10 +39,10 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<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)
|
||||
@if (!_hasAuthenticator)
|
||||
{
|
||||
<LinkButton Href="account/manage/enable-authenticator" Color="primary" Text="Add authenticator app" />
|
||||
}
|
||||
@@ -57,17 +55,23 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool hasAuthenticator;
|
||||
private int recoveryCodesLeft;
|
||||
private bool is2FaEnabled;
|
||||
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());
|
||||
var user = await UserManager.FindByIdAsync(UserService.User().Id);
|
||||
if (user == null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
_hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null;
|
||||
_is2FaEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
|
||||
_recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Manage account"
|
||||
Description="Manage your profile here.">
|
||||
Description="Manage security settings for the admin account here.">
|
||||
</PageHeader>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<hr class="mb-6 border-t border-gray-300"/>
|
||||
<div class="mx-auto px-4 py-8">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="w-full md:w-1/4 mb-6 md:mb-0">
|
||||
<ManageNavMenu/>
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
|
||||
<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 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Profile</NavLink>
|
||||
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">Password</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white 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 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Two-factor authentication</NavLink>
|
||||
<NavLink href="account/manage/2fa" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">Two-factor authentication</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active users</h3>
|
||||
<button
|
||||
@onclick="ToggleUserNames"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
@(ShowUserNames ? "Hide names" : "Show names")
|
||||
</button>
|
||||
</div>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last24Hours</h4>
|
||||
@if (ShowUserNames)
|
||||
{
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<ul>
|
||||
@foreach (var user in UserStats.Last24HourUsers)
|
||||
{
|
||||
<li>@user</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last7Days</h4>
|
||||
@if (ShowUserNames)
|
||||
{
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<ul>
|
||||
@foreach (var user in UserStats.Last7DayUsers)
|
||||
{
|
||||
<li>@user</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last14Days</h4>
|
||||
@if (ShowUserNames)
|
||||
{
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<ul>
|
||||
@foreach (var user in UserStats.Last14DayUsers)
|
||||
{
|
||||
<li>@user</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private UserStatistics UserStats { get; set; } = new();
|
||||
private bool ShowUserNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the data displayed on the card.
|
||||
/// </summary>
|
||||
public async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var last24Hours = now.AddHours(-24);
|
||||
var last7Days = now.AddDays(-7);
|
||||
var last14Days = now.AddDays(-14);
|
||||
|
||||
// Get user statistics
|
||||
var (count24h, users24h) = await GetActiveUserCount(last24Hours);
|
||||
var (count7d, users7d) = await GetActiveUserCount(last7Days);
|
||||
var (count14d, users14d) = await GetActiveUserCount(last14Days);
|
||||
|
||||
UserStats = new UserStatistics
|
||||
{
|
||||
Last24Hours = count24h,
|
||||
Last7Days = count7d,
|
||||
Last14Days = count14d,
|
||||
Last24HourUsers = users24h,
|
||||
Last7DayUsers = users7d,
|
||||
Last14DayUsers = users14d
|
||||
};
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task<(int count, List<string> users)> GetActiveUserCount(DateTime since)
|
||||
{
|
||||
// Get unique users who either:
|
||||
// 1. Have successful auth logs
|
||||
// 2. Have updated their vault
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var activeUsers = await dbContext.AuthLogs
|
||||
.Where(l => l.Timestamp >= since && l.IsSuccess)
|
||||
.Select(l => l.Username)
|
||||
.Union(
|
||||
dbContext.Vaults
|
||||
.Where(v => v.UpdatedAt >= since)
|
||||
.Select(v => v.User.UserName!)
|
||||
)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return (activeUsers.Count, activeUsers);
|
||||
}
|
||||
|
||||
private void ToggleUserNames()
|
||||
{
|
||||
ShowUserNames = !ShowUserNames;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private sealed class UserStatistics
|
||||
{
|
||||
public int Last24Hours { get; set; }
|
||||
public int Last7Days { get; set; }
|
||||
public int Last14Days { get; set; }
|
||||
public List<string> Last24HourUsers { get; set; } = new();
|
||||
public List<string> Last7DayUsers { get; set; } = new();
|
||||
public List<string> Last14DayUsers { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent emails received</h3>
|
||||
</div>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Last24Hours</h4>
|
||||
</div>
|
||||
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Last7Days</h4>
|
||||
</div>
|
||||
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Last14Days</h4>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private EmailStatistics EmailStats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the data displayed on the card.
|
||||
/// </summary>
|
||||
public async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var last24Hours = now.AddHours(-24);
|
||||
var last7Days = now.AddDays(-7);
|
||||
var last14Days = now.AddDays(-14);
|
||||
|
||||
// Get email statistics
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var emailQuery = dbContext.Emails.AsQueryable();
|
||||
EmailStats = new EmailStatistics
|
||||
{
|
||||
Last24Hours = await emailQuery.CountAsync(e => e.DateSystem >= last24Hours),
|
||||
Last7Days = await emailQuery.CountAsync(e => e.DateSystem >= last7Days),
|
||||
Last14Days = await emailQuery.CountAsync(e => e.DateSystem >= last14Days)
|
||||
};
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private sealed class EmailStatistics
|
||||
{
|
||||
public int Last24Hours { get; set; }
|
||||
public int Last7Days { get; set; }
|
||||
public int Last14Days { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">User registrations</h3>
|
||||
</div>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Last24Hours</h4>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Last7Days</h4>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
|
||||
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Last14Days</h4>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private RegistrationStatistics RegistrationStats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the data displayed on the card.
|
||||
/// </summary>
|
||||
public async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var last24Hours = now.AddHours(-24);
|
||||
var last7Days = now.AddDays(-7);
|
||||
var last14Days = now.AddDays(-14);
|
||||
|
||||
// Get registration statistics
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var registrationQuery = dbContext.AliasVaultUsers.AsQueryable();
|
||||
RegistrationStats = new RegistrationStatistics
|
||||
{
|
||||
Last24Hours = await registrationQuery.CountAsync(u => u.CreatedAt >= last24Hours),
|
||||
Last7Days = await registrationQuery.CountAsync(u => u.CreatedAt >= last7Days),
|
||||
Last14Days = await registrationQuery.CountAsync(u => u.CreatedAt >= last14Days)
|
||||
};
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private sealed class RegistrationStatistics
|
||||
{
|
||||
public int Last24Hours { get; set; }
|
||||
public int Last7Days { get; set; }
|
||||
public int Last14Days { get; set; }
|
||||
}
|
||||
}
|
||||
66
src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor
Normal file
66
src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor
Normal file
@@ -0,0 +1,66 @@
|
||||
@page "/"
|
||||
@using AliasVault.Admin.Main.Pages.Dashboard.Components
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Home</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="AliasVault Admin"
|
||||
Description="Welcome to the AliasVault admin portal. Below you can find statistics about recent email activity and active users.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
<div class="px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<ActiveUsersCard @ref="_activeUsersCard" />
|
||||
<RegistrationStatisticsCard @ref="_registrationStatisticsCard" />
|
||||
<EmailStatisticsCard @ref="_emailStatisticsCard" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ActiveUsersCard? _activeUsersCard;
|
||||
private RegistrationStatisticsCard? _registrationStatisticsCard;
|
||||
private EmailStatisticsCard? _emailStatisticsCard;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
// Check if 2FA is enabled. If not, show a one-time warning on the dashboard.
|
||||
if (!UserService.User().TwoFactorEnabled)
|
||||
{
|
||||
GlobalNotificationService.AddWarningMessage("Two-factor authentication is not enabled. It is recommended to enable it in Account Settings for better security.", true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the data displayed on the cards.
|
||||
/// </summary>
|
||||
private async Task RefreshData()
|
||||
{
|
||||
if (_activeUsersCard != null &&
|
||||
_registrationStatisticsCard != null &&
|
||||
_emailStatisticsCard != null)
|
||||
{
|
||||
await Task.WhenAll(
|
||||
_activeUsersCard.RefreshData(),
|
||||
_registrationStatisticsCard.RefreshData(),
|
||||
_emailStatisticsCard.RefreshData()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@ else
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
IQueryable<Email> query = DbContext.Emails;
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
IQueryable<Email> query = dbContext.Emails;
|
||||
|
||||
// Apply sort
|
||||
switch (SortColumn)
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
|
||||
<LayoutPageTitle>Error</LayoutPageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
<h1 class="text-danger dark:text-red-400">Error.</h1>
|
||||
<h2 class="text-danger dark:text-red-400">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
<strong>Request ID:</strong> <code class="dark:bg-gray-700 dark:text-gray-200">@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
<h3 class="dark:text-white">Development Mode</h3>
|
||||
<p class="dark:text-gray-300">
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<p class="dark:text-gray-300">
|
||||
<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>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
@page "/"
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Home</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="AliasVault Admin"
|
||||
Description="Welcome to the AliasVault admin portal.">
|
||||
</PageHeader>
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
|
||||
// Redirect to users page.
|
||||
NavigationService.RedirectTo("users");
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ else
|
||||
<SortableTableColumn>@log.Timestamp.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@log.Username</SortableTableColumn>
|
||||
<SortableTableColumn>@log.EventType</SortableTableColumn>
|
||||
<SortableTableColumn><StatusPill Enabled="log.IsSuccess" TextTrue="Success" TextFalse="Failed" /></SortableTableColumn>
|
||||
<SortableTableColumn><StatusPill Enabled="log.IsSuccess" TextTrue="Success" TextFalse="@log.FailureReason.ToString()" /></SortableTableColumn>
|
||||
<SortableTableColumn>@log.IpAddress</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
@@ -128,10 +128,8 @@ else
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var query = DbContext.AuthLogs.AsQueryable();
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var query = dbContext.AuthLogs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
@@ -215,8 +213,9 @@ else
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
DbContext.AuthLogs.RemoveRange(DbContext.AuthLogs);
|
||||
await DbContext.SaveChangesAsync();
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
dbContext.AuthLogs.RemoveRange(dbContext.AuthLogs);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
|
||||
IsLoading = false;
|
||||
|
||||
@@ -135,7 +135,8 @@ else
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
ServiceNames = await DbContext.Logs.Select(l => l.Application).Distinct().ToListAsync();
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
ServiceNames = await dbContext.Logs.Select(l => l.Application).Distinct().ToListAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
@@ -148,10 +149,8 @@ else
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var query = DbContext.Logs.AsQueryable();
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var query = dbContext.Logs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
@@ -218,8 +217,9 @@ else
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
DbContext.Logs.RemoveRange(DbContext.Logs);
|
||||
await DbContext.SaveChangesAsync();
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
dbContext.Logs.RemoveRange(dbContext.Logs);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
|
||||
IsLoading = false;
|
||||
|
||||
@@ -23,7 +23,7 @@ using Microsoft.JSInterop;
|
||||
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class MainBase : OwningComponentBase
|
||||
public abstract class MainBase : OwningComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the NavigationService instance responsible for handling navigation, replaces the default NavigationManager.
|
||||
@@ -50,16 +50,10 @@ public class MainBase : OwningComponentBase
|
||||
protected JsInvokeService JsInvokeService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the AliasServerDbContext instance.
|
||||
/// Gets or sets the IAliasServerDbContextFactory 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!;
|
||||
protected IAliasServerDbContextFactory DbContextFactory { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the GlobalLoadingService in order to manipulate the global loading spinner animation.
|
||||
@@ -102,18 +96,6 @@ public class MainBase : OwningComponentBase
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationService.BaseUri });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
// Check if 2FA is enabled. If not, show a persistent notification.
|
||||
if (!UserService.User().TwoFactorEnabled)
|
||||
{
|
||||
GlobalNotificationService.AddWarningMessage("Two-factor authentication is not enabled. Please enable it in Account Settings for better security.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the username from the authentication state asynchronously.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
@inherits MainBase
|
||||
|
||||
<div class="mb-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var job in JobList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@job.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@job.RunDate.ToString("yyyy-MM-dd")</SortableTableColumn>
|
||||
<SortableTableColumn>@job.StartTime.ToString("HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@(job.EndTime?.ToString("HH:mm") ?? "-")</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@{
|
||||
string bgColor = job.Status switch
|
||||
{
|
||||
TaskRunnerJobStatus.Pending => "bg-yellow-500",
|
||||
TaskRunnerJobStatus.Running => "bg-blue-500",
|
||||
TaskRunnerJobStatus.Finished => "bg-green-500",
|
||||
TaskRunnerJobStatus.Error => "bg-red-500",
|
||||
_ => "bg-gray-500"
|
||||
};
|
||||
}
|
||||
<span class="px-2 py-1 rounded-full text-white @bgColor">
|
||||
@job.Status
|
||||
</span>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>@(job.IsOnDemand ? "Yes" : "No")</SortableTableColumn>
|
||||
<SortableTableColumn Title="@job.ErrorMessage">
|
||||
@if (!string.IsNullOrEmpty(job.ErrorMessage))
|
||||
{
|
||||
<span class="text-red-600 dark:text-red-400">@(job.ErrorMessage.Length > 50 ? job.ErrorMessage[..50] + "..." : job.ErrorMessage)</span>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private readonly List<TableColumn> _tableColumns =
|
||||
[
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Date", PropertyName = "RunDate" },
|
||||
new TableColumn { Title = "Start", PropertyName = "StartTime" },
|
||||
new TableColumn { Title = "End", PropertyName = "EndTime" },
|
||||
new TableColumn { Title = "Status", PropertyName = "Status" },
|
||||
new TableColumn { Title = "On-Demand", PropertyName = "IsOnDemand" },
|
||||
new TableColumn { Title = "Error", PropertyName = "ErrorMessage" },
|
||||
];
|
||||
|
||||
private List<TaskRunnerJob> JobList { get; set; } = [];
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 5;
|
||||
private int TotalRecords { get; set; }
|
||||
private string SortColumn { get; set; } = "Id";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the data displayed in the table.
|
||||
/// </summary>
|
||||
public async Task RefreshData()
|
||||
{
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var query = dbContext.TaskRunnerJobs.AsQueryable();
|
||||
|
||||
// Apply sorting
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => EF.Property<object>(x, SortColumn))
|
||||
: query.OrderByDescending(x => EF.Property<object>(x, SortColumn));
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
JobList = await query
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
182
src/AliasVault.Admin/Main/Pages/Settings/Server.razor
Normal file
182
src/AliasVault.Admin/Main/Pages/Settings/Server.razor
Normal file
@@ -0,0 +1,182 @@
|
||||
@page "/settings/server"
|
||||
@inject ServerSettingsService SettingsService
|
||||
@inject ILogger<ServerSettingsService> Logger
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
@using AliasVault.Shared.Server.Models
|
||||
@using AliasVault.Shared.Server.Services
|
||||
@using AliasVault.Admin.Main.Pages.Settings.Components
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Server settings</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Server settings"
|
||||
Description="Configure AliasVault server settings.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
<ConfirmButton OnClick="SaveSettings">Save changes</ConfirmButton>
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
<div class="px-4">
|
||||
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Authentication Settings</h3>
|
||||
<div class="grid gap-4 mb-4 sm:grid-cols-2 sm:gap-6 sm:mb-5">
|
||||
<div>
|
||||
<label for="refreshTokenShort" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Short Refresh Token Lifetime (hours)</label>
|
||||
<input type="number" @bind="Settings.RefreshTokenLifetimeShort" id="refreshTokenShort" class="bg-gray-50 border border-gray-300 text-gray-900 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-primary-500 dark:focus:border-primary-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Determines how long the user stays logged in after inactivity. Used when "Remember me" is not checked during login.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="refreshTokenLong" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Long Refresh Token Lifetime (hours)</label>
|
||||
<input type="number" @bind="Settings.RefreshTokenLifetimeLong" id="refreshTokenLong" class="bg-gray-50 border border-gray-300 text-gray-900 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-primary-500 dark:focus:border-primary-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Determines how long the user stays logged in after inactivity. Used when "Remember me" is checked during login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Data Retention</h3>
|
||||
<div class="grid gap-4 mb-4 sm:grid-cols-2 sm:gap-6 sm:mb-5">
|
||||
<div>
|
||||
<label for="generalLogRetention" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">General Log Retention (days)</label>
|
||||
<input type="number" @bind="Settings.GeneralLogRetentionDays" id="generalLogRetention" class="bg-gray-50 border border-gray-300 text-gray-900 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-primary-500 dark:focus:border-primary-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 to disable automatic cleanup</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="authLogRetention" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Auth Log Retention (days)</label>
|
||||
<input type="number" @bind="Settings.AuthLogRetentionDays" id="authLogRetention" class="bg-gray-50 border border-gray-300 text-gray-900 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-primary-500 dark:focus:border-primary-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 to disable automatic cleanup</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="emailRetention" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email Retention (days)</label>
|
||||
<input type="number" @bind="Settings.EmailRetentionDays" id="emailRetention" class="bg-gray-50 border border-gray-300 text-gray-900 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-primary-500 dark:focus:border-primary-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 to disable automatic cleanup</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="maxEmails" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Max Emails per User</label>
|
||||
<input type="number" @bind="Settings.MaxEmailsPerUser" id="maxEmails" class="bg-gray-50 border border-gray-300 text-gray-900 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-primary-500 dark:focus:border-primary-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 for unlimited emails</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Maintenance Schedule</h3>
|
||||
<div class="mb-4">
|
||||
<label for="schedule" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Time (24h format)</label>
|
||||
<input type="time" @bind="Settings.MaintenanceTime" id="schedule" class="bg-gray-50 border border-gray-300 text-gray-900 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-primary-500 dark:focus:border-primary-500">
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Time when maintenance tasks are run</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Run on Days</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
@foreach (var day in DaysOfWeek)
|
||||
{
|
||||
var isSelected = Settings.TaskRunnerDays.Contains(day.Key);
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" checked="@isSelected" @onchange="@(e => ToggleDay(day.Key))" id="@($"day_{day.Key}")" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="@($"day_{day.Key}")" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">@day.Value</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-2 text-md font-medium text-gray-900 dark:text-white">Manual Execution</h4>
|
||||
<ConfirmButton OnClick="RunMaintenanceTasksNow">Run Maintenance Tasks Now</ConfirmButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Maintenance History</h3>
|
||||
<TaskRunnerHistory @ref="_taskRunnerHistoryComponent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private TaskRunnerHistory? _taskRunnerHistoryComponent;
|
||||
|
||||
private ServerSettingsModel Settings { get; set; } = new();
|
||||
private readonly Dictionary<int, string> DaysOfWeek = new()
|
||||
{
|
||||
{ 1, "Monday" },
|
||||
{ 2, "Tuesday" },
|
||||
{ 3, "Wednesday" },
|
||||
{ 4, "Thursday" },
|
||||
{ 5, "Friday" },
|
||||
{ 6, "Saturday" },
|
||||
{ 7, "Sunday" }
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Settings = await SettingsService.GetAllSettingsAsync();
|
||||
}
|
||||
|
||||
private void ToggleDay(int day)
|
||||
{
|
||||
if (Settings.TaskRunnerDays.Contains(day))
|
||||
{
|
||||
Settings.TaskRunnerDays.Remove(day);
|
||||
}
|
||||
else
|
||||
{
|
||||
Settings.TaskRunnerDays.Add(day);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveSettings()
|
||||
{
|
||||
await SettingsService.SaveSettingsAsync(Settings);
|
||||
GlobalNotificationService.AddSuccessMessage("Settings saved successfully", true);
|
||||
}
|
||||
|
||||
private async Task RunMaintenanceTasksNow()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var job = new TaskRunnerJob
|
||||
{
|
||||
Name = nameof(TaskRunnerJobType.Maintenance),
|
||||
RunDate = DateTime.UtcNow.Date,
|
||||
StartTime = TimeOnly.FromDateTime(DateTime.UtcNow),
|
||||
Status = TaskRunnerJobStatus.Pending,
|
||||
IsOnDemand = true
|
||||
};
|
||||
|
||||
dbContext.TaskRunnerJobs.Add(job);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Refresh the history component to show the new job
|
||||
if (_taskRunnerHistoryComponent != null)
|
||||
{
|
||||
await _taskRunnerHistoryComponent.RefreshData();
|
||||
}
|
||||
|
||||
Logger.LogWarning("Maintenance tasks manually queued.");
|
||||
GlobalNotificationService.AddSuccessMessage("Maintenance tasks queued. They will be executed on the next polling cycle (default every minute). Check the logs for details.", true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage($"Failed to start maintenance tasks: {ex}", true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the data displayed on the page.
|
||||
/// </summary>
|
||||
private async Task RefreshData()
|
||||
{
|
||||
Settings = await SettingsService.GetAllSettingsAsync();
|
||||
|
||||
// Refresh the history component to show the new job
|
||||
if (_taskRunnerHistoryComponent != null)
|
||||
{
|
||||
await _taskRunnerHistoryComponent.RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ else
|
||||
if (firstRender)
|
||||
{
|
||||
// Load existing Obj.
|
||||
Obj = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
Obj = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
// Hide loading spinner
|
||||
IsLoading = false;
|
||||
@@ -83,8 +84,9 @@ else
|
||||
// Add log entry.
|
||||
Logger.LogWarning("Deleted user {UserName} ({UserId}).", Obj.UserName, Obj.Id);
|
||||
|
||||
DbContext.AliasVaultUsers.Remove(Obj);
|
||||
await DbContext.SaveChangesAsync();
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
dbContext.AliasVaultUsers.Remove(Obj);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("User successfully deleted.");
|
||||
GlobalLoadingSpinner.Hide();
|
||||
|
||||
@@ -30,14 +30,19 @@ else
|
||||
@foreach (var user in UserList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@user.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@user.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn IsPrimary="true">@user.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@user.UserName</SortableTableColumn>
|
||||
<SortableTableColumn>@user.VaultCount</SortableTableColumn>
|
||||
<SortableTableColumn>@user.EmailClaimCount</SortableTableColumn>
|
||||
<SortableTableColumn>@Math.Round((double)user.VaultStorageInKb / 1024, 1) MB</SortableTableColumn>
|
||||
<SortableTableColumn><StatusPill Enabled="user.TwoFactorEnabled" /></SortableTableColumn>
|
||||
<SortableTableColumn>@user.LastVaultUpdate.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (user.Blocked)
|
||||
{
|
||||
<StatusPill Enabled="false" TextFalse="Blocked" />
|
||||
}
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<LinkButton Color="primary" Href="@($"users/{user.Id}")" Text="View" />
|
||||
</SortableTableColumn>
|
||||
@@ -49,7 +54,6 @@ else
|
||||
|
||||
@code {
|
||||
private readonly List<TableColumn> _tableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Registered", PropertyName = "CreatedAt" },
|
||||
new TableColumn { Title = "Username", PropertyName = "UserName" },
|
||||
new TableColumn { Title = "# Vaults", PropertyName = "VaultCount" },
|
||||
@@ -57,6 +61,7 @@ else
|
||||
new TableColumn { Title = "Storage", PropertyName = "VaultStorageInKb" },
|
||||
new TableColumn { Title = "2FA", PropertyName = "TwoFactorEnabled" },
|
||||
new TableColumn { Title = "LastVaultUpdate", PropertyName = "LastVaultUpdate" },
|
||||
new TableColumn { Title = "Status", Sortable = false },
|
||||
new TableColumn { Title = "Actions", Sortable = false},
|
||||
];
|
||||
|
||||
@@ -107,10 +112,8 @@ else
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
IQueryable<AliasVaultUser> query = DbContext.AliasVaultUsers;
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
IQueryable<AliasVaultUser> query = dbContext.AliasVaultUsers;
|
||||
|
||||
if (SearchTerm.Length > 0)
|
||||
{
|
||||
@@ -130,6 +133,7 @@ else
|
||||
u.UserName,
|
||||
u.CreatedAt,
|
||||
u.TwoFactorEnabled,
|
||||
u.Blocked,
|
||||
Vaults = u.Vaults.Select(v => new
|
||||
{
|
||||
v.FileSize,
|
||||
@@ -147,6 +151,7 @@ else
|
||||
Id = user.Id,
|
||||
UserName = user.UserName?.ToLower() ?? "N/A",
|
||||
TwoFactorEnabled = user.TwoFactorEnabled,
|
||||
Blocked = user.Blocked,
|
||||
CreatedAt = user.CreatedAt,
|
||||
VaultCount = user.Vaults.Count(),
|
||||
EmailClaimCount = user.EmailClaims.Count(),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.Address</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.EmailCount</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
@@ -16,7 +17,7 @@
|
||||
/// Gets or sets the list of email claims to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<UserEmailClaim> EmailClaimList { get; set; } = [];
|
||||
public List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
|
||||
|
||||
private string SortColumn { get; set; } = "CreatedAt";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
@@ -25,9 +26,10 @@
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Created", PropertyName = "CreatedAt" },
|
||||
new TableColumn { Title = "Email", PropertyName = "Address" },
|
||||
new TableColumn { Title = "Email Count", PropertyName = "EmailCount" },
|
||||
];
|
||||
|
||||
private IEnumerable<UserEmailClaim> SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
|
||||
private IEnumerable<UserEmailClaimWithCount> SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
|
||||
|
||||
private void HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
@@ -36,13 +38,14 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static IEnumerable<UserEmailClaim> SortList(List<UserEmailClaim> emailClaims, string sortColumn, SortDirection sortDirection)
|
||||
private static IEnumerable<UserEmailClaimWithCount> SortList(List<UserEmailClaimWithCount> emailClaims, string sortColumn, SortDirection sortDirection)
|
||||
{
|
||||
return sortColumn switch
|
||||
{
|
||||
"Id" => SortableTable.SortListByProperty(emailClaims, e => e.Id, sortDirection),
|
||||
"CreatedAt" => SortableTable.SortListByProperty(emailClaims, e => e.CreatedAt, sortDirection),
|
||||
"Address" => SortableTable.SortListByProperty(emailClaims, e => e.Address, sortDirection),
|
||||
"EmailCount" => SortableTable.SortListByProperty(emailClaims, e => e.EmailCount, sortDirection),
|
||||
_ => emailClaims
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +48,17 @@ else
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 mt-4">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Account Status:</span>
|
||||
<StatusPill Enabled="@(!User.Blocked)" TextTrue="Active" TextFalse="Blocked" />
|
||||
<Button Color="@(User.Blocked ? "success" : "danger")" OnClick="ToggleBlockStatus">
|
||||
@(User.Blocked ? "Unblock User" : "Block User")
|
||||
</Button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Blocking a user prevents them from logging in or accessing AliasVault
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +108,7 @@ else
|
||||
private int TwoFactorKeysCount { get; set; }
|
||||
private List<AliasVaultUserRefreshToken> RefreshTokenList { get; set; } = [];
|
||||
private List<Vault> VaultList { get; set; } = [];
|
||||
private List<UserEmailClaim> EmailClaimList { get; set; } = [];
|
||||
private List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -124,10 +135,11 @@ else
|
||||
StateHasChanged();
|
||||
|
||||
// Load the aliases from the webapi via AliasService.
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
User = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
// Get count of user authenticator tokens.
|
||||
TwoFactorKeysCount = await DbContext.UserTokens.CountAsync(x => x.UserId == User!.Id && x.Name == "AuthenticatorKey");
|
||||
TwoFactorKeysCount = await dbContext.UserTokens.CountAsync(x => x.UserId == User!.Id && x.Name == "AuthenticatorKey");
|
||||
|
||||
if (User is null)
|
||||
{
|
||||
@@ -138,7 +150,7 @@ else
|
||||
}
|
||||
|
||||
// Load all active refresh tokens for this user to show which devices are logged in.
|
||||
RefreshTokenList = await DbContext.AliasVaultUserRefreshTokens.Where(x => x.UserId == User.Id).Select(x => new AliasVaultUserRefreshToken()
|
||||
RefreshTokenList = await dbContext.AliasVaultUserRefreshTokens.Where(x => x.UserId == User.Id).Select(x => new AliasVaultUserRefreshToken()
|
||||
{
|
||||
Id = x.Id,
|
||||
DeviceIdentifier = x.DeviceIdentifier,
|
||||
@@ -151,7 +163,7 @@ else
|
||||
.ToListAsync();
|
||||
|
||||
// Load all vaults for this user (do not load the actual file content for performance reasons).
|
||||
VaultList = await DbContext.Vaults.Where(x => x.UserId == User.Id).Select(x => new Vault
|
||||
VaultList = await dbContext.Vaults.Where(x => x.UserId == User.Id).Select(x => new Vault
|
||||
{
|
||||
Id = x.Id,
|
||||
Version = x.Version,
|
||||
@@ -171,7 +183,18 @@ else
|
||||
.ToListAsync();
|
||||
|
||||
// Load all email claims for this user.
|
||||
EmailClaimList = await DbContext.UserEmailClaims.Where(x => x.UserId == User.Id)
|
||||
EmailClaimList = await dbContext.UserEmailClaims
|
||||
.Where(x => x.UserId == User.Id)
|
||||
.Select(x => new UserEmailClaimWithCount
|
||||
{
|
||||
Id = x.Id,
|
||||
Address = x.Address,
|
||||
AddressLocal = x.AddressLocal,
|
||||
AddressDomain = x.AddressDomain,
|
||||
CreatedAt = x.CreatedAt,
|
||||
UpdatedAt = x.UpdatedAt,
|
||||
EmailCount = dbContext.Emails.Count(e => e.To == x.Address)
|
||||
})
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -184,12 +207,13 @@ else
|
||||
/// </summary>
|
||||
private async Task RevokeRefreshToken(AliasVaultUserRefreshToken entry)
|
||||
{
|
||||
var token = await DbContext.AliasVaultUserRefreshTokens.FindAsync(entry.Id);
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
var token = await dbContext.AliasVaultUserRefreshTokens.FindAsync(entry.Id);
|
||||
|
||||
if (token != null)
|
||||
{
|
||||
DbContext.AliasVaultUserRefreshTokens.Remove(token);
|
||||
await DbContext.SaveChangesAsync();
|
||||
dbContext.AliasVaultUserRefreshTokens.Remove(token);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
@@ -200,12 +224,13 @@ else
|
||||
/// </summary>
|
||||
private async Task EnableTwoFactor()
|
||||
{
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
User = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User != null)
|
||||
{
|
||||
User.TwoFactorEnabled = true;
|
||||
await DbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
@@ -217,12 +242,13 @@ else
|
||||
/// </summary>
|
||||
private async Task DisableTwoFactor()
|
||||
{
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
User = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User != null)
|
||||
{
|
||||
User.TwoFactorEnabled = false;
|
||||
await DbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
@@ -234,16 +260,17 @@ else
|
||||
/// </summary>
|
||||
private async Task ResetTwoFactor()
|
||||
{
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
User = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User != null)
|
||||
{
|
||||
// Remove all authenticator keys and recovery codes.
|
||||
await DbContext.UserTokens
|
||||
await dbContext.UserTokens
|
||||
.Where(x => x.UserId == User.Id && (x.Name == "AuthenticatorKey" || x.Name == "RecoveryCodes"))
|
||||
.ForEachAsync(x => DbContext.UserTokens.Remove(x));
|
||||
.ForEachAsync(x => dbContext.UserTokens.Remove(x));
|
||||
|
||||
await DbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
@@ -254,6 +281,7 @@ else
|
||||
/// <param name="vault">The vault to make current.</param>
|
||||
private async Task MakeCurrentAsync(Vault vault)
|
||||
{
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
if (await ConfirmModalService.ShowConfirmation(
|
||||
title: "Confirm Vault Restoration",
|
||||
message: @"Are you sure you want to restore this specific vault and make it the active one?
|
||||
@@ -264,7 +292,7 @@ Important notes:
|
||||
|
||||
Do you want to proceed with the restoration?")) {
|
||||
// Load vault
|
||||
var currentVault = await DbContext.Vaults.FindAsync(vault.Id);
|
||||
var currentVault = await dbContext.Vaults.FindAsync(vault.Id);
|
||||
if (currentVault == null)
|
||||
{
|
||||
return;
|
||||
@@ -274,10 +302,26 @@ Do you want to proceed with the restoration?")) {
|
||||
currentVault.RevisionNumber = VaultList.MaxBy(x => x.RevisionNumber)!.RevisionNumber + 1;
|
||||
|
||||
// Save it.
|
||||
await DbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload the page.
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the blocked status of the user.
|
||||
/// </summary>
|
||||
private async Task ToggleBlockStatus()
|
||||
{
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
User = await dbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User != null)
|
||||
{
|
||||
User.Blocked = !User.Blocked;
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@using AliasVault.Admin
|
||||
@using AliasVault.Admin.Auth.Components
|
||||
@using AliasVault.Admin.Main
|
||||
@using AliasVault.Admin.Main.Layout
|
||||
@using AliasVault.Admin.Main.Components
|
||||
@using AliasVault.Admin.Main.Components.Alerts
|
||||
@using AliasVault.Admin.Main.Components.Layout
|
||||
@@ -27,4 +28,3 @@
|
||||
@using AliasVault.Admin.Services
|
||||
@using AliasServerDb
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ using AliasVault.Auth;
|
||||
using AliasVault.Cryptography.Server;
|
||||
using AliasVault.Logging;
|
||||
using AliasVault.RazorComponents.Services;
|
||||
using AliasVault.Shared.Server.Services;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -37,7 +38,7 @@ config.LastPasswordChanged = DateTime.Parse(lastPasswordChanged, CultureInfo.Inv
|
||||
|
||||
builder.Services.AddSingleton(config);
|
||||
|
||||
builder.Services.AddAliasVaultDataProtection("AliasVault.Api");
|
||||
builder.Services.AddAliasVaultDataProtection("AliasVault.Admin");
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
@@ -53,6 +54,7 @@ builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingAuthenticati
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AuthLoggingService>();
|
||||
builder.Services.AddScoped<ConfirmModalService>();
|
||||
builder.Services.AddScoped<ServerSettingsService>();
|
||||
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
@@ -67,7 +69,7 @@ builder.Services.ConfigureApplicationCookie(options =>
|
||||
options.LoginPath = "/user/login";
|
||||
});
|
||||
|
||||
builder.Services.AddAliasVaultSqliteConfiguration();
|
||||
builder.Services.AddAliasVaultDatabaseConfiguration(builder.Configuration);
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
builder.Services.AddIdentityCore<AdminUser>(options =>
|
||||
{
|
||||
@@ -131,7 +133,7 @@ app.MapRazorComponents<App>()
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var container = scope.ServiceProvider;
|
||||
await using var db = await container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>().CreateDbContextAsync();
|
||||
await using var db = await container.GetRequiredService<IAliasServerDbContextFactory>().CreateDbContextAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==",
|
||||
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z",
|
||||
"ADMIN_PASSWORD_GENERATED": "2030-01-01T00:00:00Z",
|
||||
"DATA_PROTECTION_CERT_PASS": "Development"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,10 +139,7 @@ public class GlobalNotificationService
|
||||
messages.Add(new KeyValuePair<string, string>("error", message));
|
||||
}
|
||||
|
||||
// Clear messages
|
||||
SuccessMessages.Clear();
|
||||
InfoMessages.Clear();
|
||||
ErrorMessages.Clear();
|
||||
ClearMessages();
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -15,34 +15,18 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// <summary>
|
||||
/// User service for managing users.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">AliasServerDbContext instance.</param>
|
||||
/// <param name="dbContextFactory">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)
|
||||
public class UserService(IAliasServerDbContextFactory dbContextFactory, 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>
|
||||
@@ -84,15 +68,6 @@ public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser>
|
||||
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>
|
||||
@@ -104,17 +79,11 @@ public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser>
|
||||
// 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;
|
||||
|
||||
await using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,58 +91,6 @@ public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser>
|
||||
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>
|
||||
@@ -228,48 +145,6 @@ public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser>
|
||||
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>
|
||||
/// 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>
|
||||
private 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>
|
||||
/// Validate if user object contents conform to the requirements.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"DatabaseProvider": "postgresql",
|
||||
"ConnectionStrings": {
|
||||
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
|
||||
"AliasServerDbContext": "Host=localhost;Port=5433;Database=aliasvault;Username=aliasvault;Password=password"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
||||
6
src/AliasVault.Admin/package-lock.json
generated
6
src/AliasVault.Admin/package-lock.json
generated
@@ -710,9 +710,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -554,40 +554,6 @@ video {
|
||||
--tw-contain-style: ;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.container {
|
||||
max-width: 1536px;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -657,6 +623,11 @@ video {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.mx-4 {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
@@ -699,6 +670,10 @@ video {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.mr-14 {
|
||||
margin-right: 3.5rem;
|
||||
}
|
||||
@@ -988,6 +963,14 @@ video {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
|
||||
@@ -1124,10 +1107,6 @@ video {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity));
|
||||
@@ -1173,6 +1152,11 @@ video {
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-emerald-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||
@@ -1258,6 +1242,11 @@ video {
|
||||
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-primary-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 224 150 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-primary-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
|
||||
@@ -1518,10 +1507,6 @@ video {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.leading-6 {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
@@ -1652,12 +1637,6 @@ video {
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.shadow-sm {
|
||||
--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
|
||||
@@ -1737,6 +1716,11 @@ video {
|
||||
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-700:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(17 24 39 / var(--tw-text-opacity));
|
||||
@@ -1887,6 +1871,10 @@ video {
|
||||
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-blue-900\/30:is(.dark *) {
|
||||
background-color: rgb(30 58 138 / 0.3);
|
||||
}
|
||||
|
||||
.dark\:bg-gray-600:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
|
||||
@@ -1897,6 +1885,10 @@ video {
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-gray-700\/50:is(.dark *) {
|
||||
background-color: rgb(55 65 81 / 0.5);
|
||||
}
|
||||
|
||||
.dark\:bg-gray-800:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
@@ -1922,6 +1914,10 @@ video {
|
||||
background-color: rgb(20 83 45 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-green-900\/30:is(.dark *) {
|
||||
background-color: rgb(20 83 45 / 0.3);
|
||||
}
|
||||
|
||||
.dark\:bg-primary-600:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
|
||||
@@ -2075,6 +2071,11 @@ video {
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-gray-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-primary-500:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(244 149 65 / var(--tw-text-opacity));
|
||||
@@ -2100,6 +2101,11 @@ video {
|
||||
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-blue-600:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-gray-600:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
|
||||
@@ -2141,6 +2147,10 @@ video {
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:mb-5 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.sm\:flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -2149,10 +2159,18 @@ video {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sm\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sm\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sm\:gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.sm\:space-x-2 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
|
||||
@@ -2227,6 +2245,10 @@ video {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.md\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@@ -2268,6 +2290,10 @@ video {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.lg\:mb-0 {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.lg\:mt-0 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
@@ -23,17 +23,19 @@
|
||||
<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="9.0.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.2.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.3.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.0" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
|
||||
<ProjectReference Include="..\Generators\AliasVault.Generators.Identity\AliasVault.Generators.Identity.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared\AliasVault.Shared.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Auth\AliasVault.Auth.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
|
||||
|
||||
19
src/AliasVault.Api/Config.cs
Normal file
19
src/AliasVault.Api/Config.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <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.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration class for the Client project with values loaded from appsettings.json.
|
||||
/// </summary>
|
||||
public class Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether public registration is enabled.
|
||||
/// </summary>
|
||||
public bool PublicRegistrationEnabled { get; set; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ using AliasVault.Shared.Models.WebApi;
|
||||
using AliasVault.Shared.Models.WebApi.Auth;
|
||||
using AliasVault.Shared.Models.WebApi.PasswordChange;
|
||||
using AliasVault.Shared.Providers.Time;
|
||||
using AliasVault.Shared.Server.Services;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -39,10 +40,12 @@ using SecureRemotePassword;
|
||||
/// <param name="cache">IMemoryCache instance for persisting SRP values during multistep login process.</param>
|
||||
/// <param name="timeProvider">ITimeProvider instance. This returns the time which can be mutated for testing.</param>
|
||||
/// <param name="authLoggingService">AuthLoggingService instance. This is used to log auth attempts to the database.</param>
|
||||
/// <param name="config">Config instance.</param>
|
||||
/// <param name="settingsService">ServerSettingsService instance.</param>
|
||||
[Route("v{version:apiVersion}/[controller]")]
|
||||
[ApiController]
|
||||
[ApiVersion("1")]
|
||||
public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService) : ControllerBase
|
||||
public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config, ServerSettingsService settingsService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Error message for invalid username or password.
|
||||
@@ -60,9 +63,14 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
private static readonly string[] InvalidRecoveryCode = ["Invalid recovery code."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for invalid 2-factor authentication recovery code.
|
||||
/// Error message for too many failed login attempts.
|
||||
/// </summary>
|
||||
private static readonly string[] AccountLocked = ["You have entered an incorrect password too many times and your account has now been locked out. You can try again in 30 minutes.."];
|
||||
private static readonly string[] AccountLocked = ["You have entered an incorrect password too many times and your account has now been locked out. You can try again in 30 minutes."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for if user is (manually) blocked by admin.
|
||||
/// </summary>
|
||||
private static readonly string[] AccountBlocked = ["Your account has been disabled. If you believe this is a mistake, please contact support."];
|
||||
|
||||
/// <summary>
|
||||
/// Semaphore to prevent concurrent access to the database when generating new tokens for a user.
|
||||
@@ -102,6 +110,13 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
return BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400));
|
||||
}
|
||||
|
||||
// Check if the account is blocked.
|
||||
if (user.Blocked)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.AccountBlocked);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(AccountBlocked, 400));
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current salt and verifier.
|
||||
var latestVaultEncryptionSettings = AuthHelper.GetUserLatestVaultEncryptionSettings(user);
|
||||
|
||||
@@ -262,6 +277,13 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
return Unauthorized("User not found (name-2)");
|
||||
}
|
||||
|
||||
// Check if the account is blocked.
|
||||
if (user.Blocked)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.AccountBlocked);
|
||||
return Unauthorized("Account blocked");
|
||||
}
|
||||
|
||||
// Generate new tokens for the user.
|
||||
var token = await GenerateNewTokensForUser(user, tokenModel.RefreshToken);
|
||||
if (token == null)
|
||||
@@ -331,6 +353,12 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest model)
|
||||
{
|
||||
// Check if public registration is disabled in the configuration.
|
||||
if (!config.PublicRegistrationEnabled)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(["New account registration is currently disabled on this server. Please contact the administrator."], 400));
|
||||
}
|
||||
|
||||
// Validate the username.
|
||||
var (isValid, errorMessage) = ValidateUsername(model.Username);
|
||||
if (!isValid)
|
||||
@@ -603,6 +631,13 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400)));
|
||||
}
|
||||
|
||||
// Check if the account is blocked.
|
||||
if (user.Blocked)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.AccountBlocked);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(AccountBlocked, 400)));
|
||||
}
|
||||
|
||||
// Validate the SRP session (actual password check).
|
||||
var serverSession = AuthHelper.ValidateSrpSession(cache, user, model.ClientPublicEphemeral, model.ClientSessionProof);
|
||||
if (serverSession is null)
|
||||
@@ -655,18 +690,14 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, bool extendedLifetime = false)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
var settings = await settingsService.GetAllSettingsAsync();
|
||||
|
||||
await Semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Determine the refresh token lifetime.
|
||||
// - 4 hours by default.
|
||||
// - 7 days if "remember me" was checked during login.
|
||||
var refreshTokenLifetime = TimeSpan.FromHours(4);
|
||||
if (extendedLifetime)
|
||||
{
|
||||
refreshTokenLifetime = TimeSpan.FromDays(7);
|
||||
}
|
||||
// Use server settings for refresh token lifetime.
|
||||
var refreshTokenLifetimeHours = extendedLifetime ? settings.RefreshTokenLifetimeLong : settings.RefreshTokenLifetimeShort;
|
||||
var refreshTokenLifetime = TimeSpan.FromHours(refreshTokenLifetimeHours);
|
||||
|
||||
// Return new refresh token.
|
||||
return await GenerateRefreshToken(user, refreshTokenLifetime);
|
||||
|
||||
@@ -23,7 +23,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// <param name="dbContextFactory">DbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
[ApiVersion("1")]
|
||||
public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a list of emails for the provided email address.
|
||||
@@ -41,9 +41,11 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
|
||||
return Unauthorized("Not authenticated.");
|
||||
}
|
||||
|
||||
var sanitizedEmail = to.Trim().ToLower();
|
||||
|
||||
// See if this user has a valid claim to the email address.
|
||||
var emailClaim = await context.UserEmailClaims
|
||||
.FirstOrDefaultAsync(x => x.Address == to);
|
||||
.FirstOrDefaultAsync(x => x.Address == sanitizedEmail);
|
||||
|
||||
if (emailClaim is null)
|
||||
{
|
||||
@@ -51,7 +53,7 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
|
||||
{
|
||||
Message = "No claim exists for this email address.",
|
||||
Code = "CLAIM_DOES_NOT_EXIST",
|
||||
Details = new { ProvidedEmail = to },
|
||||
Details = new { ProvidedEmail = sanitizedEmail },
|
||||
StatusCode = StatusCodes.Status400BadRequest,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
});
|
||||
@@ -103,10 +105,10 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of emails for the provided email address.
|
||||
/// Returns a list of emails for the provided list of email addresses.
|
||||
/// </summary>
|
||||
/// <param name="model">The request model extracted from POST body.</param>
|
||||
/// <returns>List of aliases in JSON format.</returns>
|
||||
/// <returns>List of emails in JSON format.</returns>
|
||||
[HttpPost(template: "bulk", Name = "GetEmailBoxBulk")]
|
||||
public async Task<IActionResult> GetEmailBoxBulk([FromBody] MailboxBulkRequest model)
|
||||
{
|
||||
@@ -152,6 +154,7 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
|
||||
MessagePreview = x.MessagePreview ?? string.Empty,
|
||||
EncryptedSymmetricKey = x.EncryptedSymmetricKey,
|
||||
EncryptionKey = x.EncryptionKey.PublicKey,
|
||||
HasAttachments = x.Attachments.Any(),
|
||||
})
|
||||
.OrderByDescending(x => x.DateSystem)
|
||||
.Skip((model.Page - 1) * model.PageSize)
|
||||
|
||||
@@ -22,10 +22,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// <param name="dbContextFactory">DbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
[ApiVersion("1")]
|
||||
public class EmailController(ILogger<VaultController> logger, IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
public class EmailController(ILogger<VaultController> logger, IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the newest version of the vault for the current user.
|
||||
/// Get the email with the specified ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The email ID to open.</param>
|
||||
/// <returns>List of aliases in JSON format.</returns>
|
||||
@@ -89,11 +89,8 @@ public class EmailController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
return errorResult;
|
||||
}
|
||||
|
||||
// Delete associated attachments
|
||||
context.EmailAttachments.RemoveRange(email!.Attachments);
|
||||
|
||||
// Delete the email
|
||||
context.Emails.Remove(email);
|
||||
// Delete the email - attachments will be cascade deleted
|
||||
context.Emails.Remove(email!);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -108,6 +105,36 @@ public class EmailController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the attachment bytes for the specified email and attachment ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The email ID.</param>
|
||||
/// <param name="attachmentId">The attachment ID.</param>
|
||||
/// <returns>Attachment bytes in encrypted form.</returns>
|
||||
[HttpGet(template: "{id}/attachments/{attachmentId}", Name = "GetEmailAttachment")]
|
||||
public async Task<IActionResult> GetEmailAttachment(int id, int attachmentId)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var (email, errorResult) = await AuthenticateAndRetrieveEmailAsync(id, context);
|
||||
if (errorResult != null)
|
||||
{
|
||||
return errorResult;
|
||||
}
|
||||
|
||||
// Find the requested attachment
|
||||
var attachment = await context.EmailAttachments
|
||||
.FirstOrDefaultAsync(x => x.Id == attachmentId && x.EmailId == email!.Id);
|
||||
|
||||
if (attachment == null)
|
||||
{
|
||||
return NotFound("Attachment not found.");
|
||||
}
|
||||
|
||||
// Return the encrypted bytes
|
||||
return File(attachment.Bytes, attachment.MimeType, attachment.Filename);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the user and retrieves the requested email.
|
||||
/// </summary>
|
||||
|
||||
58
src/AliasVault.Api/Controllers/IdentityController.cs
Normal file
58
src/AliasVault.Api/Controllers/IdentityController.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="IdentityController.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.Api.Controllers;
|
||||
|
||||
using AliasServerDb;
|
||||
using AliasVault.Api.Controllers.Abstracts;
|
||||
using AliasVault.Api.Helpers;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for generating identities taking into account existing information on the AliasVault server.
|
||||
/// </summary>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
/// <param name="dbContextFactory">DbContextFactory instance.</param>
|
||||
[ApiVersion("1")]
|
||||
public class IdentityController(UserManager<AliasVaultUser> userManager, IAliasServerDbContextFactory dbContextFactory) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that provided email address is not already taken by another user.
|
||||
/// </summary>
|
||||
/// <param name="email">The full email address to check.</param>
|
||||
/// <returns>True if the email address is already taken, false otherwise.</returns>
|
||||
[HttpPost("CheckEmail/{email}")]
|
||||
public async Task<IActionResult> CheckEmail(string email)
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
if (user == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
bool isTaken = await EmailClaimExistsAsync(email);
|
||||
return Ok(new { isTaken });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that provided email address is not already taken by another user.
|
||||
/// </summary>
|
||||
/// <param name="email">The email address to check.</param>
|
||||
/// <returns>True if the email address is already taken, false otherwise.</returns>
|
||||
private async Task<bool> EmailClaimExistsAsync(string email)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var sanitizedEmail = EmailHelper.SanitizeEmail(email);
|
||||
var claimExists = await context.UserEmailClaims.FirstOrDefaultAsync(c => c.Address == sanitizedEmail);
|
||||
|
||||
return claimExists != null;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/")]
|
||||
public class RootController(IDbContextFactory<AliasServerDbContext> dbContextFactory) : ControllerBase
|
||||
public class RootController(IAliasServerDbContextFactory dbContextFactory) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Root endpoint that returns a 200 OK if the database connection is successful
|
||||
|
||||
@@ -24,7 +24,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
[Route("v{version:apiVersion}/[controller]")]
|
||||
[ApiController]
|
||||
[ApiVersion("1")]
|
||||
public class SecurityController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
public class SecurityController(IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns list of active sessions (refresh tokens) for the current user.
|
||||
|
||||
@@ -36,7 +36,7 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
/// <param name="authLoggingService">AuthLoggingService instance.</param>
|
||||
/// <param name="cache">IMemoryCache instance.</param>
|
||||
[ApiVersion("1")]
|
||||
public class VaultController(ILogger<VaultController> logger, IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider, AuthLoggingService authLoggingService, IMemoryCache cache) : AuthenticatedRequestController(userManager)
|
||||
public class VaultController(ILogger<VaultController> logger, IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider, AuthLoggingService authLoggingService, IMemoryCache cache) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Error message for providing an invalid current password (during password change).
|
||||
@@ -89,6 +89,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
Status = VaultStatus.Ok,
|
||||
Vault = new Shared.Models.WebApi.Vault.Vault
|
||||
{
|
||||
Username = user.UserName!,
|
||||
Blob = string.Empty,
|
||||
Version = string.Empty,
|
||||
CurrentRevisionNumber = 0,
|
||||
@@ -121,6 +122,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
Status = VaultStatus.Ok,
|
||||
Vault = new Shared.Models.WebApi.Vault.Vault
|
||||
{
|
||||
Username = user.UserName!,
|
||||
Blob = vault.VaultBlob,
|
||||
Version = vault.Version,
|
||||
CurrentRevisionNumber = vault.RevisionNumber,
|
||||
@@ -159,6 +161,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
{
|
||||
Vaults = vaultsToMerge.Select(x => new Shared.Models.WebApi.Vault.Vault
|
||||
{
|
||||
Username = user.UserName!,
|
||||
Blob = x.VaultBlob,
|
||||
Version = x.Version,
|
||||
CurrentRevisionNumber = x.RevisionNumber,
|
||||
@@ -187,6 +190,15 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Compare the logged-in username with the username in the provided vault model.
|
||||
// If they do not match reject the request. This is important because it's
|
||||
// possible that a user has logged in with a different username than the one
|
||||
// that is being used to update the vault (e.g. if working with multiple tabs).
|
||||
if (user.UserName != model.Username)
|
||||
{
|
||||
return BadRequest("The currently logged on user is not the owner of the vault being saved. Please save your changes locally and log out and in again.");
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current encryption settings.
|
||||
var latestVault = user.Vaults.OrderByDescending(x => x.RevisionNumber).Select(x => new { x.Salt, x.Verifier, x.EncryptionType, x.EncryptionSettings, x.RevisionNumber }).First();
|
||||
|
||||
@@ -228,7 +240,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
// Update user email claims if email addresses have been supplied.
|
||||
if (model.EmailAddressList.Count > 0)
|
||||
{
|
||||
await UpdateUserEmailClaims(context, user.Id, model.EmailAddressList);
|
||||
await UpdateUserEmailClaims(context, user, model.EmailAddressList);
|
||||
}
|
||||
|
||||
// Sync user public key if supplied.
|
||||
@@ -256,6 +268,15 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Compare the logged-in username with the username in the provided vault model.
|
||||
// If they do not match reject the request. This is important because it's
|
||||
// possible that a user has logged in with a different username than the one
|
||||
// that is being used to update the vault (e.g. if working with multiple tabs).
|
||||
if (model.Username != user.UserName)
|
||||
{
|
||||
return BadRequest("The currently logged on user is not the owner of the vault being saved. Please save your changes locally and log out and in again.");
|
||||
}
|
||||
|
||||
// Validate the SRP session (actual password check).
|
||||
var serverSession = AuthHelper.ValidateSrpSession(cache, user, model.CurrentClientPublicEphemeral, model.CurrentClientSessionProof);
|
||||
if (serverSession is null)
|
||||
@@ -350,14 +371,14 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
/// Updates the user's email claims based on the provided email address list.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="userId">The ID of the user.</param>
|
||||
/// <param name="user">The user object.</param>
|
||||
/// <param name="newEmailAddresses">The list of new email addresses to claim.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
private async Task UpdateUserEmailClaims(AliasServerDbContext context, string userId, List<string> newEmailAddresses)
|
||||
private async Task UpdateUserEmailClaims(AliasServerDbContext context, AliasVaultUser user, List<string> newEmailAddresses)
|
||||
{
|
||||
// Get all existing user email claims.
|
||||
var existingEmailClaims = await context.UserEmailClaims
|
||||
.Where(x => x.UserId == userId)
|
||||
.Where(x => x.UserId == user.Id)
|
||||
.Select(x => x.Address)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -365,7 +386,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
foreach (var email in newEmailAddresses)
|
||||
{
|
||||
// Sanitize email address.
|
||||
var sanitizedEmail = email.Trim().ToLower();
|
||||
var sanitizedEmail = EmailHelper.SanitizeEmail(email);
|
||||
|
||||
// If email address is invalid according to the EmailAddressAttribute, skip it.
|
||||
if (!new EmailAddressAttribute().IsValid(sanitizedEmail))
|
||||
@@ -377,10 +398,10 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
var existingClaim = await context.UserEmailClaims
|
||||
.FirstOrDefaultAsync(x => x.Address == sanitizedEmail);
|
||||
|
||||
if (existingClaim != null && existingClaim.UserId != userId)
|
||||
if (existingClaim != null && existingClaim.UserId != user.Id)
|
||||
{
|
||||
// Email address is already claimed by another user. Log the error and continue.
|
||||
logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", userId, sanitizedEmail);
|
||||
logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", user.UserName, sanitizedEmail);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -390,7 +411,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
{
|
||||
context.UserEmailClaims.Add(new UserEmailClaim
|
||||
{
|
||||
UserId = userId,
|
||||
UserId = user.Id,
|
||||
Address = sanitizedEmail,
|
||||
AddressLocal = sanitizedEmail.Split('@')[0],
|
||||
AddressDomain = sanitizedEmail.Split('@')[1],
|
||||
@@ -401,7 +422,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
// Error while adding email claim. Log the error and continue.
|
||||
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, userId);
|
||||
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, user.UserName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 3001
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG TARGETARCH
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy the project files and restore dependencies
|
||||
COPY ["src/AliasVault.Api/AliasVault.Api.csproj", "src/AliasVault.Api/"]
|
||||
RUN dotnet restore "src/AliasVault.Api/AliasVault.Api.csproj"
|
||||
RUN dotnet restore "src/AliasVault.Api/AliasVault.Api.csproj" -a "$TARGETARCH"
|
||||
COPY . .
|
||||
|
||||
# Build and publish
|
||||
WORKDIR "/src/src/AliasVault.Api"
|
||||
RUN dotnet publish "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
|
||||
RUN dotnet publish "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" \
|
||||
-a "$TARGETARCH" \
|
||||
-o /app/publish \
|
||||
/p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
# Final stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
|
||||
24
src/AliasVault.Api/Helpers/EmailHelper.cs
Normal file
24
src/AliasVault.Api/Helpers/EmailHelper.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="EmailHelper.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.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// EmailHelper class which contains helper methods for email.
|
||||
/// </summary>
|
||||
public static class EmailHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Sanitize email address by trimming and converting to lowercase.
|
||||
/// </summary>
|
||||
/// <param name="email">Email address to sanitize.</param>
|
||||
/// <returns>Sanitized email address.</returns>
|
||||
public static string SanitizeEmail(string email)
|
||||
{
|
||||
return email.Trim().ToLower();
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using AliasServerDb;
|
||||
using AliasServerDb.Configuration;
|
||||
using AliasVault.Api;
|
||||
using AliasVault.Api.Jwt;
|
||||
using AliasVault.Auth;
|
||||
using AliasVault.Cryptography.Server;
|
||||
using AliasVault.Logging;
|
||||
using AliasVault.Shared.Providers.Time;
|
||||
using AliasVault.Shared.Server.Services;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -24,12 +26,20 @@ using Microsoft.OpenApi.Models;
|
||||
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);
|
||||
|
||||
var config = new Config();
|
||||
var publicRegistrationEnabled = Environment.GetEnvironmentVariable("PUBLIC_REGISTRATION_ENABLED") ?? "false";
|
||||
config.PublicRegistrationEnabled = bool.Parse(publicRegistrationEnabled);
|
||||
|
||||
builder.Services.AddSingleton(config);
|
||||
|
||||
builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../logs");
|
||||
|
||||
builder.Services.AddAliasVaultDataProtection("AliasVault.Api");
|
||||
builder.Services.AddSingleton<ITimeProvider, SystemTimeProvider>();
|
||||
builder.Services.AddScoped<TimeValidationJwtBearerEvents>();
|
||||
builder.Services.AddScoped<AuthLoggingService>();
|
||||
builder.Services.AddScoped<ServerSettingsService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddLogging(logging =>
|
||||
@@ -40,7 +50,7 @@ builder.Services.AddLogging(logging =>
|
||||
logging.AddFilter("Microsoft.AspNetCore.Identity.UserManager", LogLevel.Error);
|
||||
});
|
||||
|
||||
builder.Services.AddAliasVaultSqliteConfiguration();
|
||||
builder.Services.AddAliasVaultDatabaseConfiguration(builder.Configuration);
|
||||
builder.Services.AddIdentity<AliasVaultUser, AliasVaultRole>(options =>
|
||||
{
|
||||
options.Password.RequireDigit = false;
|
||||
@@ -170,7 +180,7 @@ app.MapControllers();
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var container = scope.ServiceProvider;
|
||||
await using var db = await container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>().CreateDbContextAsync();
|
||||
await using var db = await container.GetRequiredService<IAliasServerDbContextFactory>().CreateDbContextAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"JWT_KEY": "12345678901234567890123456789012",
|
||||
"DATA_PROTECTION_CERT_PASS": "Development"
|
||||
"DATA_PROTECTION_CERT_PASS": "Development",
|
||||
"PUBLIC_REGISTRATION_ENABLED": "true"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://0.0.0.0:5092"
|
||||
@@ -19,7 +20,8 @@
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"JWT_KEY": "12345678901234567890123456789012",
|
||||
"DATA_PROTECTION_CERT_PASS": "Development"
|
||||
"DATA_PROTECTION_CERT_PASS": "Development",
|
||||
"PUBLIC_REGISTRATION_ENABLED": "true"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://0.0.0.0:7223"
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
"Jwt": {
|
||||
"Issuer": "AliasVault"
|
||||
},
|
||||
"DatabaseProvider": "postgresql",
|
||||
"ConnectionStrings": {
|
||||
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
|
||||
"AliasServerDbContext": "Host=localhost;Port=5433;Database=aliasvault;Username=aliasvault;Password=password"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<a href="/">
|
||||
<div class="text-5xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<img src="img/logo.svg" alt="AliasVault" class="w-20 h-20 mr-2" />
|
||||
<span class="relative">
|
||||
<span class="relative inline-flex flex-wrap items-center">
|
||||
AliasVault
|
||||
<span class="absolute -top-2 bg-primary-500 text-white text-xs px-2 py-0.5 rounded-full font-normal">BETA</span>
|
||||
<span class="ml-2 bg-primary-500 text-white text-xs px-2 py-0.5 rounded-full font-normal sm:-top-2 sm:ml-1">BETA</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/user/login"
|
||||
@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
|
||||
@layout Auth.Layout.MainLayout
|
||||
@inject Config Config
|
||||
@attribute [AllowAnonymous]
|
||||
@using System.Text.Json
|
||||
@using AliasVault.Shared.Models.WebApi.Auth
|
||||
@@ -23,7 +24,11 @@
|
||||
<DataAnnotationsValidator/>
|
||||
<div>
|
||||
<label for="two-factor-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authenticator code</label>
|
||||
<InputNumber @bind-Value="_loginModel2Fa.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"/>
|
||||
<InputNumber @bind-Value="_loginModel2Fa.TwoFactorCode"
|
||||
id="two-factor-code"
|
||||
@oninput="OnTwoFactorCodeInput"
|
||||
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="() => _loginModel2Fa.TwoFactorCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
@@ -103,9 +108,12 @@ else
|
||||
</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>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
No account yet? <a href="/user/setup" class="text-primary-700 hover:underline dark:text-primary-500">Create new vault</a>
|
||||
</div>
|
||||
@if (Config.PublicRegistrationEnabled)
|
||||
{
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
No account yet? <a href="/user/setup" class="text-primary-700 hover:underline dark:text-primary-500">Create new vault</a>
|
||||
</div>
|
||||
}
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@@ -413,4 +421,24 @@ else
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto submit the 2FA code when 6 digits are entered.
|
||||
/// </summary>
|
||||
/// <param name="e"></param>
|
||||
private async Task OnTwoFactorCodeInput(ChangeEventArgs e)
|
||||
{
|
||||
if (e.Value?.ToString()?.Length >= 6)
|
||||
{
|
||||
// Update the blazor model with the current value.
|
||||
_loginModel2Fa.TwoFactorCode = int.Parse(e.Value.ToString()!);
|
||||
|
||||
// Submit the form.
|
||||
await Handle2Fa();
|
||||
}
|
||||
else
|
||||
{
|
||||
_serverValidationErrors.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using AliasVault.Client.Auth.Components
|
||||
@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
|
||||
@layout Auth.Layout.EmptyLayout
|
||||
@inject Config Config
|
||||
@attribute [AllowAnonymous]
|
||||
|
||||
<div class="flex lg:min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@@ -22,9 +23,12 @@
|
||||
Your Privacy. Protected.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<a href="/user/setup" class="block w-full py-3 px-4 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition duration-300 ease-in-out text-center">
|
||||
Create new vault
|
||||
</a>
|
||||
@if (Config.PublicRegistrationEnabled)
|
||||
{
|
||||
<a href="/user/setup" class="block w-full py-3 px-4 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition duration-300 ease-in-out text-center">
|
||||
Create new vault
|
||||
</a>
|
||||
}
|
||||
<a href="/user/login" class="block w-full py-3 px-4 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-800 text-gray-800 dark:text-white font-semibold rounded-lg transition duration-300 ease-in-out text-center">
|
||||
Log in with existing account
|
||||
</a>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
@if (IsLoading) {
|
||||
<BoldLoadingIndicator />
|
||||
|
||||
}
|
||||
else if (IsWebAuthnLoading) {
|
||||
<BoldLoadingIndicator />
|
||||
@@ -205,7 +204,7 @@ else
|
||||
}
|
||||
|
||||
// Check if encryption key test string is available. If not
|
||||
// user should login again.
|
||||
// user should log in again.
|
||||
if (!await AuthService.HasEncryptionKeyTestStringAsync())
|
||||
{
|
||||
// Clear all tokens and redirect to login page.
|
||||
|
||||
@@ -62,4 +62,9 @@ public class Config
|
||||
/// Gets or sets the support email address that users can contact for password recovery.
|
||||
/// </summary>
|
||||
public string? SupportEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether public registration is enabled.
|
||||
/// </summary>
|
||||
public bool PublicRegistrationEnabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG TARGETARCH
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
ENV MSBUILDDEBUGPATH=/src/msbuild-logs
|
||||
WORKDIR /src
|
||||
|
||||
# Create the debug directory and install Python which is required by the WebAssembly tools
|
||||
RUN mkdir -p /src/msbuild-logs && apt-get update && apt-get install -y python3 && apt-get clean
|
||||
# Create the debug directory
|
||||
RUN mkdir -p /src/msbuild-logs
|
||||
|
||||
# Install Python which is required by the WebAssembly tools
|
||||
RUN apt-get update && apt-get install -y python3 && apt-get clean
|
||||
|
||||
# Install the WebAssembly tools
|
||||
RUN dotnet workload install wasm-tools
|
||||
|
||||
# Copy the project files and restore dependencies
|
||||
COPY ["src/AliasVault.Client/AliasVault.Client.csproj", "src/AliasVault.Client/"]
|
||||
RUN dotnet restore "src/AliasVault.Client/AliasVault.Client.csproj"
|
||||
RUN dotnet restore "src/AliasVault.Client/AliasVault.Client.csproj" -a "$TARGETARCH"
|
||||
COPY . .
|
||||
|
||||
# Build and publish
|
||||
# Build the Client project
|
||||
WORKDIR "/src/src/AliasVault.Client"
|
||||
RUN dotnet publish "AliasVault.Client.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
|
||||
RUN dotnet build "AliasVault.Client.csproj" \
|
||||
-c "$BUILD_CONFIGURATION" \
|
||||
-o /app/build \
|
||||
-a "$TARGETARCH"
|
||||
|
||||
# Publish the Client project
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
ARG TARGETARCH
|
||||
RUN dotnet publish "AliasVault.Client.csproj" \
|
||||
-c "$BUILD_CONFIGURATION" \
|
||||
-a "$TARGETARCH" \
|
||||
--no-restore \
|
||||
-o /app/publish \
|
||||
/p:UseAppHost=false \
|
||||
/p:WasmNativeStrip=false \
|
||||
/p:EmccInitialHeapSize=268435456
|
||||
|
||||
# Final stage
|
||||
FROM nginx:1.24.0 AS final
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=build /app/publish/wwwroot .
|
||||
COPY --from=publish /app/publish/wwwroot .
|
||||
COPY /src/AliasVault.Client/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY /src/AliasVault.Client/entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
|
||||
@@ -3,42 +3,72 @@
|
||||
@inject JsInteropService JsInteropService
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject EmailService EmailService
|
||||
@inject HttpClient HttpClient
|
||||
|
||||
<ClickOutsideHandler OnClose="OnClose" ContentId="emailModal">
|
||||
<div class="fixed inset-0 z-50 overflow-auto bg-gray-500 bg-opacity-75 flex items-center justify-center">
|
||||
<div id="emailModal" class="relative p-8 bg-white w-3/4 flex-col flex rounded-lg shadow-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
@if (IsSpamOk)
|
||||
{
|
||||
<a target="_blank" href="https://spamok.com/@(Email!.ToLocal)/@(Email!.Id)">@Email.Subject</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Email?.Subject</span>
|
||||
}
|
||||
</h2>
|
||||
<button @onclick="Close" class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">From: @(Email?.FromLocal)@@@(Email?.FromDomain)</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">To: @(Email?.ToLocal)@@@(Email?.ToDomain)</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Date: @Email?.DateSystem</p>
|
||||
</div>
|
||||
<div class="mt-4 text-gray-700 dark:text-gray-300">
|
||||
<div>
|
||||
<iframe class="w-full overscroll-y-auto" style="height:500px;" srcdoc="@EmailBody">
|
||||
</iframe>
|
||||
<div id="emailModal" class="relative bg-white w-3/4 flex flex-col rounded-lg shadow-xl max-h-[90vh]">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
@if (IsSpamOk)
|
||||
{
|
||||
<a target="_blank" href="https://spamok.com/@(Email!.ToLocal)/@(Email!.Id)">@Email.Subject</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Email?.Subject</span>
|
||||
}
|
||||
</h2>
|
||||
<button @onclick="Close" class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">From: @(Email?.FromLocal)@@@(Email?.FromDomain)</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">To: @(Email?.ToLocal)@@@(Email?.ToDomain)</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Date: @Email?.DateSystem</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-4">
|
||||
<button id="delete-email" @onclick="DeleteEmail" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||
<button id="close-email-modal" @onclick="Close" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Close</button>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="text-gray-700 dark:text-gray-300">
|
||||
<div>
|
||||
<iframe class="w-full overscroll-y-auto" style="height:500px;" srcdoc="@EmailBody">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
@if (Email?.Attachments?.Any() == true)
|
||||
{
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Attachments:</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
@foreach (var attachment in Email.Attachments)
|
||||
{
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0" 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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
|
||||
</svg>
|
||||
<button @onclick="() => DownloadAttachment(attachment)"
|
||||
class="text-primary hover:underline text-sm truncate attachment-link">
|
||||
(@(Math.Ceiling((double)attachment.Filesize / 1024)) KB) @attachment.Filename
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-4">
|
||||
<button id="delete-email" @onclick="DeleteEmail" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||
<button id="close-email-modal" @onclick="Close" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,8 +149,8 @@
|
||||
var response = await client.DeleteAsync($"https://api.spamok.com/v2/Email/{Email.ToLocal}/{Email.Id}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
GlobalNotificationService.AddSuccessMessage("Email deleted successfully", true);
|
||||
await OnEmailDeleted.InvokeAsync(Email.Id);
|
||||
GlobalNotificationService.AddSuccessMessage("Email deleted successfully", true);
|
||||
await Close();
|
||||
}
|
||||
else
|
||||
@@ -150,8 +180,8 @@
|
||||
var response = await HttpClient.DeleteAsync($"v1/Email/{Email.Id}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
GlobalNotificationService.AddSuccessMessage("Email deleted successfully", true);
|
||||
await OnEmailDeleted.InvokeAsync(Email.Id);
|
||||
GlobalNotificationService.AddSuccessMessage("Email deleted successfully", true);
|
||||
await Close();
|
||||
}
|
||||
else
|
||||
@@ -192,4 +222,56 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download an attachment.
|
||||
/// </summary>
|
||||
private async Task DownloadAttachment(AttachmentApiModel attachment)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsSpamOk)
|
||||
{
|
||||
var client = HttpClientFactory.CreateClient("EmailClient");
|
||||
var response = await client.GetAsync($"https://api.spamok.com/v2/Attachment/{Email!.Id}/{attachment.Id}/download");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
await JsInteropService.DownloadFileFromStream(attachment.Filename, bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Failed to download attachment", true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var response = await HttpClient.GetAsync($"v1/Email/{Email!.Id}/attachments/{attachment.Id}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
// Get attachment bytes from API.
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// Decrypt the attachment locally with email's encryption key.
|
||||
var decryptedBytes = await EmailService.DecryptEmailAttachment(Email, bytes);
|
||||
|
||||
// Offer the decrypted attachment as download to the user's browser.
|
||||
if (decryptedBytes != null)
|
||||
{
|
||||
await JsInteropService.DownloadFileFromStream(attachment.Filename, decryptedBytes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Failed to download attachment", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage($"Error downloading attachment: {ex.Message}", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@inject EmailService EmailService
|
||||
@using System.Timers
|
||||
@inject ILogger<RecentEmails> Logger
|
||||
@implements IDisposable
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (EmailModalVisible)
|
||||
{
|
||||
@@ -61,7 +61,7 @@
|
||||
Subject
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Date & Time
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -100,54 +100,74 @@
|
||||
private bool EmailModalVisible { get; set; }
|
||||
private string Error { get; set; } = string.Empty;
|
||||
|
||||
private bool IsRefreshing { get; set; } = true;
|
||||
private bool IsLoading { get; set; } = true;
|
||||
|
||||
private bool IsSpamOk { get; set; } = false;
|
||||
|
||||
private bool IsPageVisible { get; set; } = true;
|
||||
private CancellationTokenSource? PollingCancellationTokenSource { get; set; }
|
||||
private const int ACTIVE_TAB_REFRESH_INTERVAL = 2000; // 2 seconds
|
||||
private readonly SemaphoreSlim RefreshSemaphore = new(1, 1);
|
||||
private DateTime LastRefreshTime = DateTime.MinValue;
|
||||
|
||||
private PeriodicTimer? _refreshTimer;
|
||||
private CancellationTokenSource _cancellationTokenSource = new();
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked by JavaScript when the page visibility changes.
|
||||
/// Callback invoked by JavaScript when the page visibility changes. This is used to start/stop the polling for new emails.
|
||||
/// </summary>
|
||||
/// <param name="isVisible">Boolean whether the page is visible or not.</param>
|
||||
/// <returns>Task.</returns>
|
||||
/// <param name="isVisible">Indicates whether the page is visible or not.</param>
|
||||
[JSInvokable]
|
||||
public async Task OnVisibilityChange(bool isVisible)
|
||||
{
|
||||
IsPageVisible = isVisible;
|
||||
if (isVisible)
|
||||
if (isVisible && DbService.Settings.AutoEmailRefresh)
|
||||
{
|
||||
// Only enable auto-refresh if the setting is enabled.
|
||||
if (DbService.Settings.AutoEmailRefresh)
|
||||
{
|
||||
await StartPolling();
|
||||
}
|
||||
|
||||
// Refresh immediately when tab becomes visible
|
||||
await ManualRefresh();
|
||||
await StartPolling();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cancel polling.
|
||||
if (PollingCancellationTokenSource is not null)
|
||||
await StopPolling();
|
||||
}
|
||||
|
||||
// Refresh immediately when tab becomes visible
|
||||
if (isVisible)
|
||||
{
|
||||
await ManualRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartPolling()
|
||||
{
|
||||
await StopPolling();
|
||||
|
||||
// Create a new CancellationTokenSource since the old one might have been cancelled
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_refreshTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(ACTIVE_TAB_REFRESH_INTERVAL));
|
||||
|
||||
try
|
||||
{
|
||||
while (await _refreshTimer.WaitForNextTickAsync(_cancellationTokenSource.Token))
|
||||
{
|
||||
await PollingCancellationTokenSource.CancelAsync();
|
||||
await LoadRecentEmailsAsync();
|
||||
}
|
||||
}
|
||||
StateHasChanged();
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal cancellation, ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopPolling()
|
||||
{
|
||||
if (_refreshTimer is not null)
|
||||
{
|
||||
await _cancellationTokenSource.CancelAsync();
|
||||
_refreshTimer.Dispose();
|
||||
_refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
PollingCancellationTokenSource?.Cancel();
|
||||
PollingCancellationTokenSource?.Dispose();
|
||||
RefreshSemaphore.Dispose();
|
||||
await StopPolling();
|
||||
_cancellationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -161,16 +181,11 @@
|
||||
}
|
||||
|
||||
// Check if email has a known SpamOK domain, if not, don't show this component.
|
||||
if (IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress))
|
||||
{
|
||||
ShowComponent = true;
|
||||
}
|
||||
ShowComponent = IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress);
|
||||
IsSpamOk = IsSpamOkDomain(EmailAddress);
|
||||
|
||||
// Set up visibility change detection
|
||||
await JsInteropService.RegisterVisibilityCallback(DotNetObjectReference.Create(this));
|
||||
|
||||
// Only enable auto-refresh if the setting is enabled.
|
||||
if (DbService.Settings.AutoEmailRefresh)
|
||||
{
|
||||
await StartPolling();
|
||||
@@ -206,65 +221,6 @@
|
||||
IsSpamOk = IsSpamOkDomain(EmailAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start the polling for new emails.
|
||||
/// </summary>
|
||||
/// <returns>Task.</returns>
|
||||
private async Task StartPolling()
|
||||
{
|
||||
if (PollingCancellationTokenSource is not null)
|
||||
{
|
||||
await PollingCancellationTokenSource.CancelAsync();
|
||||
}
|
||||
|
||||
PollingCancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
while (!PollingCancellationTokenSource.Token.IsCancellationRequested)
|
||||
{
|
||||
if (IsPageVisible)
|
||||
{
|
||||
// Only auto refresh when the tab is visible.
|
||||
await RefreshWithThrottling();
|
||||
await Task.Delay(ACTIVE_TAB_REFRESH_INTERVAL, PollingCancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal cancellation, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh the emails with throttling to prevent multiple refreshes at the same time.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private async Task RefreshWithThrottling()
|
||||
{
|
||||
if (!await RefreshSemaphore.WaitAsync(0)) // Don't wait if a refresh is in progress
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var timeSinceLastRefresh = DateTime.UtcNow - LastRefreshTime;
|
||||
if (timeSinceLastRefresh.TotalMilliseconds < ACTIVE_TAB_REFRESH_INTERVAL)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadRecentEmailsAsync();
|
||||
LastRefreshTime = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
RefreshSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the email address is from a known SpamOK domain.
|
||||
/// </summary>
|
||||
@@ -305,9 +261,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
Error = string.Empty;
|
||||
StateHasChanged();
|
||||
|
||||
// Get email prefix, which is the part before the @ symbol.
|
||||
string emailPrefix = EmailAddress.Split('@')[0];
|
||||
|
||||
@@ -319,6 +272,8 @@
|
||||
{
|
||||
await LoadAliasVaultEmails();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -440,6 +395,8 @@
|
||||
}
|
||||
|
||||
MailboxEmails = await EmailService.DecryptEmailList(MailboxEmails);
|
||||
|
||||
Error = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
// Error saving.
|
||||
IsCreating = false;
|
||||
GlobalLoadingSpinner.Hide();
|
||||
GlobalNotificationService.AddErrorMessage("Error saving credentials. Please try again.", true);
|
||||
GlobalNotificationService.AddErrorMessage("Error creating a new credential. Please try again (later) or log-out and in again.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user