Compare commits

...

250 Commits
0.2.0 ... 0.4.0

Author SHA1 Message Date
Leendert de Borst
364ade9181 Merge pull request #154 from lanedirt/146-add-e2e-test-with-two-users-trying-to-claim-the-same-email
Add e2e test with two users trying to claim the same email
2024-08-05 04:30:19 -07:00
Leendert de Borst
8883c87dfb Fix email conflict bug with multiple tests in same class (#146) 2024-08-05 13:25:15 +02:00
Leendert de Borst
8e35b39197 Update README.md (#146) 2024-08-05 13:14:33 +02:00
Leendert de Borst
79fd941b4e Add E2E test for checking duplicate email claim error (#146) 2024-08-05 13:14:25 +02:00
Leendert de Borst
b317407bfe Merge pull request #153 from lanedirt/145-add-client-settings-page-with-preference-for-default-domain-and-auto-email-refresh
Add client settings page with preference for default domain and auto email refresh
2024-08-05 04:04:50 -07:00
Leendert de Borst
885630b5db Refactor (#145) 2024-08-05 12:57:25 +02:00
Leendert de Borst
cc64f5c877 Add E2E test for client general settings page (#145) 2024-08-05 12:04:54 +02:00
Leendert de Borst
7d358e0c00 Implement general settings on credential page (#145) 2024-08-05 11:38:47 +02:00
Leendert de Borst
eacfee78cc Refactor SettingsService structure so it initializes when the DbService itself is ready (#145) 2024-08-05 11:05:51 +02:00
Leendert de Borst
d4a773fc2c Add settings table and service to client project (#145) 2024-08-05 09:57:33 +02:00
Leendert de Borst
540124cabf Merge pull request #152 from lanedirt/148-improve-email-popup-window-mechanism
Improve email popup window mechanism
2024-08-02 08:11:20 -07:00
Leendert de Borst
6db2b33576 Add IDisposable (#148) 2024-08-02 17:11:08 +02:00
Leendert de Borst
a132bfea65 Refactor EmailModal.razor (#148) 2024-08-02 16:56:46 +02:00
Leendert de Borst
d9f929ec63 Add encryption to email attachments (#148) 2024-08-02 16:41:23 +02:00
Leendert de Borst
f6f00bec3b Make ClickOutsideHandler component work (#148) 2024-08-02 16:19:08 +02:00
Leendert de Borst
798f8623d4 Email UI tweaks WIP (#148) 2024-08-02 11:30:44 +02:00
Leendert de Borst
27174c05ab Merge pull request #149 from lanedirt/137-improve-credential-email-generation-ui
Admin dashboard tweaks
2024-08-01 11:49:45 -07:00
Leendert de Borst
b8b95babe0 Admin dashboard tweaks (#137) 2024-08-01 20:41:45 +02:00
Leendert de Borst
741b514441 Merge pull request #147 from lanedirt/137-improve-credential-email-generation-ui 2024-07-31 14:31:53 -07:00
Leendert de Borst
f8493f2ff6 Update email field UX (#137) 2024-07-31 22:24:06 +02:00
Leendert de Borst
6f15026495 Add auto retry to E2E test for GitHub Actions as its prone to fail sometimes (#137) 2024-07-31 22:16:03 +02:00
Leendert de Borst
b9acaef46b Update RecentEmails.razor (#137) 2024-07-31 22:12:24 +02:00
Leendert de Borst
c0d8b9941d Client UI tweaks to email and password fields (#137) 2024-07-31 22:04:53 +02:00
Leendert de Borst
e44b52d357 Merge pull request #140 from lanedirt/139-smtpserver-process-is-consuming-100-cpu
Fix while loop high CPU usage bug
2024-07-30 14:04:44 -07:00
Leendert de Borst
1b79662113 Fix while loop high CPU usage bug (#139) 2024-07-30 22:55:55 +02:00
Leendert de Borst
eb2eadf14d Merge pull request #138 from lanedirt/117-add-email-ui-to-client-wasm-application-for-local-and-external-email
Add email encryption, add UI to client wasm application for local and external email
2024-07-30 13:21:57 -07:00
Leendert de Borst
175760cae6 Update DatabaseMessageStore.cs (#117) 2024-07-30 22:10:35 +02:00
Leendert de Borst
486dc67f94 Improve smtp server logic (#117) 2024-07-30 22:01:09 +02:00
Leendert de Borst
1609562499 Add test for full encryption/decryption flow (#117) 2024-07-30 18:36:08 +02:00
Leendert de Borst
31429fb5f5 Code style refactor (#117) 2024-07-29 23:32:25 +02:00
Leendert de Borst
ad7e9ea5ba Fix E2E tests for client project (#117) 2024-07-29 23:02:04 +02:00
Leendert de Borst
4c672a0ebe Added working client side decryption of emails (#117) 2024-07-29 22:51:56 +02:00
Leendert de Borst
05a2e3942c Add email view modal for external API (#117) 2024-07-29 17:59:38 +02:00
Leendert de Borst
fabb087874 Add user claims list to admin page (#117) 2024-07-29 16:50:58 +02:00
Leendert de Borst
c266fedd89 Add encryption logic to SmtpServer and integration tests (#117) 2024-07-29 16:39:06 +02:00
Leendert de Borst
e64893c26c Add JSInterop RSA methods, refactor JSInterop on client (#117) 2024-07-29 14:06:11 +02:00
Leendert de Borst
2016117d47 Add PKI tables (#117) 2024-07-29 11:18:38 +02:00
Leendert de Borst
7fd2b9d678 Merge pull request #134 from lanedirt/133-add-cache-busting-to-admin-app
Add cache busting to admin app
2024-07-29 00:25:35 -07:00
Leendert de Borst
1d5c5162e2 Make method static (#133) 2024-07-29 09:25:26 +02:00
Leendert de Borst
6407e1920f Update E2E test with VersionedContentService (#134) 2024-07-28 17:04:27 +02:00
Leendert de Borst
3bdc0f1171 Add cache busting to admin app (#133) 2024-07-28 16:53:40 +02:00
Leendert de Borst
a0f976f075 Merge pull request #132 from lanedirt/126-add-user-vault-statistics-to-admin-app
Add user vault statistics to admin app
2024-07-28 07:37:14 -07:00
Leendert de Borst
35104ce429 Do not run admin and client tests in parallel as it causes issues with the in-memory SQLite db (#126) 2024-07-28 16:19:34 +02:00
Leendert de Borst
ce43c1b2c0 Add filter by servicename (#126) 2024-07-28 15:47:53 +02:00
Leendert de Borst
00cc482342 Add user management to admin (#126) 2024-07-28 15:29:51 +02:00
Leendert de Borst
9e8521fa10 Fix dbcontext refresh in workerstatus blazor (#126) 2024-07-28 12:22:30 +02:00
Leendert de Borst
cdea2106b3 Update WorkerStatus blazor to auto refresh (#126) 2024-07-28 12:11:16 +02:00
Leendert de Borst
7cf03da0ee Merge pull request #131 from lanedirt/113-add-blazor-server-admin-project-for-user-and-smtp-management
Fix bug in install.sh (#113)
2024-07-26 14:36:58 -07:00
Leendert de Borst
cb8f677cdf Fix bug in install.sh (#113) 2024-07-26 23:36:35 +02:00
Leendert de Borst
771d82e35f Merge pull request #123 from lanedirt/113-add-blazor-server-admin-project-for-user-and-smtp-management
Add blazor server admin project for user and smtp management
2024-07-26 14:25:22 -07:00
Leendert de Borst
670dea6924 Improve tests (#113) 2024-07-26 23:14:26 +02:00
Leendert de Borst
d8cfdc2123 Updated log location for all services (#113) 2024-07-26 22:22:19 +02:00
Leendert de Borst
ad8ceff2a8 Add E2E test for API project and logging (#113) 2024-07-26 19:59:26 +02:00
Leendert de Borst
1e93c0786f Add E2E tests for admin project (#113) 2024-07-26 17:34:17 +02:00
Leendert de Borst
152ad6c973 Fix typo (#113) 2024-07-26 14:18:43 +02:00
Leendert de Borst
f51dd0b0cb Add StatusWorker control to admin project (#113) 2024-07-26 14:14:44 +02:00
Leendert de Borst
b06c00283d Update install.sh (#113) 2024-07-26 11:36:14 +02:00
Leendert de Borst
85fbb283c3 Add uninstall.sh script (#113) 2024-07-26 11:24:56 +02:00
Leendert de Borst
4cbedc7034 Code style refactor (#113) 2024-07-26 11:24:39 +02:00
Leendert de Borst
1e9dd71a7a Refactor StatusWorker library (#113) 2024-07-26 02:56:19 +02:00
Leendert de Borst
8d9f5ba302 Stable SmtpServerWorker with statusworker monitoring (#113) 2024-07-26 01:02:21 +02:00
Leendert de Borst
5e18ea163f Fix code style issues (#113) 2024-07-26 00:09:54 +02:00
Leendert de Borst
2f7a5acf42 Update packages, add dynamic service start/stop logic WIP (#113) 2024-07-26 00:07:51 +02:00
Leendert de Borst
99cc429779 Fix logs page CSS (#113) 2024-07-25 19:51:38 +02:00
Leendert de Borst
f0335b485e Add searchable logs page to admin app (#113) 2024-07-24 23:10:33 +02:00
Leendert de Borst
fc8f935092 Update DatabaseSink logic (#113) 2024-07-24 22:11:08 +02:00
Leendert de Borst
d5cf51b5da Add improved logging for SmtpService (#113) 2024-07-24 18:31:23 +02:00
Leendert de Borst
1ae5143fb7 Update install.sh dependencies (#113) 2024-07-23 21:09:11 +02:00
Leendert de Borst
ac284ba71a Update install.sh script (#113) 2024-07-23 21:06:37 +02:00
Leendert de Borst
bf68e380bc Fix admin bugs (#113) 2024-07-22 23:57:37 +02:00
Leendert de Borst
d87800f370 Fix docker build for admin project (#113) 2024-07-22 23:35:43 +02:00
Leendert de Borst
d65db96447 Fix analyzer issues, update docker compose (#113) 2024-07-22 23:04:08 +02:00
Leendert de Borst
b2e344c523 Refactor codestyle issues (#113) 2024-07-22 22:36:16 +02:00
Leendert de Borst
2b9d7d2818 Fix warnings (#113) 2024-07-22 21:34:36 +02:00
Leendert de Borst
d79c2d34a5 Delete old admin project, rename admin2 to admin (#113) 2024-07-22 17:32:12 +02:00
Leendert de Borst
586aafe1f1 Cleanup of old pages, reset 2FA when updating admin password through CLI (#113) 2024-07-22 17:24:56 +02:00
Leendert de Borst
6cb017af1c Add admin db project password seeding logic, extended init.sh (#113) 2024-07-22 17:14:21 +02:00
Leendert de Borst
25462e38bd Fix auth redirect (#123) 2024-07-22 13:16:25 +02:00
Leendert de Borst
4ba6c365a5 Add redirect to login page when already logged in (#113) 2024-07-22 12:52:58 +02:00
Leendert de Borst
aa5d229687 Refactor admin project folder structure (#113) 2024-07-22 11:47:39 +02:00
Leendert de Borst
022370f799 Update manage account page style (#113) 2024-07-22 11:38:58 +02:00
Leendert de Borst
050470453a Admin project refactor (#113) 2024-07-22 11:03:58 +02:00
Leendert de Borst
5a2353fb11 Style login/2FA pages with tailwind CSS (#113) 2024-07-22 00:24:39 +02:00
Leendert de Borst
c73769750c Add pagetitle component to admin, refactoring (#113) 2024-07-21 21:00:53 +02:00
Leendert de Borst
8cda9c06a2 Replace bootstrap with tailwind for admin (#113) 2024-07-21 20:38:57 +02:00
Leendert de Borst
6d16ff234a Update admin scaffolding with full Blazor interactive server routes (#113) 2024-07-21 19:40:08 +02:00
Leendert de Borst
220fbe2be2 Update account login URLs (#113) 2024-07-21 16:36:43 +02:00
Leendert de Borst
64d924d8a4 Add new admin project from scratch with improved identity scaffolding (#113) 2024-07-21 15:14:19 +02:00
Leendert de Borst
da2615096e Update admin project folder structure (#113) 2024-07-21 14:48:27 +02:00
Leendert de Borst
87f2997ce8 Code style refactor (#113) 2024-07-21 00:17:20 +02:00
Leendert de Borst
467943ec49 Make admin and aliasvault user table definitions work together (#113) 2024-07-20 23:20:27 +02:00
Leendert de Borst
902147cbf6 Add admin project, add separate admin and user identity tables (#113) 2024-07-20 12:59:03 +02:00
Leendert de Borst
b165969598 Merge pull request #122 from lanedirt/111-add-e2eunit-test-for-email-smtp-service
Fix SMTP Dockerfile permissions
2024-07-19 12:50:42 -07:00
Leendert de Borst
39ab7558f9 Remove user statement from SMTP Dockerfile as it conflicts with file permissions (#111) 2024-07-19 21:44:17 +02:00
Leendert de Borst
600c4d32ab Merge pull request #120 from lanedirt/111-add-e2eunit-test-for-email-smtp-service
Add SmtpService health check to Github Action after docker build (#111)
2024-07-19 12:01:56 -07:00
Leendert de Borst
00b145bcf9 Fix nc test if/else (#111) 2024-07-19 20:53:14 +02:00
Leendert de Borst
4c15d64ece Change curl to nc to check tcp port (#111) 2024-07-19 20:46:35 +02:00
Leendert de Borst
a639a2581a Update sed command without macOS fix (#111) 2024-07-19 20:37:01 +02:00
Leendert de Borst
51cd53ee9e Change sed command (#111) 2024-07-19 20:32:48 +02:00
Leendert de Borst
7ba94b9315 Change SMTP port from 25 to 2525 for GH Actions only (#111) 2024-07-19 20:23:20 +02:00
Leendert de Borst
28275bb6d9 Update docker-compose-build.yml (#111) 2024-07-19 20:15:26 +02:00
Leendert de Borst
953e45f62e Add SmtpService test to Github Action after docker build (#111) 2024-07-19 17:39:39 +02:00
Leendert de Borst
709514ff3c Merge pull request #119 from lanedirt/111-add-e2eunit-test-for-email-smtp-service
Update docker-compose.yml to include database volume for SmtpService
2024-07-19 08:37:08 -07:00
Leendert de Borst
12940d46d3 Update docker-compose.yml to include database volume for SmtpService (#111) 2024-07-19 17:35:06 +02:00
Leendert de Borst
8f4b6a5d1b Merge pull request #116 from lanedirt/111-add-e2eunit-test-for-email-smtp-service
Add integration test for email smtp service
2024-07-19 08:03:24 -07:00
Leendert de Borst
d46e582c91 Add Assert.Multiple (#111) 2024-07-19 16:50:32 +02:00
Leendert de Borst
7bbf986c09 Change integration test ports so it works with GitHub Actions (#111) 2024-07-19 16:44:28 +02:00
Leendert de Borst
2e6d5c87bc Add SMTP service integration tests (#111) 2024-07-19 16:31:39 +02:00
Leendert de Borst
0e4d0b0f84 Make basic SMTP service integration test work (#111) 2024-07-19 15:06:44 +02:00
Leendert de Borst
533362210b Add IntegrationTests project (#111) 2024-07-19 14:42:43 +02:00
Leendert de Borst
014064376c Add migrate on SmtpServer startup (#111) 2024-07-19 11:08:42 +02:00
Leendert de Borst
b26ddb809c Merge pull request #115 from lanedirt/105-add-email-storage-to-server-database
Make SmtpServer save emails to database
2024-07-19 01:59:04 -07:00
Leendert de Borst
e9a95fcc53 Refactor DatabaseMessageStore.cs structure (#105) 2024-07-19 10:50:20 +02:00
Leendert de Borst
b3ddf94089 Add SMTP service settings to environment variables so it can be exposed via Docker (#105) 2024-07-18 21:36:26 +02:00
Leendert de Borst
2213ab94da Add email table migration, update SmtpServer to save emails to database (#105) 2024-07-18 21:06:18 +02:00
Leendert de Borst
62d95d73e1 Merge pull request #114 from lanedirt/104-add-email-server-scaffolding-to-docker-stack
Add AliasVault.SmtpService scaffolding
2024-07-17 09:27:33 -07:00
Leendert de Borst
22a1fc089e Add TLS port to smtp docker compose (#104) 2024-07-16 23:31:04 +02:00
Leendert de Borst
9f95157c18 Add smtp service to docker compose (#104) 2024-07-16 23:30:25 +02:00
Leendert de Borst
dec69e959d Add AliasVault.SmtpService project, refactor solution structure (#104) 2024-07-16 20:47:00 +02:00
Leendert de Borst
81a52dcc0e Merge pull request #103 from lanedirt/85-update-readme-instructions-with-e2e-encryption-explanation
Update readme instructions
2024-07-12 07:16:00 -07:00
Leendert de Borst
dd3818499c Update README.md 2024-07-12 16:08:06 +02:00
Leendert de Borst
b141196384 Update README.md 2024-07-12 15:35:19 +02:00
Leendert de Borst
2dfe8c64e5 Update README.md (#85) 2024-07-12 15:31:18 +02:00
Leendert de Borst
033a513c92 Merge pull request #99 from lanedirt/98-improve-oobe-user-experience
Rename email prefix to email field and refactor logic (#98)
2024-07-12 04:33:03 -07:00
Leendert de Borst
0bbb504511 Update DbPersistTest.cs (#98) 2024-07-12 13:11:45 +02:00
Leendert de Borst
88fe86c19a Update DbPersistTest.cs (#98) 2024-07-12 12:56:31 +02:00
Leendert de Borst
3ccf239d84 Update DbPersistTest.cs (#98) 2024-07-12 12:50:20 +02:00
Leendert de Borst
c6ef654c87 Layout update (#98) 2024-07-12 12:45:42 +02:00
Leendert de Borst
4ef0373d31 Add global loading indicator to save actions, refactoring (#98) 2024-07-12 11:45:30 +02:00
Leendert de Borst
71f91b8050 Update csproj to conditionally include appsettings.Development.json (#98) 2024-07-12 10:11:02 +02:00
Leendert de Borst
6d8315ac4e Remove appsettings.Development from git (#98) 2024-07-12 09:52:45 +02:00
Leendert de Borst
08e550f46f Add appsettings.json for E2E test local config override (#98) 2024-07-12 09:48:19 +02:00
Leendert de Borst
dfa67a00e3 Rename email prefix to email field and refactor logic (#98) 2024-07-11 22:55:55 +02:00
Leendert de Borst
cdd58773de Merge pull request #97 from lanedirt/95-add-favicon-extract-timeout
Add favicon extract timeout
2024-07-09 13:08:37 -07:00
Leendert de Borst
808ecb865b Merge branch 'main' into 95-add-favicon-extract-timeout 2024-07-09 13:08:24 -07:00
Leendert de Borst
277ff6c012 Move timeout to webapi (#95) 2024-07-09 22:07:44 +02:00
Leendert de Borst
836f7a311e Add timeout to favicon extraction (#95) (#96) 2024-07-09 21:54:49 +02:00
Leendert de Borst
a3b232543f Add timeout to favicon extraction (#95) 2024-07-09 21:54:27 +02:00
Leendert de Borst
6077f8f377 Merge pull request #94 from lanedirt/93-username-field-value-not-shown-in-view-page
Fix username field value
2024-07-09 12:35:48 -07:00
Leendert de Borst
62711f603b Fix username field value (#93) 2024-07-09 21:35:29 +02:00
Leendert de Borst
a59fa22fa6 Merge pull request #91 from lanedirt/74-add-versioning-support-to-local-sqlite-implementation-with-local-upgrade-paths
Add versioning support to local sqlite implementation with local upgrade paths
2024-07-09 12:19:10 -07:00
Leendert de Borst
cc10bcdfb8 Add asserts to test (#74) 2024-07-09 21:11:49 +02:00
Leendert de Borst
481c283c36 Update PlaywrightTest.cs (#74) 2024-07-09 00:50:57 +02:00
Leendert de Borst
947988c2ce Add empty test migration to make the E2E vault upgrade test work (#74) 2024-07-09 00:45:55 +02:00
Leendert de Borst
202df3d9c3 Update DbUpgradeTest.cs (#74) 2024-07-09 00:29:16 +02:00
Leendert de Borst
8f3ad3d171 Add E2E test scaffolding with seed data (#74) 2024-07-09 00:21:15 +02:00
Leendert de Borst
aa771ae1b2 Fix sync redirect (#74) 2024-07-08 21:43:19 +02:00
Leendert de Borst
158a526aee Fix auth revoke token call (#74) 2024-07-08 16:45:05 +02:00
Leendert de Borst
8c4e078490 Fix tests to after adding new new sync page (#74) 2024-07-08 16:40:34 +02:00
Leendert de Borst
98dea2c4bf Improve client DB sync status indicators (#74) 2024-07-08 16:31:24 +02:00
Leendert de Borst
db62eeec22 Add client DB migration screen (#74) 2024-07-08 12:55:12 +02:00
Leendert de Borst
af1e813c48 Rename initial migration to include explicit version number (#74) 2024-07-08 11:54:09 +02:00
Leendert de Borst
d57ac9e743 Update E2E sleep threshold to 100ms to prevent ERR_CONNECTION_REFUSED (#74) 2024-07-08 11:53:47 +02:00
Leendert de Borst
f5e02fb784 Add register warning test (#74) 2024-07-08 11:26:01 +02:00
Leendert de Borst
96a7fbaf3b Add vault decrypt error message (#74) 2024-07-08 11:25:50 +02:00
Leendert de Borst
c749853870 Merge pull request #90 from lanedirt/83-refactor-wasm-project-for-naming-conventions-and-fix-remaining-todos 2024-07-03 14:39:38 -07:00
Leendert de Borst
8c93fceb3e Make E2E test more robust (#83) 2024-07-03 22:41:43 +02:00
Leendert de Borst
fb0ef1c59a Generic refactoring, delete unused files, folder restructuring (#83) 2024-07-03 20:08:18 +02:00
Leendert de Borst
847d97a0e9 Merge pull request #87 from lanedirt/81-add-support-for-attachments-file-upload 2024-07-02 23:39:18 -07:00
Leendert de Borst
8d9c80ef61 Fix multiple attachment add bug (#81) 2024-07-03 00:27:16 +02:00
Leendert de Borst
c22ba0c2cf Add DbPersist E2E test (#81) 2024-07-02 23:59:00 +02:00
Leendert de Borst
5e4654a968 Update DbService.cs (#81) 2024-07-02 23:23:23 +02:00
Leendert de Borst
bbf08d16d4 Update AttachmentViewer.cs (#81) 2024-07-02 23:21:30 +02:00
Leendert de Borst
19c6296a86 Fix tests (#81) 2024-07-02 23:20:03 +02:00
Leendert de Borst
b8ffd39f99 Revert encryption key debug statement (#81) 2024-07-02 23:15:38 +02:00
Leendert de Borst
fe29cb7a2c Merge branch 'main' into 81-add-support-for-attachments-file-upload 2024-07-02 14:13:05 -07:00
Leendert de Borst
4137cc4736 Add attachment uploader/viewer component (#81) 2024-07-02 23:11:34 +02:00
Leendert de Borst
3d23731f0e Merge pull request #86 from lanedirt/84-add-exportimport-database-to-unencrypted-csv
Add CSV import/export and DB loader (#84)
2024-07-02 14:09:45 -07:00
Leendert de Borst
bcbda92601 Update tests (#84) 2024-07-02 21:58:39 +02:00
Leendert de Borst
f0fca573fd Updated vault settings page layout, refactor tests (#84) 2024-07-02 21:22:39 +02:00
Leendert de Borst
4abc674970 Add CSV import/export and DB loader (#84) 2024-07-02 20:49:53 +02:00
Leendert de Borst
43ed35a1be Merge pull request #79 from lanedirt/58-migrate-existing-alias-crud-pages-to-new-local-sqlite-implementation
Refactor CRUD pages to local SQLite model
2024-07-01 15:50:16 -07:00
Leendert de Borst
689ab0b388 Update CredentialService.cs (#58) 2024-07-02 00:46:35 +02:00
Leendert de Borst
a884895fae Restored favicon extraction logic (#58) 2024-07-02 00:05:40 +02:00
Leendert de Borst
a644df1e3c Add notes and birthdate field, add date field validation (#58) 2024-07-01 17:18:42 +02:00
Leendert de Borst
48abe09415 Rename login to credentials, fixed warnings and bugs (#58) 2024-07-01 15:43:32 +02:00
Leendert de Borst
f1bc79a9a4 Refactor (#58) 2024-07-01 13:45:07 +02:00
Leendert de Borst
ce61fc36e1 Refactor SpamOK webapi model to eliminate duplicated code (#58) 2024-07-01 13:02:29 +02:00
Leendert de Borst
cbce527aa1 Delete AliasController.cs old api endpoints (#58) 2024-07-01 12:49:11 +02:00
Leendert de Borst
53ea7c2477 Remove client tables from server db (#58) 2024-07-01 12:46:50 +02:00
Leendert de Borst
aae0846639 Refactor CRUD pages to local SQLite model (#58) 2024-06-30 17:58:01 +02:00
Leendert de Borst
c2648cf2cb Merge pull request #76 from lanedirt/63-abstract-auth-loginunlock-to-share-the-same-implementation-to-reduce-duplicate-code
Abstract Login and Unlock duplicated logic (#63)
2024-06-29 14:23:21 -07:00
Leendert de Borst
68c19957e0 Add webapi call finish content to test pages for improved stability (#63) 2024-06-29 23:12:51 +02:00
Leendert de Borst
6c79503d1f Fix unittests (#63) 2024-06-29 23:00:41 +02:00
Leendert de Borst
c5fc5c0e81 Add dedicated test pages to webapi and wasm for E2E tests (#63) 2024-06-29 22:57:19 +02:00
Leendert de Borst
4170e754ea Add JWT auth tokens E2E tests (#63) 2024-06-29 13:07:06 +02:00
Leendert de Borst
5d44a3aeff Fix missing redirect in login handler (#63) 2024-06-29 11:23:27 +02:00
Leendert de Borst
8243213028 Abstract Login and Unlock duplicated logic (#63) 2024-06-28 23:33:33 +02:00
Leendert de Borst
efc422ac44 Merge pull request #75 from lanedirt/69-implement-vault-changebackup-history-logic-on-server
Implement vault changebackup history logic on server
2024-06-28 14:32:01 -07:00
Leendert de Borst
497430f729 Refactor (#69) 2024-06-28 23:20:20 +02:00
Leendert de Borst
659dc7b55d Refactor DbService client logic (#69) 2024-06-28 20:04:26 +02:00
Leendert de Borst
581fd945c2 Update VaultRetention logic and add tests (#69) 2024-06-28 20:04:06 +02:00
Leendert de Borst
8205fa9d6e Add vault retention logic (#69) 2024-06-28 12:29:45 +02:00
Leendert de Borst
cc4a6db976 Merge pull request #71 from lanedirt/55-add-client-side-database-sync-indicator-to-ui
Add client side database sync indicator to UI
2024-06-28 02:28:24 -07:00
Leendert de Borst
efb7ae009d Remove unused vars (#55) 2024-06-28 10:56:12 +02:00
Leendert de Borst
f29606ea94 Update UI style (#55) 2024-06-28 10:47:38 +02:00
Leendert de Borst
4a586cf117 Add DbStatus indicator to UI (#55) 2024-06-28 10:05:50 +02:00
Leendert de Borst
64688cd2b5 Merge pull request #68 from lanedirt/67-improve-e2e-test-stability
Change port assignment logic (#67)
2024-06-26 15:24:16 -07:00
Leendert de Borst
ea9c6e9aa7 Update Program.cs (#67) 2024-06-27 00:07:33 +02:00
Leendert de Borst
889dc81404 Add host project for in-memory testing of WASM app (#67) 2024-06-26 18:53:47 +02:00
Leendert de Borst
df85846fbe Change port assignment logic (#67) 2024-06-26 08:55:26 +02:00
Leendert de Borst
5a9632f80e Merge pull request #66 from lanedirt/60-unlock-screen-can-be-bypassed-by-clicking-on-logo
Add unlock page redirect test (#60)
2024-06-25 15:02:30 -07:00
Leendert de Borst
9105215dc8 Add unlock page redirect test (#60) 2024-06-25 23:55:39 +02:00
Leendert de Borst
4aa57f95b5 Merge pull request #65 from lanedirt/60-unlock-screen-can-be-bypassed-by-clicking-on-logo
Add encryption key check to generic PageBase (#60)
2024-06-25 14:40:19 -07:00
Leendert de Borst
1112922731 Add assert to UnlockTest (#60) 2024-06-25 23:33:41 +02:00
Leendert de Borst
2bee131ff4 Add E2E test for unlock mechanism (#60) 2024-06-25 23:26:54 +02:00
Leendert de Borst
75933efbdd Refactor isEncryptionKeySet logic to prevent delayed navigation loops (#60) 2024-06-25 23:03:06 +02:00
Leendert de Borst
93623a2f05 Update AliasClientDbService.cs (#60) 2024-06-25 22:35:11 +02:00
Leendert de Borst
4b12518ee4 Fix auth state redirect loop (#60) 2024-06-25 21:56:46 +02:00
Leendert de Borst
2a834eeb38 Add encryption key check to generic PageBase (#60) 2024-06-25 21:40:57 +02:00
Leendert de Borst
25872f08de Merge pull request #64 from lanedirt/59-save-encryption-key-to-session-on-registration-next-to-login
Add missing store calls (#59)
2024-06-24 11:05:29 -07:00
Leendert de Borst
9fc0f4d7da Merge pull request #62 from lanedirt/61-move-aliasclientdb-project-to-correct-folder
Move AliasClientDb project to correct folder (#61)
2024-06-24 10:39:35 -07:00
Leendert de Borst
4e2bf10115 Add missing store calls (#59) 2024-06-24 19:37:48 +02:00
Leendert de Borst
957a9474ec Move AliasClientDb project to correct folder (#61) 2024-06-24 19:32:38 +02:00
Leendert de Borst
bd0d4ad2a4 Merge pull request #56 from lanedirt/44-change-datamodel-to-be-more-dynamic-and-support-client-side-encryption
44 change datamodel to be more dynamic and support client side encryption
2024-06-24 10:09:20 -07:00
Leendert de Borst
88fa8a0c17 Update Dockerfile (#44) 2024-06-24 19:04:54 +02:00
Leendert de Borst
943f16789f Update docker compose (#44) 2024-06-24 18:34:01 +02:00
Leendert de Borst
bb91637db5 Refactoring (#44) 2024-06-24 17:38:58 +02:00
Leendert de Borst
5aef4c58e2 Add working version of client-side SQLite sync via webapi (#44) 2024-06-24 17:15:19 +02:00
Leendert de Borst
554ea91bda Add SQLite in-memory load/save mechanism (#44) 2024-06-24 14:35:45 +02:00
Leendert de Borst
d5a858d78d Refactor EF database projects, added basic client-side SQLite in-memory implementation (#44) 2024-06-24 11:30:37 +02:00
Leendert de Borst
ff265c3a86 Remove AliasVault Blazor server project (#44) 2024-06-24 09:10:20 +02:00
Leendert de Borst
f2fced86b2 Merge pull request #53 from lanedirt/52-fix-ide-warning-configuration-in-rider-regardless-of-user-settings
Move dotnet code style settings to .globalconfig (#52)
2024-06-23 12:44:26 -07:00
Leendert de Borst
83f62e17b2 Update Dockerfile (#52) 2024-06-23 21:35:42 +02:00
Leendert de Borst
0f80217e74 Replace buildtask project with RoslynCodeTaskFactory implementation (#52) 2024-06-23 21:33:56 +02:00
Leendert de Borst
7290ee870c Update buildtasks so it works on both Windows and Mac/Linux (#52) 2024-06-23 20:56:39 +02:00
Leendert de Borst
27c0c9194e Add custom build task for cache busting (#52) 2024-06-23 20:13:56 +02:00
Leendert de Borst
ae652297fa Update Login.razor (#52) 2024-06-23 19:49:11 +02:00
Leendert de Borst
bcdb9efee8 Move dotnet code style settings to .globalconfig (#52) 2024-06-23 19:41:30 +02:00
Leendert de Borst
f224507f91 Merge pull request #51 from lanedirt/49-fix-docker-build-ef-migrations
Update docker-compose.build.yml (#49)
2024-06-23 10:28:38 -07:00
Leendert de Borst
dc9ba64b21 Refactor dockerfiles to remove explicit EF bundle (#49) 2024-06-23 19:22:03 +02:00
Leendert de Borst
c99394416e Update docker-compose-build.yml typo (#49) 2024-06-23 18:31:05 +02:00
Leendert de Borst
ba2abde97d Update RootController.cs (#49) 2024-06-23 18:27:20 +02:00
Leendert de Borst
66b33a8686 Add healthcheck webapi endpoint (#49) 2024-06-23 18:24:33 +02:00
Leendert de Borst
1ebdce5216 Update docker-compose.build.yml (#49) 2024-06-23 18:03:42 +02:00
Leendert de Borst
c2d1ea9895 Merge pull request #50 from lanedirt/49-fix-docker-build-ef-migrations
49 fix docker build ef migrations
2024-06-23 09:00:50 -07:00
Leendert de Borst
75a9278d56 Update .gitignore (#49) 2024-06-23 17:55:53 +02:00
Leendert de Borst
b508354ac6 Remove .env from git (#49) 2024-06-23 17:53:50 +02:00
Leendert de Borst
e1478c055f Merge pull request #48 from lanedirt/43-implement-master-password-for-login-and-basic-encryption
Implement master password for login and basic encryption
2024-06-23 08:28:37 -07:00
Leendert de Borst
c07f0c33bb Update AuthController.cs (#43) 2024-06-23 17:22:17 +02:00
Leendert de Borst
4e2b10eeab Add try/catch to E2E test init (#43) 2024-06-23 17:12:35 +02:00
Leendert de Borst
05e9285752 Code style refactor (#43) 2024-06-23 16:35:45 +02:00
Leendert de Borst
a76a21a935 Update TestDefaults.cs due to Argon2id speed in WASM (#43) 2024-06-21 19:28:20 +02:00
Leendert de Borst
0fd23eab59 Fix code style issues (#43) 2024-06-21 19:27:54 +02:00
Leendert de Borst
1c1d1e1d74 Fix JWT key retrieval (#43) 2024-06-21 17:38:51 +02:00
Leendert de Borst
9412f862eb Make Argon2id dynamic using SRP salt (#43) 2024-06-21 15:52:08 +02:00
Leendert de Borst
9f7ba2eb20 Implement SPR for login flow (#43) 2024-06-21 15:37:51 +02:00
Leendert de Borst
101d1d485a Implement SPR for basic signup flow (#43) 2024-06-21 11:48:27 +02:00
Leendert de Borst
e316836ee5 Make basic SRP flow work (#43) 2024-06-21 10:44:09 +02:00
Leendert de Borst
c6e3c41759 Add SRP auth scaffolding (#43) 2024-06-21 00:52:19 +02:00
569 changed files with 32788 additions and 38648 deletions

View File

@@ -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
.env
View File

@@ -1 +0,0 @@
JWT_KEY=YprFMYAzrqY/R/DmDYZI1PS7qTyZYp4g

View File

@@ -1 +1,4 @@
API_URL=
JWT_KEY=
PRIVATE_EMAIL_DOMAINS=
SMTP_TLS_ENABLED=false

View File

@@ -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
View 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"

View File

@@ -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

View File

@@ -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
View File

@@ -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
View 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
View 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"
}
]
}

View File

@@ -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
View 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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,5 @@
# Documentation
This is the documentation for the AliasVault project.
## Description
TODO: Work in progress.

View 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
```

View 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.

View 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
View File

@@ -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
View 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 "$@"

View File

@@ -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();
}
}
}

View File

@@ -12,7 +12,7 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -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.

View File

@@ -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.

View 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>

View 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>

View 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();
}
}

View 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;
}

View 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("/");
}
}
}

View 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>

View 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>

View 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; }
}
}

View 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; }
}
}

View 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; } = "";
}
}

View 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);
}
}
}

View 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

View File

@@ -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;
}
}

View File

@@ -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

View 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;
}

View 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"]

View 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;
}

View File

@@ -1,7 +1,6 @@
@using Microsoft.IdentityModel.Tokens
@inherits ComponentBase
@inherits ComponentBase
@if (Message.IsNullOrEmpty())
@if (Message == string.Empty)
{
return;
}

View File

@@ -1,7 +1,6 @@
@using Microsoft.IdentityModel.Tokens
@inherits ComponentBase
@inherits ComponentBase
@if (Message.IsNullOrEmpty())
@if (Message == string.Empty)
{
return;
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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">

View File

@@ -0,0 +1,9 @@
<PageTitle>@ChildContent - AliasVault Admin</PageTitle>
@code {
/// <summary>
/// Child content.
/// </summary>
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
}

View File

@@ -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; }

View File

@@ -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;
}

View 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);
}
}
}

View File

@@ -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" : "")}";
}
}

View 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);
}
}

View 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();
}
}

View 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;
}

View File

@@ -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;
}
}

View 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; }
}

View 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; }
}

View File

@@ -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; } = "";
}
}

View File

@@ -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; } = [];
}

View File

@@ -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);
}
}

View File

@@ -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&amp;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; } = "";
}
}

View File

@@ -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);
}
}

View 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; }
}
}

View File

@@ -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");
}
}

View File

@@ -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());
}
}

View File

@@ -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]

View 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>

View 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>

View 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();
}
}

View File

@@ -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;

View 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>

View 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();
}
}

View 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]";
}
}

View 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);
}
}

View 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();
}
}

View 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();
}
}

View 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>

View 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

View 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
{
}
}

View File

@@ -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"
}

View 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;
}

View File

@@ -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.

View 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
}
}

View 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);
}

View 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();
}

View 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();
}
}

View 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.");
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -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": {

View File

@@ -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": "",

View 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: [
],
}

View 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