mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-29 09:09:59 -05:00
Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364ade9181 | ||
|
|
8883c87dfb | ||
|
|
8e35b39197 | ||
|
|
79fd941b4e | ||
|
|
b317407bfe | ||
|
|
885630b5db | ||
|
|
cc64f5c877 | ||
|
|
7d358e0c00 | ||
|
|
eacfee78cc | ||
|
|
d4a773fc2c | ||
|
|
540124cabf | ||
|
|
6db2b33576 | ||
|
|
a132bfea65 | ||
|
|
d9f929ec63 | ||
|
|
f6f00bec3b | ||
|
|
798f8623d4 | ||
|
|
27174c05ab | ||
|
|
b8b95babe0 | ||
|
|
741b514441 | ||
|
|
f8493f2ff6 | ||
|
|
6f15026495 | ||
|
|
b9acaef46b | ||
|
|
c0d8b9941d | ||
|
|
e44b52d357 | ||
|
|
1b79662113 | ||
|
|
eb2eadf14d | ||
|
|
175760cae6 | ||
|
|
486dc67f94 | ||
|
|
1609562499 | ||
|
|
31429fb5f5 | ||
|
|
ad7e9ea5ba | ||
|
|
4c672a0ebe | ||
|
|
05a2e3942c | ||
|
|
fabb087874 | ||
|
|
c266fedd89 | ||
|
|
e64893c26c | ||
|
|
2016117d47 | ||
|
|
7fd2b9d678 | ||
|
|
1d5c5162e2 | ||
|
|
6407e1920f | ||
|
|
3bdc0f1171 | ||
|
|
a0f976f075 | ||
|
|
35104ce429 | ||
|
|
ce43c1b2c0 | ||
|
|
00cc482342 | ||
|
|
9e8521fa10 | ||
|
|
cdea2106b3 | ||
|
|
7cf03da0ee | ||
|
|
cb8f677cdf | ||
|
|
771d82e35f | ||
|
|
670dea6924 | ||
|
|
d8cfdc2123 | ||
|
|
ad8ceff2a8 | ||
|
|
1e93c0786f | ||
|
|
152ad6c973 | ||
|
|
f51dd0b0cb | ||
|
|
b06c00283d | ||
|
|
85fbb283c3 | ||
|
|
4cbedc7034 | ||
|
|
1e9dd71a7a | ||
|
|
8d9f5ba302 | ||
|
|
5e18ea163f | ||
|
|
2f7a5acf42 | ||
|
|
99cc429779 | ||
|
|
f0335b485e | ||
|
|
fc8f935092 | ||
|
|
d5cf51b5da | ||
|
|
1ae5143fb7 | ||
|
|
ac284ba71a | ||
|
|
bf68e380bc | ||
|
|
d87800f370 | ||
|
|
d65db96447 | ||
|
|
b2e344c523 | ||
|
|
2b9d7d2818 | ||
|
|
d79c2d34a5 | ||
|
|
586aafe1f1 | ||
|
|
6cb017af1c | ||
|
|
25462e38bd | ||
|
|
4ba6c365a5 | ||
|
|
aa5d229687 | ||
|
|
022370f799 | ||
|
|
050470453a | ||
|
|
5a2353fb11 | ||
|
|
c73769750c | ||
|
|
8cda9c06a2 | ||
|
|
6d16ff234a | ||
|
|
220fbe2be2 | ||
|
|
64d924d8a4 | ||
|
|
da2615096e | ||
|
|
87f2997ce8 | ||
|
|
467943ec49 | ||
|
|
902147cbf6 | ||
|
|
b165969598 | ||
|
|
39ab7558f9 | ||
|
|
600c4d32ab | ||
|
|
00b145bcf9 | ||
|
|
4c15d64ece | ||
|
|
a639a2581a | ||
|
|
51cd53ee9e | ||
|
|
7ba94b9315 | ||
|
|
28275bb6d9 | ||
|
|
953e45f62e | ||
|
|
709514ff3c | ||
|
|
12940d46d3 | ||
|
|
8f4b6a5d1b | ||
|
|
d46e582c91 | ||
|
|
7bbf986c09 | ||
|
|
2e6d5c87bc | ||
|
|
0e4d0b0f84 | ||
|
|
533362210b | ||
|
|
014064376c | ||
|
|
b26ddb809c | ||
|
|
e9a95fcc53 | ||
|
|
b3ddf94089 | ||
|
|
2213ab94da | ||
|
|
62d95d73e1 | ||
|
|
22a1fc089e | ||
|
|
9f95157c18 | ||
|
|
dec69e959d | ||
|
|
81a52dcc0e | ||
|
|
dd3818499c | ||
|
|
b141196384 | ||
|
|
2dfe8c64e5 | ||
|
|
033a513c92 | ||
|
|
0bbb504511 | ||
|
|
88fe86c19a | ||
|
|
3ccf239d84 | ||
|
|
c6ef654c87 | ||
|
|
4ef0373d31 | ||
|
|
71f91b8050 | ||
|
|
6d8315ac4e | ||
|
|
08e550f46f | ||
|
|
dfa67a00e3 | ||
|
|
cdd58773de | ||
|
|
808ecb865b | ||
|
|
277ff6c012 | ||
|
|
836f7a311e | ||
|
|
a3b232543f | ||
|
|
6077f8f377 | ||
|
|
62711f603b | ||
|
|
a59fa22fa6 | ||
|
|
cc10bcdfb8 | ||
|
|
481c283c36 | ||
|
|
947988c2ce | ||
|
|
202df3d9c3 | ||
|
|
8f3ad3d171 | ||
|
|
aa771ae1b2 | ||
|
|
158a526aee | ||
|
|
8c4e078490 | ||
|
|
98dea2c4bf | ||
|
|
db62eeec22 | ||
|
|
af1e813c48 | ||
|
|
d57ac9e743 | ||
|
|
f5e02fb784 | ||
|
|
96a7fbaf3b | ||
|
|
c749853870 | ||
|
|
8c93fceb3e | ||
|
|
fb0ef1c59a | ||
|
|
847d97a0e9 | ||
|
|
8d9c80ef61 | ||
|
|
c22ba0c2cf | ||
|
|
5e4654a968 | ||
|
|
bbf08d16d4 | ||
|
|
19c6296a86 | ||
|
|
b8ffd39f99 | ||
|
|
fe29cb7a2c | ||
|
|
4137cc4736 | ||
|
|
3d23731f0e | ||
|
|
bcbda92601 | ||
|
|
f0fca573fd | ||
|
|
4abc674970 | ||
|
|
43ed35a1be | ||
|
|
689ab0b388 | ||
|
|
a884895fae | ||
|
|
a644df1e3c | ||
|
|
48abe09415 | ||
|
|
f1bc79a9a4 | ||
|
|
ce61fc36e1 | ||
|
|
cbce527aa1 | ||
|
|
53ea7c2477 | ||
|
|
aae0846639 | ||
|
|
c2648cf2cb | ||
|
|
68c19957e0 | ||
|
|
6c79503d1f | ||
|
|
c5fc5c0e81 | ||
|
|
4170e754ea | ||
|
|
5d44a3aeff | ||
|
|
8243213028 | ||
|
|
efc422ac44 | ||
|
|
497430f729 | ||
|
|
659dc7b55d | ||
|
|
581fd945c2 | ||
|
|
8205fa9d6e | ||
|
|
cc4a6db976 | ||
|
|
efb7ae009d | ||
|
|
f29606ea94 | ||
|
|
4a586cf117 | ||
|
|
64688cd2b5 | ||
|
|
ea9c6e9aa7 | ||
|
|
889dc81404 | ||
|
|
df85846fbe | ||
|
|
5a9632f80e | ||
|
|
9105215dc8 | ||
|
|
4aa57f95b5 | ||
|
|
1112922731 | ||
|
|
2bee131ff4 | ||
|
|
75933efbdd | ||
|
|
93623a2f05 | ||
|
|
4b12518ee4 | ||
|
|
2a834eeb38 | ||
|
|
25872f08de | ||
|
|
9fc0f4d7da | ||
|
|
4e2bf10115 | ||
|
|
957a9474ec | ||
|
|
bd0d4ad2a4 | ||
|
|
88fa8a0c17 | ||
|
|
943f16789f | ||
|
|
bb91637db5 | ||
|
|
5aef4c58e2 | ||
|
|
554ea91bda | ||
|
|
d5a858d78d | ||
|
|
ff265c3a86 | ||
|
|
f2fced86b2 | ||
|
|
83f62e17b2 | ||
|
|
0f80217e74 | ||
|
|
7290ee870c | ||
|
|
27c0c9194e | ||
|
|
ae652297fa | ||
|
|
bcdb9efee8 | ||
|
|
f224507f91 | ||
|
|
dc9ba64b21 | ||
|
|
c99394416e | ||
|
|
ba2abde97d | ||
|
|
66b33a8686 | ||
|
|
1ebdce5216 | ||
|
|
c2d1ea9895 | ||
|
|
75a9278d56 | ||
|
|
b508354ac6 | ||
|
|
e1478c055f | ||
|
|
c07f0c33bb | ||
|
|
4e2b10eeab | ||
|
|
05e9285752 | ||
|
|
a76a21a935 | ||
|
|
0fd23eab59 | ||
|
|
1c1d1e1d74 | ||
|
|
9412f862eb | ||
|
|
9f7ba2eb20 | ||
|
|
101d1d485a | ||
|
|
e316836ee5 | ||
|
|
c6e3c41759 |
@@ -25,11 +25,6 @@ csharp_style_expression_bodied_indexers = true:silent
|
||||
csharp_style_expression_bodied_accessors = true:silent
|
||||
csharp_style_expression_bodied_lambdas = true:silent
|
||||
csharp_style_expression_bodied_local_functions = false:silent
|
||||
dotnet_diagnostic.SA1011.severity = none
|
||||
dotnet_diagnostic.SA1101.severity = none
|
||||
dotnet_diagnostic.SA1309.severity = none
|
||||
dotnet_diagnostic.SA1310.severity = warning
|
||||
dotnet_diagnostic.SX1309.severity = none
|
||||
|
||||
# Razor files
|
||||
[*.razor]
|
||||
@@ -65,59 +60,3 @@ indent_size = 4
|
||||
[*.xml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{cs,vb}]
|
||||
#### Naming styles ####
|
||||
|
||||
# Naming rules
|
||||
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
|
||||
# Symbol specifications
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.interface.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.types.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_field_members.required_modifiers =
|
||||
|
||||
# Naming styles
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.required_suffix =
|
||||
dotnet_naming_style.begins_with_i.word_separator =
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||
tab_width = 4
|
||||
end_of_line = crlf
|
||||
dotnet_style_coalesce_expression = false:suggestion
|
||||
dotnet_style_null_propagation = false:suggestion
|
||||
|
||||
# IDE0046: Convert to conditional expression
|
||||
dotnet_diagnostic.IDE0046.severity = silent
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
API_URL=
|
||||
JWT_KEY=
|
||||
PRIVATE_EMAIL_DOMAINS=
|
||||
SMTP_TLS_ENABLED=false
|
||||
|
||||
32
.github/workflows/docker-compose-build.yml
vendored
32
.github/workflows/docker-compose-build.yml
vendored
@@ -15,9 +15,14 @@ jobs:
|
||||
options: --privileged
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set permissions and run install.sh
|
||||
run: |
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
- name: Set up Docker Compose
|
||||
run: |
|
||||
# Build the images and start the services
|
||||
# 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
|
||||
- name: Wait for services to be up
|
||||
run: |
|
||||
@@ -28,7 +33,7 @@ jobs:
|
||||
# Test if the service on localhost:80 responds
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:80)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with 200 OK"
|
||||
echo "Service did not respond with 200 OK. Check if client app is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with 200 OK"
|
||||
@@ -37,8 +42,27 @@ jobs:
|
||||
run: |
|
||||
# Test if the service on localhost:81 responds
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:81)
|
||||
if [ "$http_code" -ne 200 ] && [ "$http_code" -ne 404 ]; then
|
||||
echo "Service did not respond with expected 200 OK or 404 Not Found"
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with expected 200 OK. Check if WebApi is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with $http_code"
|
||||
fi
|
||||
- name: Test if localhost:2525 (SmtpService) responds
|
||||
run: |
|
||||
# Test if the service on localhost:2525 responds
|
||||
if ! nc -zv localhost 2525 2>&1 | grep -q 'succeeded'; then
|
||||
echo "SmtpService did not respond on port 2525. Check if the SmtpService service is running."
|
||||
exit 1
|
||||
else
|
||||
echo "SmtpService responded on port 2525"
|
||||
fi
|
||||
- name: Test if localhost:8080 (Admin) responds
|
||||
run: |
|
||||
# Test if the service on localhost:8080 responds
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/user/login)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with expected 200 OK. Check if admin app is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with $http_code"
|
||||
|
||||
48
.github/workflows/dotnet-e2e-tests.yml
vendored
Normal file
48
.github/workflows/dotnet-e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# This workflow will build a .NET project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||
|
||||
name: .NET E2E Tests (Playwright)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps
|
||||
|
||||
- name: Run AdminTests with retry
|
||||
uses: nick-invision/retry@v2
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=AdminTests"
|
||||
|
||||
- name: Run ClientTests with retry
|
||||
uses: nick-invision/retry@v2
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=ClientTests"
|
||||
|
||||
- name: Run remaining tests with retry
|
||||
uses: nick-invision/retry@v2
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests"
|
||||
10
.github/workflows/dotnet-integration-tests.yml
vendored
10
.github/workflows/dotnet-integration-tests.yml
vendored
@@ -1,7 +1,7 @@
|
||||
# This workflow will build a .NET project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||
|
||||
name: Playwright integration tests
|
||||
name: .NET Integration Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps
|
||||
- name: Run your tests
|
||||
run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal
|
||||
- name: Run integration tests
|
||||
run: dotnet test src/Tests/AliasVault.IntegrationTests --no-build --verbosity normal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This workflow will build a .NET project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||
|
||||
name: .NET build and run tests
|
||||
name: .NET Unit Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,19 +10,19 @@ on:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
- name: Test
|
||||
- name: Run unittests
|
||||
run: dotnet test src/Tests/AliasVault.UnitTests --no-build --verbosity normal
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -373,4 +373,11 @@ FodyWeavers.xsd
|
||||
|
||||
# AliasVault specific
|
||||
# index.html is generated by the build process from index.template.html and therefore should be ignored
|
||||
src/AliasVault.WebApp/wwwroot/index.html
|
||||
src/AliasVault.Client/wwwroot/index.html
|
||||
# appsettings.Development.json is generated by the build process from appsettings.Development.template.json and therefore should be ignored
|
||||
src/AliasVault.Client/wwwroot/appsettings.Development.json
|
||||
# appsettings.Development.json is added manually if needed, it should not be committed.
|
||||
src/Tests/AliasVault.E2ETests/appsettings.Development.json
|
||||
|
||||
# .env is generated by install.sh and therefore should be ignored
|
||||
.env
|
||||
|
||||
57
.globalconfig
Normal file
57
.globalconfig
Normal file
@@ -0,0 +1,57 @@
|
||||
dotnet_diagnostic.SA1011.severity = none
|
||||
dotnet_diagnostic.SA1101.severity = none
|
||||
dotnet_diagnostic.SA1309.severity = none
|
||||
dotnet_diagnostic.SA1310.severity = warning
|
||||
dotnet_diagnostic.SX1309.severity = none
|
||||
|
||||
# Naming rules
|
||||
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
|
||||
# Symbol specifications
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.interface.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.types.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_field_members.required_modifiers =
|
||||
|
||||
# Naming styles
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.required_suffix =
|
||||
dotnet_naming_style.begins_with_i.word_separator =
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||
|
||||
dotnet_style_coalesce_expression = false:suggestion
|
||||
dotnet_style_null_propagation = false:suggestion
|
||||
|
||||
# IDE0046: Convert to conditional expression
|
||||
dotnet_diagnostic.IDE0046.severity = silent
|
||||
19
.vscode/launch.json
vendored
Normal file
19
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "C#: AliasVault.WebApp [http]",
|
||||
"type": "dotnet",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/src/AliasVault.WebApp/AliasVault.WebApp.csproj",
|
||||
"launchConfigurationId": "TargetFramework=;http"
|
||||
},
|
||||
{
|
||||
"name": "C#: AliasVault.Api [http]",
|
||||
"type": "dotnet",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/src/AliasVault.Api/AliasVault.Api.csproj",
|
||||
"launchConfigurationId": "TargetFramework=;http"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -68,3 +68,22 @@ dotnet tool install --global Microsoft.Playwright.CLI
|
||||
# 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.
|
||||
|
||||
|
||||
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"],
|
||||
"UseDebugEncryptionKey": "true"
|
||||
}
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
13
ENCRYPTION.md
Normal file
13
ENCRYPTION.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Encryption
|
||||
This document describes the encryption used in AliasVault.
|
||||
|
||||
## SRP
|
||||
The application uses the Secure Remote Password (SRP) protocol for authentication. The SRP protocol is a password-authenticated key agreement protocol. This means that the client and server can authenticate each other using a password, without sending the password over the network.
|
||||
|
||||
With the use of SRP the master password never leaves the client. The client sends a verifier to the server, which is a value derived from the master password. The server uses this verifier to authenticate the client. With this the server can authenticate the client without having ever seen the actual master password.
|
||||
|
||||
## Argon2id
|
||||
The application uses the Argon2id key derivation function to derive a key from the master password. Argon2id is a memory-hard function, which makes it difficult to perform large-scale custom hardware attacks. This makes it a good choice for password hashing.
|
||||
|
||||
## AES
|
||||
AES-256 IV is used to encrypt the data. The data is encrypted with a key derived from the master password using Argon2id. The Initialization Vector (IV) is generated randomly for each encryption.
|
||||
67
README.md
67
README.md
@@ -4,22 +4,35 @@
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-unit-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=integration tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-e2e-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
|
||||
[<img src="https://img.shields.io/sonar/coverage/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=test code coverage">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
</div>
|
||||
|
||||
> Disclaimer: This repository is currently in an alpha state and is NOT ready for production use. Critical features, such as encryption, are not yet fully implemented. AliasVault is a work in progress and as of this moment serves as a research playground. Users are welcome to explore and use this project, but please be aware that there are no guarantees regarding its security or stability. Use at your own risk!
|
||||
AliasVault is an open-source password and identity 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 open-source password manager that can generate virtual identities complete with virtual email addresses. AliasVault can be self-hosted on your own server with Docker, providing a secure and private solution for managing your online identities and passwords.
|
||||
### 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.
|
||||
- **Built-in email server**: AliasVault includes its own email server that allows you to generate virtual email addresses for each identity. Emails sent to these addresses are instantly visible in the AliasVault app.
|
||||
- **Virtual identities**: Generate virtual identities and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for attackers to link your accounts.
|
||||
- **Open-source**: The source code is available on GitHub and can be self-hosted on your own server.
|
||||
|
||||
## Features
|
||||
- **Password Management:** Securely store and manage your passwords.
|
||||
- **Virtual Identities:** Generate virtual identities with virtual (working) email addresses that are assigned to one or more passwords.
|
||||
- **Zero-knowledge architecture:** Ensures that all sensitive data is end-to-end encrypted on the client and stored in encrypted state on the database. The server never has access to your data.
|
||||
> Note: AliasVault is currently in active development and some features may not yet have been (fully) implemented. If you run into any issues, please create an issue on GitHub.
|
||||
|
||||
## Installation
|
||||
## Live demo
|
||||
A live demo of the app is available at [main.aliasvault.net](https://main.aliasvault.net) (nightly builds). You can create a free account to try it out yourself.
|
||||
|
||||
<img width="700" alt="Screenshot 2024-07-12 at 14 58 29" src="https://github.com/user-attachments/assets/57103f67-dff0-4124-9b33-62137aab5578">
|
||||
|
||||
## Installation on your own machine
|
||||
To install AliasVault on your own machine, follow the steps below. Note: the install process is tested on MacOS and Linux. It should work on Windows too, but you might need to adjust some commands.
|
||||
|
||||
### Requirements:
|
||||
- Access to a terminal
|
||||
- Docker
|
||||
- Git
|
||||
|
||||
### 1. Clone this repository.
|
||||
|
||||
@@ -28,33 +41,35 @@ AliasVault is an open-source password manager that can generate virtual identiti
|
||||
$ git clone https://github.com/lanedirt/AliasVault.git
|
||||
```
|
||||
|
||||
### 2. Run the init script to set up the .env file and generate a random encryption secret.
|
||||
This script will create a .env file in the root directory of the project if it does not yet exist and populate it with a random encryption secret.
|
||||
### 2. Run the install script.
|
||||
The script prepares the .env file, builds the Docker image, and starts the AliasVault containers.
|
||||
|
||||
```bash
|
||||
# Go to the project directory
|
||||
$ cd AliasVault
|
||||
|
||||
# Make init script executable
|
||||
$ chmod +x init.sh
|
||||
# Make install script executable
|
||||
$ chmod +x install.sh
|
||||
|
||||
# Run the init script
|
||||
$ ./init.sh
|
||||
# Run the install script
|
||||
$ ./install.sh
|
||||
```
|
||||
|
||||
### 3. Build and run the app via Docker:
|
||||
Note: if you do not wish to run the script, you can set up the environment variables and build the Docker image and containers manually instead. See the [manual setup instructions](docs/setup/1-manually-setup-docker.md) for more information.
|
||||
|
||||
```bash
|
||||
# Build and run the app via Docker Compose
|
||||
$ docker compose up -d --build --force-recreate
|
||||
```
|
||||
> Note: the container binds to port 80 by default. If you have another service running on port 80, you can change the port in the `docker-compose.yml` file.
|
||||
### 3. AliasVault is ready to use.
|
||||
The script will output the URL where the app is available. You can now open the app in your browser and create an account.
|
||||
|
||||
> Note: the container binds to port 80 for client and port 8080 for admin by default. If you have another service running on these ports, you can change the AliasVault ports in the `docker-compose.yml` file.
|
||||
|
||||
#### Note for first time build:
|
||||
- When running the app for the first time, it may take a few minutes to build the Docker image.
|
||||
- A SQLite database file will be created in `./database/aliasdb.sqlite`. This file will store all (encrypted) password vaults. It should be kept secure and not shared.
|
||||
- When running the init script for the first time, it may take a few minutes for Docker to download all dependencies. Subsequent builds will be faster.
|
||||
- A SQLite database file will be created in `./database/AliasServerDb.sqlite`. This file will store all (encrypted) password vaults. It should be kept secure and not shared.
|
||||
|
||||
After the Docker containers have started the app will be available at http://localhost:80
|
||||
#### Other useful commands:
|
||||
- To reset the admin password, run the install.sh script with the `--reset-admin-password` flag.
|
||||
- To uninstall AliasVault, make the uninstall script executable with `chmod +x uninstall.sh` first, then run the script: `./uninstall.sh`.
|
||||
This will remove all containers, images, and volumes related to AliasVault. It will keep all files and configuration intact however, so you can easily reinstall AliasVault later.
|
||||
|
||||
## Tech stack / credits
|
||||
The following technologies, frameworks and libraries are used in this project:
|
||||
@@ -63,8 +78,12 @@ The following technologies, frameworks and libraries are used in this project:
|
||||
- [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) - An open-source framework for building modern, cloud-based, internet-connected applications.
|
||||
- [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) - A lightweight, extensible, open-source and cross-platform version of the popular Entity Framework data access technology.
|
||||
- [Blazor WASM](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) - A framework for building interactive web UIs using C# instead of JavaScript. It's a single-page app framework that runs in the browser via WebAssembly.
|
||||
- [Playwright](https://playwright.dev/) - A Node.js library to automate Chromium, Firefox and WebKit with a single API. Used for end-to-end testing.
|
||||
- [Docker](https://www.docker.com/) - A platform for building, sharing, and running containerized applications.
|
||||
- [SQLite](https://www.sqlite.org/index.html) - A C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine.
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - A utility-first CSS framework for rapidly building custom designs.
|
||||
- [Flowbite](https://flowbite.com/) - A free and open-source UI component library based on Tailwind CSS.
|
||||
- [Konscious.Security.Cryptography](https://github.com/kmaragon/Konscious.Security.Cryptography) - A .NET library that implements Argon2id, a memory-hard password hashing algorithm.
|
||||
- [SRP.net](https://github.com/secure-remote-password/srp.net) - SRP6a Secure Remote Password protocol for secure password authentication.
|
||||
- [SmtpServer](https://github.com/cosullivan/SmtpServer) - A SMTP server library for .NET that is used for the virtual email address feature.
|
||||
- [MimeKit](https://github.com/jstedfast/MimeKit) - A .NET MIME creation and parser library used for the virtual email address feature.
|
||||
|
||||
100
aliasvault.sln
100
aliasvault.sln
@@ -3,10 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.10.34928.147
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault", "src\AliasVault\AliasVault.csproj", "{BD2050C0-DC26-4777-9514-546525307370}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasDb", "src\AliasDb\AliasDb.csproj", "{64F47C9A-FE69-4793-B469-28BAADEC6706}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasGenerators", "src\AliasGenerators\AliasGenerators.csproj", "{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}"
|
||||
@@ -19,7 +15,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cryptography", "src\Utiliti
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.Api", "src\AliasVault.Api\AliasVault.Api.csproj", "{B797C533-260E-4DA2-83B1-0EE4BCFE08DB}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.WebApp", "src\AliasVault.WebApp\AliasVault.WebApp.csproj", "{25248E01-5A4B-4F95-A63C-BEA01499A1C2}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.Client", "src\AliasVault.Client\AliasVault.Client.csproj", "{25248E01-5A4B-4F95-A63C-BEA01499A1C2}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.Shared", "src\AliasVault.Shared\AliasVault.Shared.csproj", "{15EFE0D0-F41B-47D7-86B7-8F840335CB82}"
|
||||
EndProject
|
||||
@@ -27,20 +23,40 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{29DE523D
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.E2ETests", "src\Tests\AliasVault.E2ETests\AliasVault.E2ETests.csproj", "{AF013D08-1BF6-4E23-87D2-37F614BE7952}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Database", "Database", "{5F7417F6-4388-49CC-9511-ED63C4A6488A}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasServerDb", "src\Databases\AliasServerDb\AliasServerDb.csproj", "{1277105D-50CD-4CE0-9C2C-549F46867E54}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasClientDb", "src\Databases\AliasClientDb\AliasClientDb.csproj", "{FE10F294-817F-477E-A24F-8597A15AF0B5}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.E2ETests.Client.Server", "src\Tests\Server\AliasVault.E2ETests.Client.Server\AliasVault.E2ETests.Client.Server.csproj", "{DD1F496F-CF10-47D1-A57F-5FA256479332}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{607945F3-9896-4544-99EC-F3496CF4D36B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CsvImportExport", "src\Utilities\CsvImportExport\CsvImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{8A477241-B96C-4174-968D-D40CB77F1ECD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.SmtpService", "src\Services\AliasVault.SmtpService\AliasVault.SmtpService.csproj", "{B095A174-E528-4D38-BEC1-D1D38B3B30C0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.IntegrationTests", "src\Tests\AliasVault.IntegrationTests\AliasVault.IntegrationTests.csproj", "{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Admin", "src\AliasVault.Admin\AliasVault.Admin.csproj", "{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InitializationCLI", "src\Utilities\InitializationCLI\InitializationCLI.csproj", "{857BCD0E-753F-437A-AF75-B995B4D9A5FE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Logging", "src\Utilities\AliasVault.Logging\AliasVault.Logging.csproj", "{FF0B0E64-1AE2-415C-A404-0EB78010821A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.RazorComponents", "src\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj", "{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.WorkerStatus", "src\Utilities\AliasVault.WorkerStatus\AliasVault.WorkerStatus.csproj", "{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{BD2050C0-DC26-4777-9514-546525307370}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BD2050C0-DC26-4777-9514-546525307370}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BD2050C0-DC26-4777-9514-546525307370}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BD2050C0-DC26-4777-9514-546525307370}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -73,6 +89,50 @@ Global
|
||||
{AF013D08-1BF6-4E23-87D2-37F614BE7952}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AF013D08-1BF6-4E23-87D2-37F614BE7952}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AF013D08-1BF6-4E23-87D2-37F614BE7952}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -82,5 +142,19 @@ Global
|
||||
{8E6A418A-B305-465D-857D-49953605C18E} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
|
||||
{427EA8E2-EA76-467E-A6BC-201EFE40C0D0} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{AF013D08-1BF6-4E23-87D2-37F614BE7952} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
|
||||
{1277105D-50CD-4CE0-9C2C-549F46867E54} = {5F7417F6-4388-49CC-9511-ED63C4A6488A}
|
||||
{FE10F294-817F-477E-A24F-8597A15AF0B5} = {5F7417F6-4388-49CC-9511-ED63C4A6488A}
|
||||
{DD1F496F-CF10-47D1-A57F-5FA256479332} = {607945F3-9896-4544-99EC-F3496CF4D36B}
|
||||
{607945F3-9896-4544-99EC-F3496CF4D36B} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
|
||||
{A9C9A606-C87E-4298-AB32-09B1884D7487} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{B095A174-E528-4D38-BEC1-D1D38B3B30C0} = {8A477241-B96C-4174-968D-D40CB77F1ECD}
|
||||
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
|
||||
{857BCD0E-753F-437A-AF75-B995B4D9A5FE} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{FF0B0E64-1AE2-415C-A404-0EB78010821A} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
services:
|
||||
wasm:
|
||||
image: aliasvault
|
||||
admin:
|
||||
image: aliasvault-admin
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/AliasVault.WebApp/Dockerfile
|
||||
dockerfile: src/AliasVault.Admin/Dockerfile
|
||||
ports:
|
||||
- "8080:8082"
|
||||
volumes:
|
||||
- ./database:/database:rw
|
||||
- ./logs:/logs:rw
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
client:
|
||||
image: aliasvault-client
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/AliasVault.Client/Dockerfile
|
||||
ports:
|
||||
- "80:8080"
|
||||
restart: always
|
||||
environment:
|
||||
- API_URL=http://localhost:81
|
||||
|
||||
server:
|
||||
image: aliasvault-server
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/AliasVault/Dockerfile
|
||||
ports:
|
||||
- "82:8082"
|
||||
volumes:
|
||||
- ./database:/database
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
api:
|
||||
image: aliasvault-api
|
||||
@@ -29,7 +31,23 @@ services:
|
||||
ports:
|
||||
- "81:8081"
|
||||
volumes:
|
||||
- ./database:/database
|
||||
- ./database:/database:rw
|
||||
- ./logs:/logs:rw
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
smtp:
|
||||
image: aliasvault-smtp
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/Services/AliasVault.SmtpService/Dockerfile
|
||||
ports:
|
||||
- "25:25"
|
||||
- "587:587"
|
||||
volumes:
|
||||
- ./database:/database:rw
|
||||
- ./logs:/logs:rw
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
5
docs/README.md
Normal file
5
docs/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Documentation
|
||||
This is the documentation for the AliasVault project.
|
||||
|
||||
## Description
|
||||
TODO: Work in progress.
|
||||
18
docs/misc/configure-sqlite-wasm.md
Normal file
18
docs/misc/configure-sqlite-wasm.md
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
```
|
||||
12
docs/misc/upgrade-ef-client-model.md
Normal file
12
docs/misc/upgrade-ef-client-model.md
Normal file
@@ -0,0 +1,12 @@
|
||||
To upgrade the AliasClientDb EF model, follow these steps:
|
||||
|
||||
1. Make changes to the AliasClientDb EF model in the `AliasClientDb` project.
|
||||
2. Create a new migration by running the following command in the `AliasClientDb` project:
|
||||
|
||||
```bash
|
||||
# Important: make sure the migration name is prefixed by the Semver version number of the release.
|
||||
# For example, if the release version is 1.0.0, the migration name should be `1.0.0-<migration-name>`.
|
||||
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.
|
||||
110
docs/setup/1-manually-setup-docker.md
Normal file
110
docs/setup/1-manually-setup-docker.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Manual Setup Instructions for AliasVault
|
||||
|
||||
This README provides step-by-step instructions for manually setting up AliasVault without using the `install.sh` script. Follow these steps if you prefer to execute all statements yourself.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed on your system
|
||||
- OpenSSL for generating random passwords
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Create .env file**
|
||||
|
||||
Copy the `.env.example` file to create a new `.env` file:
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Generate and set JWT_KEY**
|
||||
|
||||
Update the .env file and set the JWT_KEY environment variable to a random 32-char string. This key is used for JWT token generation and should be kept secure.
|
||||
|
||||
Generate a random 32 char string for the JWT:
|
||||
```
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Add the generated key to the .env file:
|
||||
|
||||
```
|
||||
JWT_KEY=your_32_char_string_here
|
||||
|
||||
3. **Set PRIVATE_EMAIL_DOMAINS**
|
||||
|
||||
Update the .env file and set the PRIVATE_EMAIL_DOMAINS value the allowed domains that can be used for email addresses. Separate multiple domains with commas.
|
||||
```
|
||||
PRIVATE_EMAIL_DOMAINS=yourdomain.com,anotherdomain.com
|
||||
```
|
||||
Replace `yourdomain.com,anotherdomain.com` with your actual allowed domains.
|
||||
|
||||
4. **Set SMTP_TLS_ENABLED**
|
||||
|
||||
Decide whether to enable TLS for email and add it to the .env file:
|
||||
```
|
||||
SMTP_TLS_ENABLED=true
|
||||
```
|
||||
Or set it to `false` if you don't want to enable TLS.
|
||||
|
||||
5. **Generate admin password**
|
||||
|
||||
Set the admin password hash in the .env file. The password hash is generated using the `InitializationCLI` utility.
|
||||
|
||||
Build the Docker image for password hashing:
|
||||
```
|
||||
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile .
|
||||
```
|
||||
|
||||
Generate the password hash:
|
||||
```
|
||||
docker run --rm initcli "<your_prefered_admin_password_here>"
|
||||
```
|
||||
|
||||
Add the password hash and generation timestamp to the .env file:
|
||||
```
|
||||
ADMIN_PASSWORD_HASH=<output_of_step_above>
|
||||
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
|
||||
```
|
||||
|
||||
6. **Build and start Docker containers**
|
||||
|
||||
Build the Docker Compose stack:
|
||||
```
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
Start the Docker Compose stack:
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
7. **Access AliasVault**
|
||||
|
||||
AliasVault should now be running. You can access it as follows:
|
||||
|
||||
- Admin Panel: http://localhost:8080/
|
||||
- Username: admin
|
||||
- Password: [Use the ADMIN_PASSWORD generated in step 5]
|
||||
|
||||
- Client Website: http://localhost:80/
|
||||
- Create your own account from here
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Make sure to save the admin password (ADMIN_PASSWORD) generated in step 5 in a secure location. It won't be shown again.
|
||||
- If you need to reset the admin password in the future, you'll need to generate a new hash and update the .env file manually.
|
||||
Afterwards restart the docker containers which will update the admin password in the database.
|
||||
- Always keep your .env file secure and do not share it, as it contains sensitive information.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter any issues during the setup:
|
||||
|
||||
1. Check the Docker logs:
|
||||
```
|
||||
docker-compose logs
|
||||
```
|
||||
2. Ensure all required ports (8080 and 80) are available and not being used by other services.
|
||||
3. Verify that all environment variables in the .env file are set correctly.
|
||||
|
||||
For further assistance, please refer to the project documentation or seek support through the appropriate channels.
|
||||
78
init.sh
78
init.sh
@@ -1,78 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Define colors for CLI output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
MAGENTA='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Define the path to the .env and .env.example files
|
||||
ENV_FILE=".env"
|
||||
ENV_EXAMPLE_FILE=".env.example"
|
||||
|
||||
# Function to generate a new 32-character JWT key
|
||||
generate_jwt_key() {
|
||||
dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32
|
||||
}
|
||||
|
||||
# Function to create .env file from .env.example if it doesn't exist
|
||||
create_env_file() {
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f "$ENV_EXAMPLE_FILE" ]; then
|
||||
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
|
||||
printf "${GREEN}> .env file created from .env.example.${NC}\n"
|
||||
else
|
||||
touch "$ENV_FILE"
|
||||
printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n"
|
||||
fi
|
||||
else
|
||||
printf "${CYAN}> .env file already exists.${NC}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check and populate the .env file with JWT_KEY
|
||||
populate_jwt_key() {
|
||||
if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
printf "${YELLOW}JWT_KEY not found or empty in $ENV_FILE. Generating a new JWT key...${NC}\n"
|
||||
JWT_KEY=$(generate_jwt_key)
|
||||
if grep -q "^JWT_KEY=" "$ENV_FILE"; then
|
||||
awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
else
|
||||
printf "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE\n"
|
||||
fi
|
||||
printf "${GREEN}> JWT_KEY has been added to $ENV_FILE.${NC}\n"
|
||||
else
|
||||
printf "${CYAN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to print the CLI logo
|
||||
print_logo() {
|
||||
printf "${MAGENTA}\n"
|
||||
printf "=========================================================\n"
|
||||
printf " _ _ __ __ _ _ \n"
|
||||
printf " /\ | (_) \ \ / / | | | \n"
|
||||
printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n"
|
||||
printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n"
|
||||
printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n"
|
||||
printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
|
||||
printf "\n"
|
||||
printf "=========================================================\n"
|
||||
printf "${NC}\n"
|
||||
}
|
||||
|
||||
# Run the functions and print status
|
||||
print_logo
|
||||
printf "${BLUE}Initializing AliasVault...${NC}\n"
|
||||
create_env_file
|
||||
populate_jwt_key
|
||||
printf "${BLUE}Initialization complete.${NC}\n"
|
||||
printf "\n"
|
||||
printf "To build the images and start the containers, run the following command:\n"
|
||||
printf "\n"
|
||||
printf "${CYAN}$ docker compose up -d --build --force-recreate${NC}\n"
|
||||
printf "\n"
|
||||
printf "\n"
|
||||
360
install.sh
Executable file
360
install.sh
Executable file
@@ -0,0 +1,360 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Define colors for CLI output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
MAGENTA='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Define the path to the .env and .env.example files
|
||||
ENV_FILE=".env"
|
||||
ENV_EXAMPLE_FILE=".env.example"
|
||||
|
||||
# Define verbose flag and reset password flag
|
||||
VERBOSE=false
|
||||
RESET_PASSWORD=false
|
||||
|
||||
# Function to parse command-line arguments
|
||||
parse_args() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
;;
|
||||
--reset-password)
|
||||
RESET_PASSWORD=true
|
||||
;;
|
||||
*)
|
||||
printf "${RED}Unknown argument: $1${NC}\n"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
# Function to generate a random admin password and store its hash in the .env file
|
||||
generate_admin_password() {
|
||||
if grep -q "^ADMIN_PASSWORD_HASH=" ".env" && [ "$RESET_PASSWORD" = false ]; then
|
||||
printf "${CYAN}> Checking admin password...${NC}\n"
|
||||
printf "${GREEN}> ADMIN_PASSWORD_HASH already exists in .env. Use --reset-password to generate a new one.${NC}\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf "${CYAN}> Generating new admin password...${NC}\n"
|
||||
|
||||
ADMIN_PASSWORD=$(openssl rand -base64 12)
|
||||
printf "${CYAN}> Building Docker image for password generation...${NC}"
|
||||
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
printf "\n"
|
||||
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile .
|
||||
else
|
||||
(
|
||||
# Run docker build and capture its output
|
||||
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . > install_build_output.log 2>&1 &
|
||||
BUILD_PID=$!
|
||||
|
||||
printf "${CYAN}"
|
||||
|
||||
# Print dots while the build is running
|
||||
while kill -0 $BUILD_PID 2>/dev/null; do
|
||||
printf "."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
printf "${NC}\n"
|
||||
|
||||
# Wait for the build to finish and capture its exit code
|
||||
wait $BUILD_PID
|
||||
BUILD_EXIT_CODE=$?
|
||||
|
||||
# If there was an error, display it
|
||||
if [ $BUILD_EXIT_CODE -ne 0 ]; then
|
||||
printf "\n${RED} An error occurred while building the Docker image for password generation. Check the output above.${NC}\n"
|
||||
printf "\n"
|
||||
cat install_build_output.log
|
||||
exit $BUILD_EXIT_CODE
|
||||
fi
|
||||
)
|
||||
fi
|
||||
|
||||
printf "${GREEN}> Docker image built successfully.${NC}\n"
|
||||
|
||||
printf "${CYAN}> Running Docker container to generate admin password hash...${NC}\n"
|
||||
|
||||
# Run the Docker container to generate the password hash
|
||||
ADMIN_PASSWORD_HASH=$(docker run --rm initcli "$ADMIN_PASSWORD" 2> install_run_output.log)
|
||||
RUN_EXIT_CODE=$?
|
||||
|
||||
if [ $RUN_EXIT_CODE -ne 0 ]; then
|
||||
printf "${RED}> Error occurred while running the Docker container. Check install_run_output.log for details.${NC}\n"
|
||||
return $RUN_EXIT_CODE
|
||||
fi
|
||||
|
||||
# Remove existing ADMIN_PASSWORD_HASH and ADMIN_PASSWORD_GENERATED if it exists
|
||||
sed -i '' '/^ADMIN_PASSWORD_HASH=/d' .env
|
||||
sed -i '' '/^ADMIN_PASSWORD_GENERATED=/d' .env
|
||||
|
||||
# Append new entries
|
||||
echo "ADMIN_PASSWORD_HASH=$ADMIN_PASSWORD_HASH" >> .env
|
||||
echo "ADMIN_PASSWORD_GENERATED=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> .env
|
||||
|
||||
printf "${GREEN}> New admin password generated and hash stored in .env${NC}\n"
|
||||
}
|
||||
|
||||
# Function to restart Docker containers
|
||||
restart_docker_containers() {
|
||||
printf "${CYAN}> Restarting Docker containers...${NC}\n"
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
printf "${GREEN}> Docker containers restarted successfully.${NC}\n"
|
||||
}
|
||||
|
||||
# Function to generate a new 32-character JWT key
|
||||
generate_jwt_key() {
|
||||
dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32
|
||||
}
|
||||
|
||||
# Function to create .env file from .env.example if it doesn't exist
|
||||
create_env_file() {
|
||||
printf "${CYAN}> Creating .env file...${NC}\n"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f "$ENV_EXAMPLE_FILE" ]; then
|
||||
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
|
||||
printf "${GREEN}> .env file created from .env.example.${NC}\n"
|
||||
else
|
||||
touch "$ENV_FILE"
|
||||
printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n"
|
||||
fi
|
||||
else
|
||||
printf "${GREEN}> .env file already exists.${NC}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check and populate the .env file with API_URL
|
||||
populate_api_url() {
|
||||
printf "${CYAN}> Checking API_URL...${NC}\n"
|
||||
if ! grep -q "^API_URL=" "$ENV_FILE" || [ -z "$(grep "^API_URL=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
DEFAULT_API_URL="http://localhost:81"
|
||||
read -p "Enter the base URL where the API will be hosted (press Enter for default: $DEFAULT_API_URL): " USER_API_URL
|
||||
API_URL=${USER_API_URL:-$DEFAULT_API_URL}
|
||||
if grep -q "^API_URL=" "$ENV_FILE"; then
|
||||
awk -v url="$API_URL" '/^API_URL=/ {$0="API_URL="url} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
else
|
||||
echo "API_URL=${API_URL}" >> "$ENV_FILE"
|
||||
fi
|
||||
printf "${GREEN}> API_URL has been set to $API_URL in $ENV_FILE.${NC}\n"
|
||||
else
|
||||
API_URL=$(grep "^API_URL=" "$ENV_FILE" | cut -d '=' -f2)
|
||||
printf "${GREEN}> API_URL already exists in $ENV_FILE with value: $API_URL${NC}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check and populate the .env file with JWT_KEY
|
||||
populate_jwt_key() {
|
||||
printf "${CYAN}> Checking JWT_KEY...${NC}\n"
|
||||
if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
JWT_KEY=$(generate_jwt_key)
|
||||
if grep -q "^JWT_KEY=" "$ENV_FILE"; then
|
||||
awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
else
|
||||
echo "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE"
|
||||
fi
|
||||
printf "${GREEN}> JWT_KEY has been generated and added to $ENV_FILE.${NC}\n"
|
||||
else
|
||||
printf "${GREEN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to ask the user for PRIVATE_EMAIL_DOMAINS
|
||||
set_private_email_domains() {
|
||||
printf "${CYAN}> Setting PRIVATE_EMAIL_DOMAINS...${NC}\n"
|
||||
if ! grep -q "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
printf "Please enter the domains that should be allowed to receive email, separated by commas (press Enter to disable email support): "
|
||||
read -r private_email_domains
|
||||
|
||||
# Set default value if user input is empty
|
||||
private_email_domains=${private_email_domains:-"DISABLED.TLD"}
|
||||
|
||||
if grep -q "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE"; then
|
||||
awk -v domains="$private_email_domains" '/^PRIVATE_EMAIL_DOMAINS=/ {$0="PRIVATE_EMAIL_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
else
|
||||
echo "PRIVATE_EMAIL_DOMAINS=${private_email_domains}" >> "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if [ "$private_email_domains" = "DISABLED.TLD" ]; then
|
||||
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS has been set to 'DISABLED.TLD' in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n"
|
||||
else
|
||||
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS has been set to '${private_email_domains}' in $ENV_FILE.${NC}\n"
|
||||
fi
|
||||
else
|
||||
private_email_domains=$(grep "^private_email_domains=" "$ENV_FILE" | cut -d '=' -f2)
|
||||
if [ "$private_email_domains" = "DISABLED.TLD" ]; then
|
||||
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS already exists in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n"
|
||||
else
|
||||
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS already exists in $ENV_FILE with value: ${private_email_domains}${NC}\n"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to ask the user if TLS should be enabled for email
|
||||
set_smtp_tls_enabled() {
|
||||
printf "${CYAN}> Setting SMTP_TLS_ENABLED...${NC}\n"
|
||||
if ! grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE" || [ -z "$(grep "^SMTP_TLS_ENABLED=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
printf "Do you want TLS enabled for email? (yes/no): "
|
||||
read -r tls_enabled
|
||||
tls_enabled=$(echo "$tls_enabled" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$tls_enabled" = "yes" ] || [ "$tls_enabled" = "y" ]; then
|
||||
tls_enabled="true"
|
||||
else
|
||||
tls_enabled="false"
|
||||
fi
|
||||
if grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE"; then
|
||||
awk -v tls="$tls_enabled" '/^SMTP_TLS_ENABLED=/ {$0="SMTP_TLS_ENABLED="tls} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
else
|
||||
echo "SMTP_TLS_ENABLED=${tls_enabled}" >> "$ENV_FILE"
|
||||
fi
|
||||
printf "${GREEN}> SMTP_TLS_ENABLED has been set to ${tls_enabled} in $ENV_FILE.${NC}\n"
|
||||
else
|
||||
printf "${GREEN}> SMTP_TLS_ENABLED already exists and has a value in $ENV_FILE.${NC}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to build and run the Docker Compose stack with muted output unless an error occurs, showing progress indication
|
||||
build_and_run_docker_compose() {
|
||||
printf "${CYAN}> Building Docker Compose stack..."
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
docker compose build
|
||||
else
|
||||
(
|
||||
# Run docker compose build and capture its output
|
||||
docker compose build > install_compose_build_output.log 2>&1 &
|
||||
BUILD_PID=$!
|
||||
|
||||
# Print dots while the build is running
|
||||
while kill -0 $BUILD_PID 2>/dev/null; do
|
||||
printf "."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
printf "${NC}"
|
||||
|
||||
# Wait for the build to finish and capture its exit code
|
||||
wait $BUILD_PID
|
||||
BUILD_EXIT_CODE=$?
|
||||
|
||||
# If there was an error, display it
|
||||
if [ $BUILD_EXIT_CODE -ne 0 ]; then
|
||||
printf "\n${RED}> An error occurred while building the Docker Compose stack. Check install_compose_build_output.log for details.${NC}\n"
|
||||
exit $BUILD_EXIT_CODE
|
||||
fi
|
||||
)
|
||||
fi
|
||||
|
||||
printf "\n${GREEN}> Docker Compose stack built successfully.${NC}\n"
|
||||
|
||||
printf "${CYAN}> Starting Docker Compose stack...${NC}\n"
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
docker compose up -d
|
||||
else
|
||||
docker compose up -d > install_compose_up_output.log 2>&1
|
||||
fi
|
||||
UP_EXIT_CODE=$?
|
||||
|
||||
if [ $UP_EXIT_CODE -ne 0 ]; then
|
||||
printf "${RED}> An error occurred while starting the Docker Compose stack. Check install_compose_up_output.log for details.${NC}\n"
|
||||
exit $UP_EXIT_CODE
|
||||
fi
|
||||
|
||||
printf "${GREEN}> Docker Compose stack started successfully.${NC}\n"
|
||||
}
|
||||
|
||||
# Function to print the CLI logo
|
||||
print_logo() {
|
||||
printf "${MAGENTA}\n"
|
||||
printf "=========================================================\n"
|
||||
printf " _ _ __ __ _ _ \n"
|
||||
printf " /\ | (_) \ \ / / | | | \n"
|
||||
printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n"
|
||||
printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n"
|
||||
printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n"
|
||||
printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
|
||||
printf "\n"
|
||||
printf " Install Script\n"
|
||||
printf "=========================================================\n"
|
||||
printf "${NC}\n"
|
||||
}
|
||||
|
||||
# Main execution flow
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
if [ "$RESET_PASSWORD" = true ]; then
|
||||
print_logo
|
||||
generate_admin_password
|
||||
if [ $? -eq 0 ]; then
|
||||
restart_docker_containers
|
||||
fi
|
||||
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
printf "\n"
|
||||
printf "${GREEN}The admin password is successfully reset!${NC}\n"
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
printf "\n"
|
||||
else
|
||||
# Run the original initialization process
|
||||
print_logo
|
||||
|
||||
printf "${YELLOW}+++ Initializing .env file +++${NC}\n"
|
||||
printf "\n"
|
||||
create_env_file || exit $?
|
||||
populate_api_url || exit $?
|
||||
populate_jwt_key || exit $?
|
||||
set_private_email_domains || exit $?
|
||||
set_smtp_tls_enabled || exit $?
|
||||
generate_admin_password || exit $?
|
||||
printf "\n${YELLOW}+++ Building Docker containers +++${NC}\n"
|
||||
printf "\n"
|
||||
build_and_run_docker_compose || exit $?
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
printf "\n"
|
||||
printf "${GREEN}AliasVault is successfully installed!${NC}\n"
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
printf "\n"
|
||||
fi
|
||||
|
||||
printf "${CYAN}To configure the server, login to the admin panel:${NC}\n"
|
||||
printf "\n"
|
||||
if [ "$ADMIN_PASSWORD" != "" ]; then
|
||||
printf "Admin Panel: http://localhost:8080/\n"
|
||||
printf "Username: admin\n"
|
||||
printf "Password: $ADMIN_PASSWORD\n"
|
||||
printf "\n"
|
||||
printf "${YELLOW}(!) Caution: Make sure to backup the above credentials in a safe place, they won't be shown again!${NC}\n"
|
||||
printf "\n"
|
||||
else
|
||||
printf "Admin Panel: http://localhost:8080/\n"
|
||||
printf "Username: admin\n"
|
||||
printf "Password: (Previously set. Run this command with --reset-password to generate a new one.)\n"
|
||||
printf "\n"
|
||||
fi
|
||||
printf "${CYAN}===========================${NC}\n"
|
||||
printf "\n"
|
||||
printf "${CYAN}In order to start using AliasVault and create your own vault, log into the client website:${NC}\n"
|
||||
printf "\n"
|
||||
printf "Client Website: http://localhost:80/\n"
|
||||
printf "You can create your own account from there.\n"
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
}
|
||||
|
||||
# Run the main function
|
||||
main "$@"
|
||||
@@ -1,144 +0,0 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="AliasDbContext.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 AliasDb;
|
||||
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The AliasDbContext class.
|
||||
/// </summary>
|
||||
public class AliasDbContext : IdentityDbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AliasDbContext"/> class.
|
||||
/// </summary>
|
||||
public AliasDbContext()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AliasDbContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">DbContextOptions.</param>
|
||||
public AliasDbContext(DbContextOptions<AliasDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Identities DbSet.
|
||||
/// </summary>
|
||||
public DbSet<Identity> Identities { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Logins DbSet.
|
||||
/// </summary>
|
||||
public DbSet<Login> Logins { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Passwords DbSet.
|
||||
/// </summary>
|
||||
public DbSet<Password> Passwords { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Services DbSet.
|
||||
/// </summary>
|
||||
public DbSet<Service> Services { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the AspNetUserRefreshTokens DbSet.
|
||||
/// </summary>
|
||||
public DbSet<AspNetUserRefreshToken> AspNetUserRefreshTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The OnModelCreating method.
|
||||
/// </summary>
|
||||
/// <param name="builder">ModelBuilder instance.</param>
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
foreach (var entity in builder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entity.GetProperties())
|
||||
{
|
||||
// NOTE: This is a workaround for SQLite. Add conditional check if SQLite is used.
|
||||
// NOTE: SQL server doesn't need this override.
|
||||
|
||||
// SQLite does not support varchar(max) so we use TEXT.
|
||||
if (property.ClrType == typeof(string) && property.GetMaxLength() == null)
|
||||
{
|
||||
property.SetColumnType("TEXT");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure Identity - Login relationship
|
||||
builder.Entity<Login>()
|
||||
.HasOne(l => l.Identity)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.IdentityId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Configure the Login - UserId entity
|
||||
builder.Entity<Login>()
|
||||
.HasOne(p => p.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.UserId)
|
||||
.IsRequired();
|
||||
|
||||
// Configure Login - Service relationship
|
||||
builder.Entity<Login>()
|
||||
.HasOne(l => l.Service)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.ServiceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Configure Login - Password relationship
|
||||
builder.Entity<Login>()
|
||||
.HasMany(l => l.Passwords)
|
||||
.WithOne(p => p.Login)
|
||||
.HasForeignKey(p => p.LoginId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Configure Identity - DefaultPassword relationship
|
||||
builder.Entity<Identity>()
|
||||
.HasOne(i => i.DefaultPassword)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.DefaultPasswordId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Configure the User - AspNetUserRefreshToken entity
|
||||
builder.Entity<AspNetUserRefreshToken>()
|
||||
.HasOne(p => p.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.UserId)
|
||||
.IsRequired();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the connection string if it is not already configured.
|
||||
/// </summary>
|
||||
/// <param name="optionsBuilder">DbContextOptionsBuilder instance.</param>
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
// If the options are not already configured, use the appsettings.json file.
|
||||
if (!optionsBuilder.IsConfigured)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json")
|
||||
.Build();
|
||||
|
||||
optionsBuilder
|
||||
.UseSqlite(configuration.GetConnectionString("AliasDbContext"))
|
||||
.UseLazyLoadingProxies();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
namespace AliasGenerators.Implementations;
|
||||
namespace AliasGenerators.Password;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for password generators.
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasGenerators.Password.Implementations;
|
||||
|
||||
using AliasGenerators.Implementations;
|
||||
using AliasGenerators.Password;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of IPasswordGenerator which generates passwords using the SpamOK library.
|
||||
|
||||
51
src/AliasVault.Admin/AliasVault.Admin.csproj
Normal file
51
src/AliasVault.Admin/AliasVault.Admin.csproj
Normal file
@@ -0,0 +1,51 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>aspnet-AliasVault.Admin-1DAADE35-C01B-43BB-B440-AA5E1E0B672D</UserSecretsId>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<DocumentationFile>bin\Debug\net8.0\AliasVault.Admin.xml</DocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<DocumentationFile>bin\Release\net8.0\AliasVault.Admin.xml</DocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="..\stylecop.json" Link="stylecop.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
5
src/AliasVault.Admin/Auth/Components/Logo.razor
Normal file
5
src/AliasVault.Admin/Auth/Components/Logo.razor
Normal file
@@ -0,0 +1,5 @@
|
||||
<a href="/" class="flex items-center justify-center mb-8 text-2xl font-semibold lg:mb-10 dark:text-white">
|
||||
<img src="horizontal-logo-cropped.png" alt="AliasVault" class="img-fluid" style="max-width: 330px;"/>
|
||||
<span class="ps-2 self-center hidden sm:flex text-lg font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
|
||||
|
||||
</a>
|
||||
36
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor
Normal file
36
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor
Normal file
@@ -0,0 +1,36 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using AliasVault.Admin.Auth.Components
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-6 pt-8 mx-auto md:h-screen pt:mt-0 dark:bg-gray-900">
|
||||
<Logo />
|
||||
<div class="w-full max-w-xl p-6 space-y-4 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
18
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor.css
Normal file
18
src/AliasVault.Admin/Auth/Layout/AuthLayout.razor.css
Normal file
@@ -0,0 +1,18 @@
|
||||
#blazor-error-ui {
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
71
src/AliasVault.Admin/Auth/Pages/AuthBase.cs
Normal file
71
src/AliasVault.Admin/Auth/Pages/AuthBase.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="AuthBase.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Auth.Pages;
|
||||
|
||||
using AliasServerDb;
|
||||
using AliasVault.Admin.Main.Components.Alerts;
|
||||
using AliasVault.Admin.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Base auth page that all pages that are part of the auth (non-logged in part of website) should inherit from.
|
||||
/// All pages that inherit from this class will require the user to be logged out. If user is logged in they
|
||||
/// are automatically redirected to index page.
|
||||
/// </summary>
|
||||
public class AuthBase : OwningComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the logger.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected ILogger<Login> Logger { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the navigation service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected NavigationService NavigationService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sign in manager.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected SignInManager<AdminUser> SignInManager { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user manager.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected UserManager<AdminUser> UserManager { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authentication state provider.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets object which holds server validation errors to show in the UI.
|
||||
/// </summary>
|
||||
protected ServerValidationErrors ServerValidationErrors { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authState.User;
|
||||
|
||||
// Redirect to home if the user is already authenticated
|
||||
if (SignInManager.IsSignedIn(user))
|
||||
{
|
||||
NavigationService.RedirectTo("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/AliasVault.Admin/Auth/Pages/ForgotPassword.razor
Normal file
8
src/AliasVault.Admin/Auth/Pages/ForgotPassword.razor
Normal file
@@ -0,0 +1,8 @@
|
||||
@page "/user/forgot-password"
|
||||
|
||||
<LayoutPageTitle>Forgot your password?</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Forgot your password?
|
||||
</h2>
|
||||
<p>If you have forgotten your password, please consult with the server admin.</p>
|
||||
8
src/AliasVault.Admin/Auth/Pages/Lockout.razor
Normal file
8
src/AliasVault.Admin/Auth/Pages/Lockout.razor
Normal file
@@ -0,0 +1,8 @@
|
||||
@page "/user/lockout"
|
||||
|
||||
<LayoutPageTitle>Locked out</LayoutPageTitle>
|
||||
|
||||
<header>
|
||||
<h1 class="text-danger">Locked out</h1>
|
||||
<p class="text-danger">This account has been locked out, please try again later.</p>
|
||||
</header>
|
||||
97
src/AliasVault.Admin/Auth/Pages/Login.razor
Normal file
97
src/AliasVault.Admin/Auth/Pages/Login.razor
Normal file
@@ -0,0 +1,97 @@
|
||||
@page "/user/login"
|
||||
|
||||
<LayoutPageTitle>Log in</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Sign in to AliasVault Admin
|
||||
</h2>
|
||||
|
||||
<ServerValidationErrors @ref="ServerValidationErrors" />
|
||||
|
||||
<EditForm Model="Input" FormName="LoginForm" OnValidSubmit="LoginUser" class="mt-8 space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<div>
|
||||
<label asp-for="Input.UserName" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your username</label>
|
||||
<InputTextField id="username" @bind-Value="Input.UserName" placeholder="username" />
|
||||
<ValidationMessage For="() => Input.UserName"/>
|
||||
</div>
|
||||
<div>
|
||||
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
|
||||
<InputTextField id="password" @bind-Value="Input.Password" type="password" placeholder="••••••••" />
|
||||
<ValidationMessage For="() => Input.Password"/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="remember" aria-describedby="remember" name="remember" type="checkbox" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="remember" class="font-medium text-gray-900 dark:text-white">Remember me</label>
|
||||
</div>
|
||||
<a href="/user/forgot-password" class="ml-auto text-sm text-primary-700 hover:underline dark:text-primary-500">Lost Password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Login to your account</button>
|
||||
</EditForm>
|
||||
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method))
|
||||
{
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs in the user.
|
||||
/// </summary>
|
||||
protected async Task LoginUser()
|
||||
{
|
||||
ServerValidationErrors.Clear();
|
||||
|
||||
var result = await SignInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: true);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User logged in.");
|
||||
NavigationService.RedirectTo(ReturnUrl ?? "/");
|
||||
}
|
||||
else if (result.RequiresTwoFactor)
|
||||
{
|
||||
NavigationService.RedirectTo(
|
||||
"user/loginWith2fa",
|
||||
new Dictionary<string, object?> { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User account locked out.");
|
||||
NavigationService.RedirectTo("user/lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
ServerValidationErrors.AddError("Error: Invalid login attempt.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required] public string UserName { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Display(Name = "Remember me?")] public bool RememberMe { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
98
src/AliasVault.Admin/Auth/Pages/LoginWith2fa.razor
Normal file
98
src/AliasVault.Admin/Auth/Pages/LoginWith2fa.razor
Normal file
@@ -0,0 +1,98 @@
|
||||
@page "/user/loginWith2fa"
|
||||
|
||||
<LayoutPageTitle>Two-factor authentication</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Two-factor authentication
|
||||
</h2>
|
||||
|
||||
<ServerValidationErrors @ref="ServerValidationErrors" />
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">Your login is protected with an authenticator app. Enter your authenticator code below.</p>
|
||||
<div class="w-full max-w-md">
|
||||
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
|
||||
<input type="hidden" name="ReturnUrl" value="@ReturnUrl"/>
|
||||
<input type="hidden" name="RememberMe" value="@RememberMe"/>
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="two-factor-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authenticator code</label>
|
||||
<InputText @bind-Value="Input.TwoFactorCode" id="two-factor-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off"/>
|
||||
<ValidationMessage For="() => Input.TwoFactorCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<InputCheckbox @bind-Value="Input.RememberMachine" id="remember-machine" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-primary-600 dark:ring-offset-gray-800"/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="remember-machine" class="font-medium text-gray-900 dark:text-white">Remember this machine</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-gray-700 dark:text-gray-300">
|
||||
Don't have access to your authenticator device? You can
|
||||
<a href="user/loginWithRecoveryCode?ReturnUrl=@ReturnUrl" class="text-primary-600 hover:underline dark:text-primary-500">log in with a recovery code</a>.
|
||||
</p>
|
||||
|
||||
@code {
|
||||
private AdminUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery] private bool RememberMe { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
// Ensure the user has gone through the username & password screen first
|
||||
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
|
||||
throw new InvalidOperationException("Unable to load two-factor authentication user.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits the form.
|
||||
/// </summary>
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
ServerValidationErrors.Clear();
|
||||
|
||||
var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
|
||||
NavigationService.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
|
||||
NavigationService.RedirectTo("user/lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
|
||||
ServerValidationErrors.AddError("Error: Invalid authenticator code.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Authenticator code")]
|
||||
public string? TwoFactorCode { get; set; }
|
||||
|
||||
[Display(Name = "Remember this machine")]
|
||||
public bool RememberMachine { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
81
src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor
Normal file
81
src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor
Normal file
@@ -0,0 +1,81 @@
|
||||
@page "/user/loginWithRecoveryCode"
|
||||
|
||||
<LayoutPageTitle>Recovery code verification</LayoutPageTitle>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Recovery code verification
|
||||
</h2>
|
||||
|
||||
<ServerValidationErrors @ref="ServerValidationErrors" />
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
You have requested to log in with a recovery code. This login will not be remembered until you provide
|
||||
an authenticator app code at log in or disable 2FA and log in again.
|
||||
</p>
|
||||
<div class="w-full max-w-md">
|
||||
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="recovery-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Recovery Code</label>
|
||||
<InputText @bind-Value="Input.RecoveryCode" id="recovery-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off" placeholder="Enter your recovery code"/>
|
||||
<ValidationMessage For="() => Input.RecoveryCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
|
||||
</div>
|
||||
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AdminUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Ensure the user has gone through the username & password screen first
|
||||
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
|
||||
throw new InvalidOperationException("Unable to load two-factor authentication user.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits the form.
|
||||
/// </summary>
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
ServerValidationErrors.Clear();
|
||||
|
||||
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
|
||||
|
||||
var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId);
|
||||
NavigationService.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User account locked out.");
|
||||
NavigationService.RedirectTo("user/lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId);
|
||||
ServerValidationErrors.AddError("Error: Invalid recovery code entered.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Recovery Code")]
|
||||
public string RecoveryCode { get; set; } = "";
|
||||
}
|
||||
|
||||
}
|
||||
35
src/AliasVault.Admin/Auth/Pages/Logout.razor
Normal file
35
src/AliasVault.Admin/Auth/Pages/Logout.razor
Normal file
@@ -0,0 +1,35 @@
|
||||
@page "/user/logout"
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Sign out the user.
|
||||
// NOTE: the try/catch below is a workaround for the issue that the sign out does not work when
|
||||
// the server session is already started.
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
GlobalNotificationService.ClearMessages();
|
||||
|
||||
// Redirect to the home page with hard refresh.
|
||||
NavigationService.RedirectTo("/", true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hard refresh current page if sign out fails. When an interactive server session is already started
|
||||
// the sign out will fail because it tries to mutate cookies which is only possible when the server
|
||||
// session is not started yet.
|
||||
NavigationService.RedirectTo(NavigationService.Uri, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Redirect to the home page with hard refresh.
|
||||
NavigationService.RedirectTo("/", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/AliasVault.Admin/Auth/Pages/_Imports.razor
Normal file
11
src/AliasVault.Admin/Auth/Pages/_Imports.razor
Normal file
@@ -0,0 +1,11 @@
|
||||
@inherits AuthBase
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using AliasVault.Admin.Auth.Components
|
||||
@using AliasVault.Admin.Auth.Layout
|
||||
@using AliasVault.Admin.Main.Components.Alerts
|
||||
@using AliasVault.Admin.Main.Components.Layout
|
||||
@using AliasVault.Admin.Main.Layout
|
||||
@using AliasVault.Admin.Services
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@layout AuthLayout
|
||||
@@ -0,0 +1,67 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="RevalidatingAuthenticationStateProvider.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Auth.Providers;
|
||||
|
||||
using System.Security.Claims;
|
||||
using AliasServerDb;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
/// <summary>
|
||||
/// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
|
||||
/// every 30 minutes an interactive circuit is connected.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">ILoggerFactory instance.</param>
|
||||
/// <param name="scopeFactory">IServiceScopeFactory instance.</param>
|
||||
/// <param name="options">IOptions instance.</param>
|
||||
internal sealed class RevalidatingAuthenticationStateProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<IdentityOptions> options)
|
||||
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the revalidation interval.
|
||||
/// </summary>
|
||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Validate the authentication state.
|
||||
/// </summary>
|
||||
/// <param name="authenticationState">AuthenticationState instance.</param>
|
||||
/// <param name="cancellationToken">CancellationToken.</param>
|
||||
/// <returns>Boolean indicating whether the currently logged on user is still valid.</returns>
|
||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get the user manager from a new scope to ensure it fetches fresh data
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AdminUser>>();
|
||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<AdminUser> userManager, ClaimsPrincipal principal)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(principal);
|
||||
if (user is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!userManager.SupportsUserSecurityStamp)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
|
||||
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||
return principalStamp == userStamp;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@using System.Net.Http
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@@ -7,7 +7,6 @@
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using AliasVault
|
||||
@using AliasVault.Components
|
||||
@using AliasVault.Components.Shared
|
||||
@using AliasVault.Components.Pages.Aliases
|
||||
@using AliasVault.Admin
|
||||
@using AliasVault.Admin.Main
|
||||
@using AliasServerDb
|
||||
26
src/AliasVault.Admin/Config.cs
Normal file
26
src/AliasVault.Admin/Config.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="Config.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration class for the Admin project with values loaded from environment variables.
|
||||
/// </summary>
|
||||
public class Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the admin password hash which is generated by install.sh and will be set
|
||||
/// as the default password for the admin user.
|
||||
/// </summary>
|
||||
public string AdminPasswordHash { get; set; } = "false";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last time the password was changed. This is used to check if the
|
||||
/// password hash generated by install.sh should replace the current password hash if user already exists.
|
||||
/// </summary>
|
||||
public DateTime LastPasswordChanged { get; set; } = DateTime.MinValue;
|
||||
}
|
||||
32
src/AliasVault.Admin/Dockerfile
Normal file
32
src/AliasVault.Admin/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8082
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy the project files and restore dependencies
|
||||
COPY ["src/AliasVault.Admin/AliasVault.Admin.csproj", "src/AliasVault.Admin/"]
|
||||
COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"]
|
||||
RUN dotnet restore "src/AliasVault.Admin/AliasVault.Admin.csproj"
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the WebApi project
|
||||
WORKDIR "/src/src/AliasVault.Admin"
|
||||
RUN dotnet build "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/build
|
||||
|
||||
# Publish the application to the /app/publish directory in the container
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
EXPOSE 8082
|
||||
ENV ASPNETCORE_URLS=http://+:8082
|
||||
ENTRYPOINT ["dotnet", "AliasVault.Admin.dll"]
|
||||
74
src/AliasVault.Admin/Main/App.razor
Normal file
74
src/AliasVault.Admin/Main/App.razor
Normal file
@@ -0,0 +1,74 @@
|
||||
@inject VersionedContentService VersionService
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="@VersionService.GetVersionedPath("css/tailwind.css")"/>
|
||||
<link rel="stylesheet" href="@VersionService.GetVersionedPath("css/app.css")"/>
|
||||
<link rel="stylesheet" href="AliasVault.Admin.styles.css"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<HeadOutlet @rendermode="RenderModeForPage"/>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 dark:bg-gray-800">
|
||||
<Routes @rendermode="RenderModeForPage"/>
|
||||
<script src="@VersionService.GetVersionedPath("lib/qrcode.min.js")"></script>
|
||||
<script src="@VersionService.GetVersionedPath("js/dark-mode.js")"></script>
|
||||
<script src="@VersionService.GetVersionedPath("js/utilities.js")"></script>
|
||||
|
||||
<script>
|
||||
window.initTopMenu = function() {
|
||||
initDarkModeSwitcher();
|
||||
};
|
||||
|
||||
window.registerClickOutsideHandler = (dotNetHelper) => {
|
||||
document.addEventListener('click', (event) => {
|
||||
const menu = document.getElementById('userMenuDropdown');
|
||||
const menuButton = document.getElementById('userMenuDropdownButton');
|
||||
if (menu && !menu.contains(event.target) && !menuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
const mobileMenuButton = document.getElementById('toggleMobileMenuButton');
|
||||
if (mobileMenu && !mobileMenu.contains(event.target) && !mobileMenuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.clipboardCopy = {
|
||||
copyText: function (text) {
|
||||
navigator.clipboard.writeText(text).then(function () { })
|
||||
.catch(function (error) {
|
||||
alert(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.isFunctionDefined = function(functionName) {
|
||||
return typeof window[functionName] === 'function';
|
||||
};
|
||||
|
||||
// Primarily used by E2E tests.
|
||||
window.blazorNavigate = (url) => {
|
||||
Blazor.navigateTo(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
private IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/user")
|
||||
? null
|
||||
: InteractiveServer;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
@using Microsoft.IdentityModel.Tokens
|
||||
@inherits ComponentBase
|
||||
@inherits ComponentBase
|
||||
|
||||
@if (Message.IsNullOrEmpty())
|
||||
@if (Message == string.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
@using Microsoft.IdentityModel.Tokens
|
||||
@inherits ComponentBase
|
||||
@inherits ComponentBase
|
||||
|
||||
@if (Message.IsNullOrEmpty())
|
||||
@if (Message == string.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@implements IDisposable
|
||||
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "success")
|
||||
{
|
||||
<AlertMessageSuccess Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "error")
|
||||
{
|
||||
<AlertMessageError Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<KeyValuePair<string, string>> Messages { get; set; } = new();
|
||||
private bool _onChangeSubscribed = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
// We subscribe to the OnChange event of the PortalMessageService to update the UI when a new message is added
|
||||
RefreshAddMessages();
|
||||
GlobalNotificationService.OnChange += RefreshAddMessages;
|
||||
_onChangeSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// We unsubscribe from the OnChange event of the PortalMessageService when the component is disposed
|
||||
if (_onChangeSubscribed)
|
||||
{
|
||||
GlobalNotificationService.OnChange -= RefreshAddMessages;
|
||||
_onChangeSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the messages by adding any new messages from the PortalMessageService.
|
||||
/// </summary>
|
||||
private void RefreshAddMessages()
|
||||
{
|
||||
// We retrieve any additional messages from the GlobalNotificationService that we do not yet have.
|
||||
var newMessages = GlobalNotificationService.GetMessagesForDisplay();
|
||||
foreach (var message in newMessages)
|
||||
{
|
||||
if (!Messages.Exists(m => m.Key == message.Key && m.Value == message.Value))
|
||||
{
|
||||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove messages that are no longer in the GlobalNotificationService and have already been displayed.
|
||||
var messagesToRemove = Messages.Where(m => !newMessages.Exists(nm => nm.Key == m.Key && nm.Value == m.Value)).ToList();
|
||||
foreach (var message in messagesToRemove)
|
||||
{
|
||||
Messages.Remove(message);
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@if (_errors.Any())
|
||||
{
|
||||
@foreach (var error in _errors)
|
||||
{
|
||||
<AlertMessageError Message="@error" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly List<string> _errors = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds a server validation error.
|
||||
/// </summary>
|
||||
public void AddError(string error)
|
||||
{
|
||||
_errors.Add(error);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the server validation errors.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_errors.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
@inherits ComponentBase
|
||||
@using Microsoft.IdentityModel.Tokens
|
||||
|
||||
<nav class="flex mb-5" aria-label="RecentEmails">
|
||||
<nav class="flex mb-5">
|
||||
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
|
||||
<li class="inline-flex items-center">
|
||||
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-500">
|
||||
@@ -11,7 +10,7 @@
|
||||
</li>
|
||||
@foreach (var item in BreadcrumbItems)
|
||||
{
|
||||
@if (!item.Url.IsNullOrEmpty())
|
||||
@if (item.Url is not null)
|
||||
{
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
@@ -0,0 +1,9 @@
|
||||
<PageTitle>@ChildContent - AliasVault Admin</PageTitle>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Child content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment ChildContent { get; set; } = default!;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
<div class="loading" style="display:@(IsVisible ? "block" : "none");">
|
||||
<div class="spinner">
|
||||
<div class="rect1"></div>
|
||||
<div class="rect2"></div>
|
||||
<div class="rect3"></div>
|
||||
<div class="rect4"></div>
|
||||
<div class="rect5"></div>
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="loading z-50">
|
||||
<div class="spinner">
|
||||
<div class="rect1"></div>
|
||||
<div class="rect2"></div>
|
||||
<div class="rect3"></div>
|
||||
<div class="rect4"></div>
|
||||
<div class="rect5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool IsVisible { get; set; }
|
||||
@@ -0,0 +1,22 @@
|
||||
<div role="status" class="px-2" title="@Title">
|
||||
<svg aria-hidden="true" class="inline w-7 h-7 text-gray-200 @(Spinning ? "animate-spin fill-primary-600" : "") dark:text-gray-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Optional title of the loading indicator.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Set spinning to false to stop the animation.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Spinning { get; set; } = true;
|
||||
|
||||
}
|
||||
17
src/AliasVault.Admin/Main/Components/RedirectToLogin.razor
Normal file
17
src/AliasVault.Admin/Main/Components/RedirectToLogin.razor
Normal file
@@ -0,0 +1,17 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var returnUrl = NavigationManager.Uri;
|
||||
if (string.IsNullOrWhiteSpace(returnUrl) || returnUrl == "/")
|
||||
{
|
||||
NavigationManager.NavigateTo($"user/login", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
NavigationManager.NavigateTo($"user/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@using System.Timers
|
||||
|
||||
<button @onclick="HandleClick"
|
||||
disabled="@IsRefreshing"
|
||||
class="@GetButtonClasses()">
|
||||
<svg class="@GetIconClasses()" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<span class="ml-2">@ButtonText</span>
|
||||
</button>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The event to call in the parent when the button is clicked.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnRefresh { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The text to display on the button.
|
||||
/// </summary>
|
||||
[Parameter] public string ButtonText { get; set; } = "Refresh";
|
||||
|
||||
private bool IsRefreshing;
|
||||
private Timer Timer = new();
|
||||
|
||||
private async Task HandleClick()
|
||||
{
|
||||
if (IsRefreshing) return;
|
||||
|
||||
IsRefreshing = true;
|
||||
await OnRefresh.InvokeAsync();
|
||||
|
||||
Timer = new Timer(500);
|
||||
Timer.Elapsed += (sender, args) =>
|
||||
{
|
||||
IsRefreshing = false;
|
||||
Timer.Dispose();
|
||||
InvokeAsync(StateHasChanged);
|
||||
};
|
||||
Timer.Start();
|
||||
}
|
||||
|
||||
private string GetButtonClasses()
|
||||
{
|
||||
return $"flex items-center px-3 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-4 focus:ring-primary-300 dark:focus:ring-primary-800 {(IsRefreshing ? "bg-gray-400 cursor-not-allowed" : "bg-primary-700 hover:bg-primary-800 dark:bg-primary-600 dark:hover:bg-primary-700")}";
|
||||
}
|
||||
|
||||
private string GetIconClasses()
|
||||
{
|
||||
return $"w-4 h-4 {(IsRefreshing ? "animate-spin" : "")}";
|
||||
}
|
||||
}
|
||||
222
src/AliasVault.Admin/Main/Components/WorkerStatus/Services.razor
Normal file
222
src/AliasVault.Admin/Main/Components/WorkerStatus/Services.razor
Normal file
@@ -0,0 +1,222 @@
|
||||
@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;
|
||||
private readonly SemaphoreSlim InitLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// The interval in milliseconds for refreshing the service status.
|
||||
/// </summary>
|
||||
private readonly int AutoRefreshInterval = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// The timer for refreshing the service status.
|
||||
/// </summary>
|
||||
private Timer? Timer;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
Timer = new Timer(async _ =>
|
||||
{
|
||||
await InitPage();
|
||||
}, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(AutoRefreshInterval));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
// Dispose of the timer if it exists.
|
||||
Timer?.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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await InitLock.WaitAsync();
|
||||
|
||||
if (InitInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SmtpPending)
|
||||
{
|
||||
InitInProgress = true;
|
||||
|
||||
try
|
||||
{
|
||||
InitInProgress = true;
|
||||
var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync();
|
||||
// Service status checks if the status is "Started" and was lastAlive
|
||||
// (so actually reported itself) in the last 5 minutes.
|
||||
var smtpEntry = ServiceStatus.Find(x => x.ServiceName == "AliasVault.SmtpService");
|
||||
if (smtpEntry != null)
|
||||
{
|
||||
LastHeartbeat = smtpEntry.Heartbeat;
|
||||
SmtpStatus = IsHeartbeatValid() && smtpEntry.CurrentStatus == "Started";
|
||||
}
|
||||
|
||||
InitInProgress = false;
|
||||
|
||||
await InvokeAsync(() => StateHasChanged());
|
||||
}
|
||||
finally
|
||||
{
|
||||
InitInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
InitLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
58
src/AliasVault.Admin/Main/Layout/MainLayout.razor
Normal file
58
src/AliasVault.Admin/Main/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,58 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalLoadingService GlobalLoadingService
|
||||
|
||||
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
|
||||
<TopMenu />
|
||||
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||
<main>
|
||||
@Body
|
||||
</main>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private FullScreenLoadingIndicator LoadingIndicator = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
GlobalLoadingService.OnChange -= OnChange;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
GlobalLoadingService.OnChange += OnChange;
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnChange()
|
||||
{
|
||||
if (GlobalLoadingService.IsLoading)
|
||||
{
|
||||
LoadingIndicator.Show();
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadingIndicator.Hide();
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
18
src/AliasVault.Admin/Main/Layout/MainLayout.razor.css
Normal file
18
src/AliasVault.Admin/Main/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,18 @@
|
||||
#blazor-error-ui {
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -1,31 +1,32 @@
|
||||
@inherits PageBase
|
||||
@inherits MainBase
|
||||
@implements IDisposable
|
||||
|
||||
<header>
|
||||
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4">
|
||||
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4 bg-primary-100">
|
||||
<div class="flex justify-between items-center max-w-screen-2xl mx-auto">
|
||||
<div class="flex justify-start items-center">
|
||||
<a href="/" class="flex mr-14">
|
||||
<img src="/icon-trimmed.png" class="mr-3 h-8" alt="AliasVault Logo">
|
||||
<span class="self-center hidden sm:flex text-2xl font-semibold whitespace-nowrap dark:text-white">AliasVault</span>
|
||||
<span class="ps-2 self-center hidden sm:flex text-sm font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
|
||||
</a>
|
||||
|
||||
<div class="hidden justify-between items-center w-full lg:flex lg:w-auto lg:order-1">
|
||||
<ul class="flex flex-col mt-4 space-x-6 text-sm font-medium lg:flex-row xl:space-x-8 lg:mt-0">
|
||||
<NavLink href="/" class="block rounded 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">
|
||||
Home
|
||||
<NavLink href="/users" 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">
|
||||
Users
|
||||
</NavLink>
|
||||
<NavLink href="/aliases" 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">
|
||||
Aliases
|
||||
<NavLink href="/emails" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Emails
|
||||
</NavLink>
|
||||
<NavLink href="/logs" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Logs
|
||||
</NavLink>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center lg:order-2">
|
||||
<div class="mr-3 -mb-1 hidden sm:block">
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end items-center lg:order-2">
|
||||
<Services />
|
||||
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
||||
@@ -48,7 +49,7 @@
|
||||
</div>
|
||||
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
|
||||
<li>
|
||||
<a href="#" 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" 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">
|
||||
@@ -76,8 +77,8 @@
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="/aliases" 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">
|
||||
Aliases
|
||||
<NavLink href="/credentials" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Credentials
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -90,12 +91,31 @@
|
||||
private bool isMobileMenuOpen = false;
|
||||
private string _username { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Close the menu.
|
||||
/// </summary>
|
||||
[JSInvokable]
|
||||
public void CloseMenu()
|
||||
{
|
||||
isMenuOpen = false;
|
||||
isMobileMenuOpen = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose method.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationService.LocationChanged -= LocationChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
_username = await GetUsernameAsync();
|
||||
NavigationManager.LocationChanged += LocationChanged;
|
||||
_username = GetUsername();
|
||||
NavigationService.LocationChanged += LocationChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -110,7 +130,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
void LocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
private void LocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
isMenuOpen = false;
|
||||
isMobileMenuOpen = false;
|
||||
@@ -126,17 +146,4 @@
|
||||
{
|
||||
isMobileMenuOpen = !isMobileMenuOpen;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void CloseMenu()
|
||||
{
|
||||
isMenuOpen = false;
|
||||
isMobileMenuOpen = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= LocationChanged;
|
||||
}
|
||||
}
|
||||
24
src/AliasVault.Admin/Main/Models/BreadcrumbItem.cs
Normal file
24
src/AliasVault.Admin/Main/Models/BreadcrumbItem.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="BreadcrumbItem.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Breadcrumb item model.
|
||||
/// </summary>
|
||||
public class BreadcrumbItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL.
|
||||
/// </summary>
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
49
src/AliasVault.Admin/Main/Models/UserViewModel.cs
Normal file
49
src/AliasVault.Admin/Main/Models/UserViewModel.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="UserViewModel.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 view model.
|
||||
/// </summary>
|
||||
public class UserViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the id.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the CreatedAt timestamp.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user name.
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vault count.
|
||||
/// </summary>
|
||||
public int VaultCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the email claim count.
|
||||
/// </summary>
|
||||
public int EmailClaimCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total vault storage that this user takes up in kilobytes.
|
||||
/// </summary>
|
||||
public int VaultStorageInKb { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last time the vault was updated.
|
||||
/// </summary>
|
||||
public DateTime LastVaultUpdate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
@page "/account/manage/change-password"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<ChangePassword> Logger
|
||||
|
||||
<LayoutPageTitle>Change password</LayoutPageTitle>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Change password</h3>
|
||||
<EditForm Model="Input" FormName="change-password" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="old-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Old password</label>
|
||||
<InputText type="password" @bind-Value="Input.OldPassword" id="old-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password."/>
|
||||
<ValidationMessage For="() => Input.OldPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">New password</label>
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" id="new-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password."/>
|
||||
<ValidationMessage For="() => Input.NewPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Confirm password</label>
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="confirm-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md">
|
||||
Update password
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var changePasswordResult = await UserManager.ChangePasswordAsync(UserService.User(), Input.OldPassword, Input.NewPassword);
|
||||
var user = UserService.User();
|
||||
user.LastPasswordChanged = DateTime.UtcNow;
|
||||
await UserService.UpdateUserAsync(user);
|
||||
|
||||
// Clear the password fields
|
||||
Input.OldPassword = "";
|
||||
Input.NewPassword = "";
|
||||
Input.ConfirmPassword = "";
|
||||
|
||||
if (!changePasswordResult.Succeeded)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage($"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}", true);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("User changed their password successfully.");
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your password has been changed.", true);
|
||||
|
||||
NavigationService.RedirectToCurrentPage();
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Current password")]
|
||||
public string OldPassword { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "New password")]
|
||||
public string NewPassword { get; set; } = "";
|
||||
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Confirm new password")]
|
||||
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<h3 class="text-lg font-medium">Recovery codes</h3>
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4" role="alert">
|
||||
<p class="font-semibold">
|
||||
Put these codes in a safe place.
|
||||
</p>
|
||||
<p>
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1">
|
||||
@foreach (var recoveryCode in RecoveryCodes)
|
||||
{
|
||||
<div>
|
||||
<code class="block p-2 bg-primary-200 rounded">@recoveryCode</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The recovery codes to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string[] RecoveryCodes { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@page "/account/manage/disable-2fa"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<Disable2fa> Logger
|
||||
|
||||
<LayoutPageTitle>Disable two-factor authentication (2FA)</LayoutPageTitle>
|
||||
|
||||
<h3 class="text-xl font-bold mb-4">Disable two-factor authentication (2FA)</h3>
|
||||
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
|
||||
<p class="font-bold mb-2">
|
||||
This action only disables 2FA.
|
||||
</p>
|
||||
<p>
|
||||
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
|
||||
used in an authenticator app you should <a href="account/manage/reset-authenticator" class="text-primary-600 hover:text-primary-800 underline">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form @formname="disable-2fa" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken/>
|
||||
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" type="submit">Disable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!await UserManager.GetTwoFactorEnabledAsync(UserService.User()))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false);
|
||||
if (!disable2FaResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected error occurred disabling 2FA.");
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId);
|
||||
|
||||
// Reload current page.
|
||||
NavigationService.RedirectTo(NavigationService.Uri, forceLoad: true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
@page "/account/manage/enable-authenticator"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject UrlEncoder UrlEncoder
|
||||
@inject ILogger<EnableAuthenticator> Logger
|
||||
|
||||
<LayoutPageTitle>Configure authenticator app</LayoutPageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Configure authenticator app</h3>
|
||||
<div class="space-y-6">
|
||||
<p class="text-gray-700 dark:text-gray-300">To use an authenticator app go through the following steps:</p>
|
||||
<ol class="list-decimal space-y-4">
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Download a two-factor authenticator app like Microsoft Authenticator for
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825072" class="text-blue-600 hover:underline dark:text-blue-400">Android</a> and
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825073" class="text-blue-600 hover:underline dark:text-blue-400">iOS</a> or
|
||||
Google Authenticator for
|
||||
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en" class="text-blue-600 hover:underline dark:text-blue-400">Android</a> and
|
||||
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8" class="text-blue-600 hover:underline dark:text-blue-400">iOS</a>.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">Scan the QR Code or enter this key <kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500">@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
|
||||
<div id="authenticator-uri" data-url="@authenticatorUri" class="mt-4"></div>
|
||||
</li>
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
|
||||
with a unique code. Enter the code in the confirmation box below.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<EditForm Model="Input" FormName="send-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-4">
|
||||
<DataAnnotationsValidator/>
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Verification Code</label>
|
||||
<InputText @bind-Value="Input.Code" id="code" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="off" placeholder="Please enter the code."/>
|
||||
<ValidationMessage For="() => Input.Code" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md dark:bg-primary-500 dark:hover:bg-primary-600">
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
</EditForm>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
|
||||
|
||||
private string? sharedKey;
|
||||
private string? authenticatorUri;
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
await LoadSharedKeyAndQrCodeUriAsync(UserService.User());
|
||||
|
||||
await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri");
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
// Strip spaces and hyphens
|
||||
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
|
||||
var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
|
||||
UserService.User(), UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
|
||||
|
||||
if (!is2faTokenValid)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Error: Verification code is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
await UserManager.SetTwoFactorEnabledAsync(UserService.User(), true);
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your authenticator app has been verified.");
|
||||
|
||||
if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0)
|
||||
{
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Navigate back to the two factor authentication page.
|
||||
NavigationService.RedirectTo("account/manage/2fa", forceLoad: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask LoadSharedKeyAndQrCodeUriAsync(AdminUser user)
|
||||
{
|
||||
// Load the authenticator key & QR code URI to display on the form
|
||||
var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
if (string.IsNullOrEmpty(unformattedKey))
|
||||
{
|
||||
await UserManager.ResetAuthenticatorKeyAsync(user);
|
||||
unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
}
|
||||
|
||||
sharedKey = FormatKey(unformattedKey!);
|
||||
|
||||
var username = await UserManager.GetUserNameAsync(user);
|
||||
authenticatorUri = GenerateQrCodeUri(username!, unformattedKey!);
|
||||
}
|
||||
|
||||
private string FormatKey(string unformattedKey)
|
||||
{
|
||||
var result = new StringBuilder();
|
||||
int currentPosition = 0;
|
||||
while (currentPosition + 4 < unformattedKey.Length)
|
||||
{
|
||||
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
|
||||
currentPosition += 4;
|
||||
}
|
||||
|
||||
if (currentPosition < unformattedKey.Length)
|
||||
{
|
||||
result.Append(unformattedKey.AsSpan(currentPosition));
|
||||
}
|
||||
|
||||
return result.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string GenerateQrCodeUri(string username, string unformattedKey)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
AuthenticatorUriFormat,
|
||||
UrlEncoder.Encode("AliasVault Admin"),
|
||||
UrlEncoder.Encode(username),
|
||||
unformattedKey);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Verification Code")]
|
||||
public string Code { get; set; } = "";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@page "/account/manage/generate-recovery-codes"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<GenerateRecoveryCodes> Logger
|
||||
|
||||
<LayoutPageTitle>Generate two-factor authentication (2FA) recovery codes</LayoutPageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h3 class="text-xl font-bold mb-4">Generate two-factor authentication (2FA) recovery codes</h3>
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
|
||||
<p class="mb-2">
|
||||
<svg class="inline w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<strong>Put these codes in a safe place.</strong>
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
<p>
|
||||
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
|
||||
used in an authenticator app you should <a href="account/manage/reset-authenticator" class="text-primary-600 hover:text-primary-800 underline">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" @onclick="GenerateCodes" type="submit">Generate Recovery Codes</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User());
|
||||
if (!isTwoFactorEnabled)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateCodes()
|
||||
{
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
|
||||
GlobalNotificationService.AddSuccessMessage("You have generated new recovery codes.");
|
||||
|
||||
Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
|
||||
}
|
||||
|
||||
}
|
||||
69
src/AliasVault.Admin/Main/Pages/Account/Manage/Index.razor
Normal file
69
src/AliasVault.Admin/Main/Pages/Account/Manage/Index.razor
Normal file
@@ -0,0 +1,69 @@
|
||||
@page "/account/manage"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
|
||||
<LayoutPageTitle>Profile</LayoutPageTitle>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Profile</h3>
|
||||
|
||||
<EditForm Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" class="space-y-6">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="username" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Username</label>
|
||||
<input type="text" value="@username" id="username" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 bg-gray-100 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:text-gray-400" placeholder="Please choose your username." disabled/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone-number" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Phone number</label>
|
||||
<InputText @bind-Value="Input.PhoneNumber" id="phone-number" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder="Please enter your phone number."/>
|
||||
<ValidationMessage For="() => Input.PhoneNumber" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md dark:bg-primary-500 dark:hover:bg-primary-600">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? username;
|
||||
private string? phoneNumber;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
username = await UserManager.GetUserNameAsync(UserService.User());
|
||||
phoneNumber = await UserManager.GetPhoneNumberAsync(UserService.User());
|
||||
|
||||
Input.PhoneNumber ??= phoneNumber;
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (Input.PhoneNumber != phoneNumber)
|
||||
{
|
||||
var setPhoneResult = await UserManager.SetPhoneNumberAsync(UserService.User(), Input.PhoneNumber);
|
||||
if (!setPhoneResult.Succeeded)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Phone number could not be set", true);
|
||||
}
|
||||
}
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your profile has been updated", true);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Phone]
|
||||
[Display(Name = "Phone number")]
|
||||
public string? PhoneNumber { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@page "/account/manage/reset-authenticator"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject ILogger<ResetAuthenticator> Logger
|
||||
|
||||
<LayoutPageTitle>Reset authenticator key</LayoutPageTitle>
|
||||
|
||||
<h3 class="text-xl font-bold mb-4">Reset authenticator key</h3>
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
|
||||
<p class="mb-2">
|
||||
<svg class="inline w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
|
||||
</p>
|
||||
<p>
|
||||
This process disables 2FA until you verify your authenticator app.
|
||||
If you do not complete your authenticator app configuration you may lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<form @formname="reset-authenticator" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken/>
|
||||
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" type="submit">Reset authenticator key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false);
|
||||
await UserManager.ResetAuthenticatorKeyAsync(UserService.User());
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId);
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key.", true);
|
||||
|
||||
NavigationService.RedirectTo(
|
||||
"account/manage/2fa");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@page "/account/manage/2fa"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@inject SignInManager<AdminUser> SignInManager
|
||||
|
||||
<LayoutPageTitle>Two-factor authentication (2FA)</LayoutPageTitle>
|
||||
|
||||
@if (is2FaEnabled)
|
||||
{
|
||||
<div class="mx-auto mt-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Two-factor authentication (2FA)</h3>
|
||||
|
||||
@if (recoveryCodesLeft == 0)
|
||||
{
|
||||
<div class="mb-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700 dark:bg-red-900 dark:text-red-100">
|
||||
<p class="font-bold">You have no recovery codes left.</p>
|
||||
<p>You must <a href="account/manage/generate-recovery-codes" class="text-red-800 dark:text-red-200 underline">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft == 1)
|
||||
{
|
||||
<div class="mb-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700 dark:bg-red-900 dark:text-red-100">
|
||||
<p class="font-bold">You have 1 recovery code left.</p>
|
||||
<p>You can <a href="account/manage/generate-recovery-codes" class="text-red-800 dark:text-red-200 underline">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft <= 3)
|
||||
{
|
||||
<div class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-100">
|
||||
<p class="font-bold">You have @recoveryCodesLeft recovery codes left.</p>
|
||||
<p>You should <a href="account/manage/generate-recovery-codes" class="text-yellow-800 dark:text-yellow-200 underline">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<a href="account/manage/disable-2fa" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Disable 2FA</a>
|
||||
<a href="account/manage/generate-recovery-codes" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Reset recovery codes</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Authenticator app</h4>
|
||||
<div class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
@if (!hasAuthenticator)
|
||||
{
|
||||
<a href="account/manage/enable-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Add authenticator app
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="account/manage/enable-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Set up authenticator app
|
||||
</a>
|
||||
<a href="account/manage/reset-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Reset authenticator app
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool hasAuthenticator;
|
||||
private int recoveryCodesLeft;
|
||||
private bool is2FaEnabled;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(UserService.User()) is not null;
|
||||
is2FaEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User());
|
||||
recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(UserService.User());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@layout ManageLayout
|
||||
@inherits MainBase
|
||||
@using AliasVault.Admin.Auth
|
||||
@using AliasVault.Admin.Main.Pages.Account.Manage.Components
|
||||
@using AliasVault.Admin.Main.Components.Layout
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
26
src/AliasVault.Admin/Main/Pages/Account/ManageLayout.razor
Normal file
26
src/AliasVault.Admin/Main/Pages/Account/ManageLayout.razor
Normal file
@@ -0,0 +1,26 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using AliasVault.Admin.Main.Layout
|
||||
@layout MainLayout
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<GlobalNotificationDisplay />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Manage account</h1>
|
||||
</div>
|
||||
<p>Manage your profile here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<hr class="mb-6 border-t border-gray-300"/>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="w-full md:w-1/4 mb-6 md:mb-0">
|
||||
<ManageNavMenu/>
|
||||
</div>
|
||||
<div class="w-full md:w-3/4 md:pl-8">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
15
src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor
Normal file
15
src/AliasVault.Admin/Main/Pages/Account/ManageNavMenu.razor
Normal file
@@ -0,0 +1,15 @@
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject SignInManager<AdminUser> SignInManager
|
||||
|
||||
<ul class="flex flex-col space-y-1">
|
||||
<li>
|
||||
<NavLink href="account/manage" Match="NavLinkMatch.All" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Profile</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Password</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="account/manage/2fa" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Two-factor authentication</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
110
src/AliasVault.Admin/Main/Pages/Emails.razor
Normal file
110
src/AliasVault.Admin/Main/Pages/Emails.razor
Normal file
@@ -0,0 +1,110 @@
|
||||
@page "/emails"
|
||||
@using AliasVault.RazorComponents
|
||||
@using Azure
|
||||
|
||||
<LayoutPageTitle>Emails</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Emails</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of recently received mails by this AliasVault server.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overflow-x-auto px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border mt-8">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Time</th>
|
||||
<th scope="col" class="px-4 py-3">From</th>
|
||||
<th scope="col" class="px-4 py-3">To</th>
|
||||
<th scope="col" class="px-4 py-3">Subject</th>
|
||||
<th scope="col" class="px-4 py-3">Preview</th>
|
||||
<th scope="col" class="px-4 py-3">Attachments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var email in EmailList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">
|
||||
@email.Id
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@email.DateSystem.ToString("yyyy-MM-dd HH:mm")
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@(email.FromLocal.Length > 15 ? email.FromLocal.Substring(0, 15) : email.FromLocal)@@@(email.FromDomain.Length > 15 ? email.FromDomain.Substring(0, 15) : email.FromDomain)
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@email.ToLocal@@@email.ToDomain
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@(email.Subject.Length > 30 ? email.Subject.Substring(0, 30) : email.Subject)
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="line-clamp-1">
|
||||
@(email.MessagePreview?.Length > 30 ? email.MessagePreview.Substring(0, 30) : email.MessagePreview)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@email.Attachments.Count
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Email> EmailList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
_ = RefreshData();
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
TotalRecords = await DbContext.Emails.CountAsync();
|
||||
EmailList = await DbContext.Emails
|
||||
.OrderByDescending(x => x.DateSystem)
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
@inherits AuthorizePageBase
|
||||
@page "/Error"
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
<LayoutPageTitle>Error</LayoutPageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
@@ -31,6 +30,7 @@
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
|
||||
15
src/AliasVault.Admin/Main/Pages/Home.razor
Normal file
15
src/AliasVault.Admin/Main/Pages/Home.razor
Normal file
@@ -0,0 +1,15 @@
|
||||
@page "/"
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Home</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">AliasVault Admin</h1>
|
||||
</div>
|
||||
<p>Welcome to the AliasVault admin portal.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
162
src/AliasVault.Admin/Main/Pages/Logs.razor
Normal file
162
src/AliasVault.Admin/Main/Pages/Logs.razor
Normal file
@@ -0,0 +1,162 @@
|
||||
@page "/logs"
|
||||
@using AliasVault.RazorComponents
|
||||
|
||||
<LayoutPageTitle>Logs</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Logs</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of recent system logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4 flex space-x-4">
|
||||
<div class="flex w-full">
|
||||
<div class="w-2/3 pr-2">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div class="w-1/3 pl-2">
|
||||
<select @bind="SelectedServiceName" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Services</option>
|
||||
@foreach (var service in ServiceNames)
|
||||
{
|
||||
<option value="@service">@service</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Time</th>
|
||||
<th scope="col" class="px-4 py-3">Application</th>
|
||||
<th scope="col" class="px-4 py-3">Level</th>
|
||||
<th scope="col" class="px-4 py-3">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var log in LogList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@log.Id</td>
|
||||
<td class="px-4 py-3">@log.TimeStamp.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@log.Application</td>
|
||||
|
||||
@{
|
||||
string bgColor = log.Level switch
|
||||
{
|
||||
"Information" => "bg-blue-500",
|
||||
"Error" => "bg-red-500",
|
||||
"Warning" => "bg-yellow-500",
|
||||
"Debug" => "bg-green-500",
|
||||
_ => "bg-gray-500"
|
||||
};
|
||||
}
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 rounded-full text-white @bgColor">
|
||||
@log.Level
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 line-clamp-1" title="@log.Exception">@log.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Log> LogList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
private string _searchTerm = string.Empty;
|
||||
private string SearchTerm
|
||||
{
|
||||
get => _searchTerm;
|
||||
set
|
||||
{
|
||||
if (_searchTerm != value)
|
||||
{
|
||||
_searchTerm = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedServiceName = string.Empty;
|
||||
private string SelectedServiceName
|
||||
{
|
||||
get => _selectedServiceName;
|
||||
set
|
||||
{
|
||||
if (_selectedServiceName != value)
|
||||
{
|
||||
_selectedServiceName = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> ServiceNames { get; set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ServiceNames = await DbContext.Logs.Select(l => l.Application).Distinct().ToListAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private void HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
_ = RefreshData();
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var query = DbContext.Logs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Message.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Level.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(SelectedServiceName))
|
||||
{
|
||||
query = query.Where(x => x.Application == SelectedServiceName);
|
||||
}
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
LogList = await query
|
||||
.OrderByDescending(x => x.Id)
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
105
src/AliasVault.Admin/Main/Pages/MainBase.cs
Normal file
105
src/AliasVault.Admin/Main/Pages/MainBase.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="MainBase.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Pages;
|
||||
|
||||
using AliasServerDb;
|
||||
using AliasVault.Admin.Main.Models;
|
||||
using AliasVault.Admin.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
/// <summary>
|
||||
/// Base authorize page that all pages that are part of the logged in website should inherit from.
|
||||
/// All pages that inherit from this class will require the user to be logged in and have a confirmed email.
|
||||
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class MainBase : OwningComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the NavigationService instance responsible for handling navigation, replaces the default NavigationManager.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected NavigationService NavigationService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UserService instance responsible for handling user data.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected UserService UserService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the global notification service for showing notifications throughout the app.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected GlobalNotificationService GlobalNotificationService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JS invoke service for calling JS functions from C#.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected JsInvokeService JsInvokeService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the AliasServerDbContext instance.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected AliasServerDbContext DbContext { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the AliasServerDbContextFactory instance.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected IDbContextFactory<AliasServerDbContext> DbContextFactory { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the GlobalLoadingService in order to manipulate the global loading spinner animation.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected GlobalLoadingService GlobalLoadingSpinner { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the injected JSRuntime instance.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected IJSRuntime Js { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the breadcrumb items for the page. A default set of breadcrumbs is added in the parent OnInitialized method.
|
||||
/// </summary>
|
||||
protected List<BreadcrumbItem> BreadcrumbItems { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
// Load the current user.
|
||||
await UserService.LoadCurrentUserAsync();
|
||||
|
||||
// Add base breadcrumbs.
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationService.BaseUri });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the username from the authentication state asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>The username.</returns>
|
||||
protected string GetUsername()
|
||||
{
|
||||
return UserService.User().UserName ?? "[Unknown]";
|
||||
}
|
||||
}
|
||||
102
src/AliasVault.Admin/Main/Pages/Users/Delete.razor
Normal file
102
src/AliasVault.Admin/Main/Pages/Users/Delete.razor
Normal file
@@ -0,0 +1,102 @@
|
||||
@page "/users/{id}/delete"
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Delete user</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Delete user</h1>
|
||||
</div>
|
||||
<p>You can delete the user below.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<AlertMessageError Message="Note: removing this user is permanent and cannot be undone. All encrypted vault data will also be removed." />
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">User</h3>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
|
||||
<div>@Id</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div>@Obj?.UserName</div>
|
||||
</div>
|
||||
<button @onclick="DeleteConfirm" class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm inline-flex items-center px-3 py-2.5 text-center mr-2 dark:focus:ring-red-900">
|
||||
Yes, I'm sure
|
||||
</button>
|
||||
|
||||
<button @onclick="Cancel" class="text-gray-900 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 border border-gray-200 font-medium inline-flex items-center rounded-lg text-sm px-3 py-2.5 text-center dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700" data-drawer-hide="drawer-delete-product-default">
|
||||
No, cancel
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The ID of the user to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private AliasVaultUser? Obj { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { Url = "users/" + Id, DisplayName = "View user" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Delete user" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
// Load existing Obj.
|
||||
Obj = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
// Hide loading spinner
|
||||
IsLoading = false;
|
||||
|
||||
// Force re-render invoke so the charts can be rendered
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteConfirm()
|
||||
{
|
||||
if (Obj is null)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Error deleting. User entry not found.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
GlobalLoadingSpinner.Show();
|
||||
|
||||
DbContext.AliasVaultUsers.Remove(Obj);
|
||||
await DbContext.SaveChangesAsync();
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("User successfully deleted.");
|
||||
GlobalLoadingSpinner.Hide();
|
||||
|
||||
NavigationService.RedirectTo("/users");
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
NavigationService.RedirectTo("/users/" + Id);
|
||||
}
|
||||
}
|
||||
149
src/AliasVault.Admin/Main/Pages/Users/Users.razor
Normal file
149
src/AliasVault.Admin/Main/Pages/Users/Users.razor
Normal file
@@ -0,0 +1,149 @@
|
||||
@page "/users"
|
||||
@using AliasVault.RazorComponents
|
||||
|
||||
<LayoutPageTitle>Users</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Users</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of all registered users and the associated vaults.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Registered</th>
|
||||
<th scope="col" class="px-4 py-3">Username</th>
|
||||
<th scope="col" class="px-4 py-3"># Vaults</th>
|
||||
<th scope="col" class="px-4 py-3"># Email claims</th>
|
||||
<th scope="col" class="px-4 py-3">Storage</th>
|
||||
<th scope="col" class="px-4 py-3">Last vault update</th>
|
||||
<th scope="col" class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logTableBody">
|
||||
@foreach (var user in UserList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@user.Id</td>
|
||||
<td class="px-4 py-3">@user.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@user.UserName</td>
|
||||
<td class="px-4 py-3">@user.VaultCount</td>
|
||||
<td class="px-4 py-3">@user.EmailClaimCount</td>
|
||||
<td class="px-4 py-3">@Math.Round((double)user.VaultStorageInKb / 1024, 1) MB</td>
|
||||
<td class="px-4 py-3">@user.LastVaultUpdate.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="users/@user.Id" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-blue-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<UserViewModel> UserList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
private string _searchTerm = string.Empty;
|
||||
private string SearchTerm
|
||||
{
|
||||
get => _searchTerm;
|
||||
set
|
||||
{
|
||||
if (_searchTerm != value)
|
||||
{
|
||||
_searchTerm = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
_ = RefreshData();
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
IQueryable<AliasVaultUser> query = DbContext.AliasVaultUsers;
|
||||
|
||||
if (SearchTerm.Length > 0)
|
||||
{
|
||||
query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Email!.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
}
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
|
||||
var users = await query
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.Select(u => new
|
||||
{
|
||||
u.Id,
|
||||
u.UserName,
|
||||
u.CreatedAt,
|
||||
Vaults = u.Vaults.Select(v => new
|
||||
{
|
||||
v.FileSize,
|
||||
v.CreatedAt
|
||||
}),
|
||||
EmailClaims = u.EmailClaims.Select(ec => new
|
||||
{
|
||||
ec.CreatedAt
|
||||
}),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
UserList = users.Select(user => new UserViewModel
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName?.ToLower() ?? "N/A",
|
||||
CreatedAt = user.CreatedAt,
|
||||
VaultCount = user.Vaults.Count(),
|
||||
EmailClaimCount = user.EmailClaims.Count(),
|
||||
VaultStorageInKb = user.Vaults.Sum(x => x.FileSize),
|
||||
LastVaultUpdate = user.Vaults.Max(x => x.CreatedAt),
|
||||
}).ToList();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
166
src/AliasVault.Admin/Main/Pages/Users/View.razor
Normal file
166
src/AliasVault.Admin/Main/Pages/Users/View.razor
Normal file
@@ -0,0 +1,166 @@
|
||||
@page "/users/{Id}"
|
||||
|
||||
<LayoutPageTitle>User</LayoutPageTitle>
|
||||
|
||||
@if (IsLoading || User == null)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-2 px-4 pt-6 md:grid-cols-3 lg:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems"/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">View user</h1>
|
||||
<div class="flex">
|
||||
<a href="/users/@Id/delete" class="px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 focus:ring-4 focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800">
|
||||
Delete user
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center xl:block sm:space-x-4 xl:space-x-0 2xl:space-x-4">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@User.UserName</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
|
||||
<div>@User.Id</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Vaults</h3>
|
||||
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Created</th>
|
||||
<th scope="col" class="px-4 py-3">Filesize</th>
|
||||
<th scope="col" class="px-4 py-3">DB version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in VaultList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@entry.Id</td>
|
||||
<td class="px-4 py-3">@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@Math.Round((double)entry.FileSize / 1024, 1) MB</td>
|
||||
<td class="px-4 py-3">@entry.Version</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Email claims</h3>
|
||||
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Created</th>
|
||||
<th scope="col" class="px-4 py-3">Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in EmailClaimList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@entry.Id</td>
|
||||
<td class="px-4 py-3">@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@entry.Address</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private AliasVaultUser? User { get; set; } = new();
|
||||
private List<Vault> VaultList { get; set; } = new();
|
||||
private List<UserEmailClaim> EmailClaimList { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "/users" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View user" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
await LoadEntryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadEntryAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Load the aliases from the webapi via AliasService.
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User is null)
|
||||
{
|
||||
// Error loading user.
|
||||
GlobalNotificationService.AddErrorMessage("This user does not exist (anymore). Please try again.");
|
||||
NavigationService.RedirectTo("/users");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
Id = x.Id,
|
||||
Version = x.Version,
|
||||
FileSize = x.FileSize,
|
||||
CreatedAt = x.CreatedAt,
|
||||
})
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// Load all email claims for this user.
|
||||
EmailClaimList = await DbContext.UserEmailClaims.Where(x => x.UserId == User.Id)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
10
src/AliasVault.Admin/Main/Routes.razor
Normal file
10
src/AliasVault.Admin/Main/Routes.razor
Normal file
@@ -0,0 +1,10 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin/>
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
26
src/AliasVault.Admin/Main/_Imports.razor
Normal file
26
src/AliasVault.Admin/Main/_Imports.razor
Normal file
@@ -0,0 +1,26 @@
|
||||
@inherits AliasVault.Admin.Main.Pages.MainBase
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.JSInterop
|
||||
@using AliasVault.Admin
|
||||
@using AliasVault.Admin.Auth.Components
|
||||
@using AliasVault.Admin.Main
|
||||
@using AliasVault.Admin.Main.Components
|
||||
@using AliasVault.Admin.Main.Components.Alerts
|
||||
@using AliasVault.Admin.Main.Components.Layout
|
||||
@using AliasVault.Admin.Main.Components.Loading
|
||||
@using AliasVault.Admin.Main.Components.WorkerStatus
|
||||
@using AliasVault.Admin.Main.Components.Refresh
|
||||
@using AliasVault.Admin.Main.Models
|
||||
@using AliasVault.Admin.Main.Pages
|
||||
@using AliasVault.Admin.Services
|
||||
@using AliasServerDb
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
137
src/AliasVault.Admin/Program.cs
Normal file
137
src/AliasVault.Admin/Program.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="Program.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using AliasServerDb;
|
||||
using AliasVault.Admin;
|
||||
using AliasVault.Admin.Auth.Providers;
|
||||
using AliasVault.Admin.Main;
|
||||
using AliasVault.Admin.Services;
|
||||
using AliasVault.Logging;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
|
||||
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true);
|
||||
builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../logs");
|
||||
|
||||
// Create global config object, get values from environment variables.
|
||||
Config config = new Config();
|
||||
var adminPasswordHash = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_HASH") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_HASH environment variable is not set.");
|
||||
config.AdminPasswordHash = adminPasswordHash;
|
||||
|
||||
var lastPasswordChanged = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_GENERATED") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_GENERATED environment variable is not set.");
|
||||
config.LastPasswordChanged = DateTime.ParseExact(lastPasswordChanged, "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
|
||||
|
||||
builder.Services.AddSingleton(config);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<JsInvokeService>();
|
||||
builder.Services.AddScoped<GlobalNotificationService>();
|
||||
builder.Services.AddScoped<GlobalLoadingService>();
|
||||
builder.Services.AddScoped<NavigationService>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingAuthenticationStateProvider>();
|
||||
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
||||
})
|
||||
.AddIdentityCookies();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/user/login";
|
||||
});
|
||||
|
||||
// We use dbContextFactory to create a new instance of the DbContext for every place that needs it
|
||||
// as otherwise concurrency issues may occur if we use a single instance of the DbContext across the application.
|
||||
builder.Services.AddSingleton<DbConnection>(container =>
|
||||
{
|
||||
var connection = new SqliteConnection(builder.Configuration.GetConnectionString("AliasServerDbContext"));
|
||||
connection.Open();
|
||||
|
||||
return connection;
|
||||
});
|
||||
|
||||
builder.Services.AddDbContextFactory<AliasServerDbContext>((container, options) =>
|
||||
{
|
||||
var connection = container.GetRequiredService<DbConnection>();
|
||||
options.UseSqlite(connection).UseLazyLoadingProxies();
|
||||
});
|
||||
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
builder.Services.AddIdentityCore<AdminUser>(options =>
|
||||
{
|
||||
options.Password.RequireDigit = false;
|
||||
options.Password.RequireLowercase = false;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequireUppercase = false;
|
||||
options.Password.RequiredLength = 8;
|
||||
options.Password.RequiredUniqueChars = 0;
|
||||
options.SignIn.RequireConfirmedAccount = false;
|
||||
options.User.RequireUniqueEmail = false;
|
||||
})
|
||||
.AddRoles<AdminRole>()
|
||||
.AddEntityFrameworkStores<AliasServerDbContext>()
|
||||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseMigrationsEndPoint();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var container = scope.ServiceProvider;
|
||||
var db = await container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>().CreateDbContextAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider);
|
||||
await StartupTasks.SetAdminUser(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
namespace AliasVault.Admin
|
||||
{
|
||||
/// <summary>
|
||||
/// Explicit program class definition. This is required in order to start the Admin project
|
||||
/// in-memory from E2ETests project via WebApplicationFactory.
|
||||
/// </summary>
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:32869",
|
||||
"sslPort": 44372
|
||||
"applicationUrl": "http://localhost:12292",
|
||||
"sslPort": 44398
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -13,16 +13,18 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5280",
|
||||
"applicationUrl": "http://localhost:5216",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==",
|
||||
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7004;http://localhost:5280",
|
||||
"applicationUrl": "https://localhost:7025;http://localhost:5216",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
47
src/AliasVault.Admin/Services/GlobalLoadingService.cs
Normal file
47
src/AliasVault.Admin/Services/GlobalLoadingService.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="GlobalLoadingService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Global loading service that can be used to show or hide a global layout loading spinner.
|
||||
/// </summary>
|
||||
public class GlobalLoadingService
|
||||
{
|
||||
private bool _isLoading;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the loading state changes.
|
||||
/// </summary>
|
||||
public event Action? OnChange;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the global loading spinner is currently visible.
|
||||
/// </summary>
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
set
|
||||
{
|
||||
if (_isLoading != value)
|
||||
{
|
||||
_isLoading = value;
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the global loading spinner.
|
||||
/// </summary>
|
||||
public void Show() => IsLoading = true;
|
||||
|
||||
/// <summary>
|
||||
/// Hide the global loading spinner.
|
||||
/// </summary>
|
||||
public void Hide() => IsLoading = false;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.WebApp.Services;
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles global notifications that should be displayed to the user, such as success or error messages. These messages
|
||||
@@ -22,12 +22,12 @@ public class GlobalNotificationService
|
||||
/// <summary>
|
||||
/// Gets or sets success messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
protected List<string> SuccessMessages { get; set; } = new();
|
||||
protected List<string> SuccessMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets error messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
protected List<string> ErrorMessages { get; set; } = new();
|
||||
protected List<string> ErrorMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds a success message to the list of messages that should be displayed to the user.
|
||||
53
src/AliasVault.Admin/Services/JSInvokeService.cs
Normal file
53
src/AliasVault.Admin/Services/JSInvokeService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="JSInvokeService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
/// <summary>
|
||||
/// Service for invoking JavaScript functions from C#.
|
||||
/// </summary>
|
||||
public class JsInvokeService(IJSRuntime js)
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoke a JavaScript function with retry and exponential backoff.
|
||||
/// </summary>
|
||||
/// <param name="functionName">The JS function name to call.</param>
|
||||
/// <param name="initialDelay">Initial delay before calling the function.</param>
|
||||
/// <param name="maxAttempts">Maximum attempts before giving up.</param>
|
||||
/// <param name="args">Arguments to pass on to the javascript function.</param>
|
||||
/// <returns>Async Task.</returns>
|
||||
public async Task RetryInvokeAsync(string functionName, TimeSpan initialDelay, int maxAttempts, params object[] args)
|
||||
{
|
||||
TimeSpan delay = initialDelay;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool isDefined = await js.InvokeAsync<bool>("isFunctionDefined", functionName);
|
||||
if (isDefined)
|
||||
{
|
||||
await js.InvokeVoidAsync(functionName, args);
|
||||
return; // Successfully called the JS function, exit the method
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Optionally log the exception
|
||||
}
|
||||
|
||||
// Wait for the delay before the next attempt
|
||||
await Task.Delay(delay);
|
||||
|
||||
// Exponential backoff: double the delay for the next attempt
|
||||
delay = TimeSpan.FromTicks(delay.Ticks * 2);
|
||||
}
|
||||
|
||||
// Optionally log that the JS function could not be called after maxAttempts
|
||||
}
|
||||
}
|
||||
102
src/AliasVault.Admin/Services/NavigationService.cs
Normal file
102
src/AliasVault.Admin/Services/NavigationService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="NavigationService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation helper service.
|
||||
/// </summary>
|
||||
public class NavigationService
|
||||
{
|
||||
private readonly NavigationManager _navigationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NavigationService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="navigationManager">NavigationManager instance.</param>
|
||||
public NavigationService(NavigationManager navigationManager)
|
||||
{
|
||||
_navigationManager = navigationManager;
|
||||
_navigationManager.LocationChanged += (sender, args) => { LocationChanged?.Invoke(sender, args); };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Location changed event.
|
||||
/// </summary>
|
||||
public event EventHandler<LocationChangedEventArgs>? LocationChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Base URI.
|
||||
/// </summary>
|
||||
public string BaseUri => _navigationManager.BaseUri;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI.
|
||||
/// </summary>
|
||||
public string Uri => _navigationManager.Uri;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current path.
|
||||
/// </summary>
|
||||
private string CurrentPath => _navigationManager.ToAbsoluteUri(_navigationManager.Uri).GetLeftPart(UriPartial.Path);
|
||||
|
||||
/// <summary>
|
||||
/// Redirect to the current page.
|
||||
/// </summary>
|
||||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
|
||||
|
||||
/// <summary>
|
||||
/// Redirect to the specified URI.
|
||||
/// </summary>
|
||||
/// <param name="uri">The uri to redirect to.</param>
|
||||
/// <param name="forceLoad">Force load true/false.</param>
|
||||
public void RedirectTo(string? uri, bool forceLoad = false)
|
||||
{
|
||||
uri ??= string.Empty;
|
||||
|
||||
// Prevent open redirects.
|
||||
if (!System.Uri.IsWellFormedUriString(uri, UriKind.Relative))
|
||||
{
|
||||
uri = _navigationManager.ToBaseRelativePath(uri);
|
||||
}
|
||||
|
||||
_navigationManager.NavigateTo(uri, forceLoad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redirect to the specified URI with query parameters.
|
||||
/// </summary>
|
||||
/// <param name="uri">URI to redirect to.</param>
|
||||
/// <param name="queryParameters">Optional querystring parameters to add to the URL.</param>
|
||||
/// <param name="forceLoad">Force load true/false.</param>
|
||||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters, bool forceLoad = false)
|
||||
{
|
||||
var uriWithoutQuery = _navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
|
||||
var newUri = _navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
|
||||
RedirectTo(newUri, forceLoad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a URI constructed from <paramref name="uri" /> except with multiple parameters
|
||||
/// added, updated, or removed.
|
||||
/// </summary>
|
||||
/// <param name="uri">The URI with the query to modify.</param>
|
||||
/// <param name="parameters">The values to add, update, or remove.</param>
|
||||
/// <returns>The URI with the query modified.</returns>
|
||||
public string GetUriWithQueryParameters(string uri, IReadOnlyDictionary<string, object?> parameters) => _navigationManager.GetUriWithQueryParameters(uri, parameters);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a relative URI into an absolute one (by resolving it
|
||||
/// relative to the current absolute URI).
|
||||
/// </summary>
|
||||
/// <param name="relativeUri">The relative URI.</param>
|
||||
/// <returns>The absolute URI.</returns>
|
||||
public Uri ToAbsoluteUri(string relativeUri) => _navigationManager.ToAbsoluteUri(relativeUri);
|
||||
}
|
||||
331
src/AliasVault.Admin/Services/UserService.cs
Normal file
331
src/AliasVault.Admin/Services/UserService.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="UserService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using AliasServerDb;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// User service for managing users.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">AliasServerDbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
/// <param name="httpContextAccessor">HttpContextManager instance.</param>
|
||||
public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser> userManager, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
private const string AdminRole = "Admin";
|
||||
private AdminUser? _user;
|
||||
|
||||
/// <summary>
|
||||
/// The roles of the current user.
|
||||
/// </summary>
|
||||
private List<string> _userRoles = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the current user is an admin or not.
|
||||
/// </summary>
|
||||
private bool _isAdmin;
|
||||
|
||||
/// <summary>
|
||||
/// Allow other components to subscribe to changes in the event object.
|
||||
/// </summary>
|
||||
public event Action OnChange = () => { };
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the User is loaded and available, false if not. Use this before accessing User() method.
|
||||
/// </summary>
|
||||
public bool UserLoaded => _user != null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns all users.
|
||||
/// </summary>
|
||||
/// <returns>List of users.</returns>
|
||||
public async Task<List<AdminUser>> GetAllUsersAsync()
|
||||
{
|
||||
var userList = await userManager.Users.ToListAsync();
|
||||
return userList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds and returns user by id, using the userManager instead of the dbContext.
|
||||
/// This is necessary when performing actions on the user, such as changing password or deleting the object.
|
||||
/// </summary>
|
||||
/// <param name="userId">User ID.</param>
|
||||
/// <returns>AdminUser object.</returns>
|
||||
public async Task<AdminUser> GetUserByIdUserManagerAsync(Guid userId)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentException($"User with id {userId} not found.");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns inner User EF object.
|
||||
/// </summary>
|
||||
/// <returns>User object.</returns>
|
||||
public AdminUser User()
|
||||
{
|
||||
if (_user == null)
|
||||
{
|
||||
throw new ArgumentException("Trying to access User object which is null.");
|
||||
}
|
||||
|
||||
return _user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether current user is admin or not.
|
||||
/// </summary>
|
||||
/// <returns>Boolean which indicates if user is admin.</returns>
|
||||
public bool CurrentUserIsAdmin()
|
||||
{
|
||||
return _isAdmin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns current logged on user based on HttpContext.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
public async Task LoadCurrentUserAsync()
|
||||
{
|
||||
if (httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
// Load user from database. Use a new context everytime to ensure we get the latest data.
|
||||
var userName = httpContextAccessor.HttpContext?.User?.Identity?.Name ?? string.Empty;
|
||||
|
||||
var user = await dbContext.AdminUsers.FirstOrDefaultAsync(u => u.UserName == userName);
|
||||
if (user != null)
|
||||
{
|
||||
_user = user;
|
||||
|
||||
// Load all roles for current user.
|
||||
var roles = await userManager.GetRolesAsync(User());
|
||||
_userRoles = roles.ToList();
|
||||
|
||||
// Define if current user is admin.
|
||||
_isAdmin = _userRoles.Contains(AdminRole);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners that the user has been loaded.
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns current logged on user roles based on HttpContext.
|
||||
/// </summary>
|
||||
/// <returns>List of roles.</returns>
|
||||
public async Task<List<string>> GetCurrentUserRolesAsync()
|
||||
{
|
||||
var roles = await userManager.GetRolesAsync(User());
|
||||
|
||||
return roles.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search for users based on search term.
|
||||
/// </summary>
|
||||
/// <param name="searchTerm">Search term.</param>
|
||||
/// <returns>List of users matching the search term.</returns>
|
||||
public async Task<List<AdminUser>> SearchUsersAsync(string searchTerm)
|
||||
{
|
||||
return await userManager.Users.Where(x => x.UserName != null && x.UserName.Contains(searchTerm)).Take(5).ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">Password.</param>
|
||||
/// <param name="roles">Roles.</param>
|
||||
/// <returns>List of errors if there are any.</returns>
|
||||
public async Task<List<string>> CreateUserAsync(AdminUser user, string password, List<string> roles)
|
||||
{
|
||||
var errors = await ValidateUser(user, password, isUpdate: false);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return errors;
|
||||
}
|
||||
|
||||
var result = await userManager.CreateAsync(user, password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
errors.Add(error.Description);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
errors = await UpdateUserRolesAsync(user, roles);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="newPassword">Optional parameter for new password for the user.</param>
|
||||
/// <returns>List of errors if any.</returns>
|
||||
public async Task<List<string>> UpdateUserAsync(AdminUser user, string newPassword = "")
|
||||
{
|
||||
var errors = await ValidateUser(user, newPassword, isUpdate: true);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Update password if necessary
|
||||
if (!string.IsNullOrEmpty(newPassword))
|
||||
{
|
||||
var passwordRemoveResult = await userManager.RemovePasswordAsync(user);
|
||||
if (!passwordRemoveResult.Succeeded)
|
||||
{
|
||||
foreach (var error in passwordRemoveResult.Errors)
|
||||
{
|
||||
errors.Add(error.Description);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
var passwordAddResult = await userManager.AddPasswordAsync(user, newPassword);
|
||||
if (!passwordAddResult.Succeeded)
|
||||
{
|
||||
foreach (var error in passwordAddResult.Errors)
|
||||
{
|
||||
errors.Add(error.Description);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
errors.Add(error.Description);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update user roles. This is a separate method because it is called from both CreateUserAsync and UpdateUserAsync.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="roles">New roles for the user.</param>
|
||||
/// <returns>List of errors if any.</returns>
|
||||
public async Task<List<string>> UpdateUserRolesAsync(AdminUser user, List<string> roles)
|
||||
{
|
||||
List<string> errors = new();
|
||||
|
||||
var currentRoles = await userManager.GetRolesAsync(user);
|
||||
if (user.Id == User().Id && currentRoles.Contains(AdminRole) && !roles.Contains(AdminRole))
|
||||
{
|
||||
errors.Add("You cannot remove the Admin role from yourself if you are an Admin.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
var rolesToAdd = roles.Except(currentRoles).ToList();
|
||||
var rolesToRemove = currentRoles.Except(roles).ToList();
|
||||
|
||||
await userManager.AddToRolesAsync(user, rolesToAdd);
|
||||
await userManager.RemoveFromRolesAsync(user, rolesToRemove);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if supplied password is correct for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">The password to check.</param>
|
||||
/// <returns>Boolean indicating whether supplied password is valid and matches what is stored in the database..</returns>
|
||||
public async Task<bool> CheckPasswordAsync(AdminUser user, string password)
|
||||
{
|
||||
if (password.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await userManager.CheckPasswordAsync(user, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate if user object contents conform to the requirements.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">Password for the user.</param>
|
||||
/// <param name="isUpdate">Boolean indicating whether the user is being updated or not.</param>
|
||||
/// <returns>List of strings.</returns>
|
||||
private async Task<List<string>> ValidateUser(AdminUser user, string password, bool isUpdate)
|
||||
{
|
||||
// Username and email are the same, so enforce any changes to username here to email as well
|
||||
user.Email = user.UserName;
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrEmpty(user.UserName) || string.IsNullOrEmpty(user.Email))
|
||||
{
|
||||
errors.Add("Username and email are required.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!new EmailAddressAttribute().IsValid(user.Email))
|
||||
{
|
||||
errors.Add("Email is not valid.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (isUpdate)
|
||||
{
|
||||
var originalUser = await userManager.FindByIdAsync(user.Id);
|
||||
if (originalUser != null && user.UserName != originalUser.UserName)
|
||||
{
|
||||
errors.Add("Username cannot be changed for existing users.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var existingUser = await userManager.FindByNameAsync(user.UserName);
|
||||
if (existingUser != null)
|
||||
{
|
||||
errors.Add("Username is already in use.");
|
||||
}
|
||||
|
||||
var existingEmail = await userManager.FindByEmailAsync(user.Email);
|
||||
if (existingEmail != null)
|
||||
{
|
||||
errors.Add("Email is already in use.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
errors.Add("Password is required.");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnChange.Invoke();
|
||||
}
|
||||
59
src/AliasVault.Admin/Services/VersionedContentService.cs
Normal file
59
src/AliasVault.Admin/Services/VersionedContentService.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="VersionedContentService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Service to provide versioned content paths for cache busting of static files.
|
||||
/// </summary>
|
||||
public class VersionedContentService
|
||||
{
|
||||
private readonly Dictionary<string, string> _hashCache = new();
|
||||
private readonly string _webRootPath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VersionedContentService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="webRootPath">Web root path.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if webRootPath is not provided.</exception>
|
||||
public VersionedContentService(string webRootPath)
|
||||
{
|
||||
_webRootPath = webRootPath ?? throw new ArgumentNullException(nameof(webRootPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the versioned path for a content file.
|
||||
/// </summary>
|
||||
/// <param name="contentPath">Content path to the file.</param>
|
||||
/// <returns>Path with version suffix added.</returns>
|
||||
public string GetVersionedPath(string contentPath)
|
||||
{
|
||||
if (!_hashCache.TryGetValue(contentPath, out var version))
|
||||
{
|
||||
var serverPath = Path.Combine(_webRootPath, contentPath.TrimStart('/'));
|
||||
version = GetVersionHashFrom(serverPath);
|
||||
_hashCache[contentPath] = version;
|
||||
}
|
||||
|
||||
return $"{contentPath}?v={version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the version hash for a file.
|
||||
/// </summary>
|
||||
/// <param name="serverPath">Path to the file on the server.</param>
|
||||
/// <returns>MD5 hash.</returns>
|
||||
private static string GetVersionHashFrom(string serverPath)
|
||||
{
|
||||
using var md5 = MD5.Create();
|
||||
using var stream = File.OpenRead(serverPath);
|
||||
byte[] hash = md5.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
82
src/AliasVault.Admin/StartupTasks.cs
Normal file
82
src/AliasVault.Admin/StartupTasks.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="StartupTasks.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin;
|
||||
|
||||
using AliasServerDb;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Startup tasks that should be run when the application starts.
|
||||
/// </summary>
|
||||
public static class StartupTasks
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the roles if they do not exist.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">IServiceProvider instance.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public static async Task CreateRolesIfNotExist(IServiceProvider serviceProvider)
|
||||
{
|
||||
var roleManager = serviceProvider.GetRequiredService<RoleManager<AdminRole>>();
|
||||
|
||||
const string adminRole = "Admin";
|
||||
|
||||
if (!await roleManager.RoleExistsAsync(adminRole))
|
||||
{
|
||||
await roleManager.CreateAsync(new AdminRole(adminRole));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the admin user if it does not exist.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">IServiceProvider instance.</param>
|
||||
/// <returns>Async Task.</returns>
|
||||
public static async Task SetAdminUser(IServiceProvider serviceProvider)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AdminUser>>();
|
||||
var adminUser = await userManager.FindByNameAsync("admin");
|
||||
var config = serviceProvider.GetRequiredService<Config>();
|
||||
|
||||
if (adminUser == null)
|
||||
{
|
||||
var adminPasswordHash = config.AdminPasswordHash;
|
||||
adminUser = new AdminUser();
|
||||
adminUser.UserName = "admin";
|
||||
|
||||
await userManager.CreateAsync(adminUser);
|
||||
adminUser.PasswordHash = adminPasswordHash;
|
||||
adminUser.LastPasswordChanged = DateTime.UtcNow;
|
||||
await userManager.UpdateAsync(adminUser);
|
||||
|
||||
Console.WriteLine("Admin user created.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if the password hash is different AND the password in .env file is newer than the password of user.
|
||||
// If so, update the password hash of the user in the database so it matches the one in the .env file.
|
||||
if (adminUser.PasswordHash != config.AdminPasswordHash && (adminUser.LastPasswordChanged is null || config.LastPasswordChanged > adminUser.LastPasswordChanged))
|
||||
{
|
||||
// The password has been changed in the .env file, update the user's password hash.
|
||||
adminUser.PasswordHash = config.AdminPasswordHash;
|
||||
adminUser.LastPasswordChanged = DateTime.UtcNow;
|
||||
|
||||
// Reset 2FA settings
|
||||
adminUser.TwoFactorEnabled = false;
|
||||
|
||||
// Clear existing recovery codes
|
||||
await userManager.GenerateNewTwoFactorRecoveryCodesAsync(adminUser, 0);
|
||||
|
||||
await userManager.UpdateAsync(adminUser);
|
||||
|
||||
Console.WriteLine("Admin password hash updated.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/AliasVault.Admin/appsettings.Development.json
Normal file
8
src/AliasVault.Admin/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/AliasVault.Admin/appsettings.json
Normal file
12
src/AliasVault.Admin/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "aliasvault.webapp",
|
||||
"name": "aliasvault.client",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aliasvault.webapp",
|
||||
"name": "aliasvault.client",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "aliasvault.webapp",
|
||||
"name": "aliasvault.client",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build:css": "tailwindcss -i ./Styles/tailwind.css -o ./wwwroot/css/tailwind.css --watch"
|
||||
"build:css": "tailwindcss -i ./tailwind.css -o ./wwwroot/css/tailwind.css --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
58
src/AliasVault.Admin/tailwind.config.js
Normal file
58
src/AliasVault.Admin/tailwind.config.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./**/*.html',
|
||||
'./**/*.razor',
|
||||
'../Utilities/AliasVault.RazorComponents/**/*.razor',
|
||||
],
|
||||
safelist: [
|
||||
'w-64',
|
||||
'w-1/2',
|
||||
'rounded-l-lg',
|
||||
'rounded-r-lg',
|
||||
'bg-gray-200',
|
||||
'grid-cols-4',
|
||||
'grid-cols-7',
|
||||
'h-6',
|
||||
'leading-6',
|
||||
'h-9',
|
||||
'leading-9',
|
||||
'shadow-lg',
|
||||
'bg-opacity-50',
|
||||
'dark:bg-opacity-80'
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
"900": "#7b4a1e",
|
||||
"800": "#9a5d26",
|
||||
"700": "#b8702f",
|
||||
"600": "#d68338",
|
||||
"500": "#f49541",
|
||||
"400": "#f6a752",
|
||||
"300": "#f8b963",
|
||||
"200": "#fbcb74",
|
||||
"100": "#fdde85",
|
||||
"50": "#ffe096"
|
||||
}
|
||||
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'],
|
||||
'body': ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'],
|
||||
'mono': ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace']
|
||||
},
|
||||
transitionProperty: {
|
||||
'width': 'width'
|
||||
},
|
||||
textDecoration: ['active'],
|
||||
minWidth: {
|
||||
'kanban': '28rem'
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
],
|
||||
}
|
||||
41
src/AliasVault.Admin/wwwroot/css/app.css
Normal file
41
src/AliasVault.Admin/wwwroot/css/app.css
Normal file
@@ -0,0 +1,41 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user