Compare commits

...

163 Commits
0.3.0 ... 0.5.0

Author SHA1 Message Date
Leendert de Borst
18978b94be Merge pull request #173 from lanedirt/164-add-oobe-beginning-screen-if-user-does-not-have-any-credentials-yet
Out-of-box experience UX tweaks
2024-08-16 06:08:30 -07:00
Leendert de Borst
c989573565 Update Vault.razor (#164) 2024-08-16 14:57:21 +02:00
Leendert de Borst
67ce7da21a Refactor (#164) 2024-08-16 14:48:40 +02:00
Leendert de Borst
fb2972695a Update E2E tests (#164) 2024-08-16 14:35:18 +02:00
Leendert de Borst
2f47f81af8 Fix bug in email credential lookup query (#164) 2024-08-16 13:38:24 +02:00
Leendert de Borst
6d6ee8bf3f Add enter on form submit for AddEdit page, refactor service URL placeholder logic (#164) 2024-08-16 13:34:58 +02:00
Leendert de Borst
881eb58a35 Add focus tweaks to Credentials AddEdit page (#164) 2024-08-16 13:27:07 +02:00
Leendert de Borst
80bc7cd223 Add welcome page for new users for OOBE (#164) 2024-08-16 12:25:52 +02:00
Leendert de Borst
87f494fea8 Layout tweaks (#164) 2024-08-16 12:25:28 +02:00
Leendert de Borst
a24e533e4c Tweak settings page layout (#164) 2024-08-16 12:24:52 +02:00
Leendert de Borst
ebb8b27f85 Update DbStatusIndicator.razor (#164) 2024-08-16 12:24:40 +02:00
Leendert de Borst
41c210e75a Add minimum loading screen delay to blazor bootstrap to improve UX (#164) 2024-08-15 21:52:21 +02:00
Leendert de Borst
2a50a455d8 Merge pull request #170 from lanedirt/165-add-styled-wasm-loading-animation
Updated blazor loading animation to AliasVault style
2024-08-13 11:05:31 -07:00
Leendert de Borst
6896c4cd1d Updated blazor loading animation to AliasVault style (#165) 2024-08-13 19:05:15 +02:00
Leendert de Borst
9560572a40 Merge pull request #169 from lanedirt/144-update-client-side-validation-for-all-form-steps
Update client side validation for all form steps
2024-08-12 11:49:15 -07:00
Leendert de Borst
4dffb9c3c0 Change StartsWith overload (#144) 2024-08-12 20:39:12 +02:00
Leendert de Borst
b8cb3c4d78 Add username generate button, fix form validation bugs, tweak UI (#144) 2024-08-12 19:07:39 +02:00
Leendert de Borst
6f54b05d5a Update email style (#144) 2024-08-12 16:15:27 +02:00
Leendert de Borst
d051d69aea Merge pull request #166 from lanedirt/160-rework-credential-view-page-to-show-most-relevant-data-first
Add email page to browse through all received emails
2024-08-12 04:39:21 -07:00
Leendert de Borst
02f0c43cbd Code style refactor (#160) 2024-08-12 13:31:20 +02:00
Leendert de Borst
14cce42091 Add email page to browser through all received emails for all claimed email addresses(#160) 2024-08-12 13:20:20 +02:00
Leendert de Borst
a1c26cec04 Merge pull request #163 from lanedirt/158-add-global-search-bar
Add global search bar
2024-08-09 08:55:22 -07:00
Leendert de Borst
42fc1c018c Add E2E test for global search bar (#158) 2024-08-09 17:47:42 +02:00
Leendert de Borst
f3e740bab3 Add global search bar widget (#158) 2024-08-09 13:51:02 +02:00
Leendert de Borst
bbdf47d6f4 Merge pull request #162 from lanedirt/161-keyboard-shortcuts-stop-working-when-something-else-has-been-typed-before 2024-08-07 22:15:09 -07:00
Leendert de Borst
5faf93d6be Fix CredentialTest, replace wait text after breadcrumb change (#161) 2024-08-07 23:55:47 +02:00
Leendert de Borst
fa1573ee13 Update keyboardShortcuts.js, fix bug (#161) 2024-08-07 23:27:44 +02:00
Leendert de Borst
50f7866a0b Improve GlobalNotificationDisplay system (#161) 2024-08-07 23:25:25 +02:00
Leendert de Borst
7b1a1e893e Merge pull request #159 from lanedirt/142-design-new-client-datamodel-structure-for-credentialsaliases-with-simplified-user-flow
Add quick create new identity popup
2024-08-07 13:39:59 -07:00
Leendert de Borst
40afea3908 Fix parallel E2E tests race condition (#142) 2024-08-07 22:33:58 +02:00
Leendert de Borst
e1ae260fc5 Code style refactor (#142) 2024-08-07 22:28:46 +02:00
Leendert de Borst
c33399b91d Add E2E test for quick create widget (#142) 2024-08-07 22:24:34 +02:00
Leendert de Borst
f46202223a Fix tests (#142) 2024-08-07 22:01:37 +02:00
Leendert de Borst
0867573f2f Load specific JS via isolated modules, refactor CredentialService (#142) 2024-08-07 20:39:39 +02:00
Leendert de Borst
2becb3aa8f Refactor (#142) 2024-08-06 22:04:12 +02:00
Leendert de Borst
dc2f4dd040 Add quick create new identity popup (#142) 2024-08-06 20:29:48 +02:00
Leendert de Borst
2cf3c142da Merge pull request #157 from lanedirt/156-add-e2e-test-for-generating-identity-via-client-gui
Add E2E test for identity generation in client (#156)
2024-08-05 13:53:21 -07:00
Leendert de Borst
a8d84fd38a Update CredentialTest.cs (#156) 2024-08-05 22:43:03 +02:00
Leendert de Borst
4a207763cc Add E2E test for identity generation in client (#156) 2024-08-05 21:18:21 +02:00
Leendert de Borst
b1ef5c33db Merge pull request #155 from lanedirt/108-add-identity-generator-scaffolding-utility-project
Add identity generator utility project for EN and NL identities
2024-08-05 11:35:56 -07:00
Leendert de Borst
578532efdf Code style refactor (#108) 2024-08-05 20:21:48 +02:00
Leendert de Borst
95fb8baaaa Add nonbacktracking option to regexes (#108) 2024-08-05 20:20:28 +02:00
Leendert de Borst
73e432b2dc Refactor identity generation logic (#108) 2024-08-05 17:24:51 +02:00
Leendert de Borst
f43c3171b0 Add local dictionary based identity generation (#108) 2024-08-05 16:34:22 +02:00
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
374 changed files with 27303 additions and 2479 deletions

View File

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

View File

@@ -15,13 +15,14 @@ jobs:
options: --privileged
steps:
- uses: actions/checkout@v2
- name: Set permissions and run init.sh
- name: Set permissions and run install.sh
run: |
chmod +x init.sh
./init.sh
chmod +x install.sh
./install.sh
- name: Set up Docker Compose
run: |
# 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: |
@@ -32,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"
@@ -42,7 +43,26 @@ jobs:
# Test if the service on localhost:81 responds
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:81)
if [ "$http_code" -ne 200 ]; then
echo "Service did not respond with expected 200 OK. Check if all DB migrations are applied."
echo "Service did not respond with expected 200 OK. Check if WebApi is configured correctly."
exit 1
else
echo "Service responded with $http_code"
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:
@@ -23,7 +23,5 @@ jobs:
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,10 +10,8 @@ on:
branches: [ "main" ]
jobs:
build:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
@@ -26,5 +24,5 @@ jobs:
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

6
.gitignore vendored
View File

@@ -373,11 +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.WebApp/wwwroot/appsettings.Development.json
src/AliasVault.Client/wwwroot/appsettings.Development.json
# appsettings.Development.json is added manually if needed, it should not be committed.
src/Tests/AliasVault.E2ETests/appsettings.Development.json
# .env is generated by init.sh and therefore should be ignored
# .env is generated by install.sh and therefore should be ignored
.env

View File

@@ -69,8 +69,8 @@ dotnet tool install --global Microsoft.Playwright.CLI
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install
```
### 7. Create AliasVault.WebApp appsettings.Development.json
The WASM app supports a development specific appsettings.json file. This appsettings file is optional but can override various options to make debugging easier.
### 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`
@@ -80,6 +80,7 @@ Here is an example file with the various options explained:
```
{
"ApiUrl": "http://localhost:5092",
"PrivateEmailDomains": ["example.tld"],
"UseDebugEncryptionKey": "true"
}
```

View File

@@ -4,8 +4,9 @@
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-unit-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=integration tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-e2e-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
[<img src="https://img.shields.io/sonar/coverage/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=test code coverage">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
</div>
@@ -14,10 +15,11 @@ AliasVault is an open-source password and identity manager built with C# ASP.NET
### 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.
- **Virtual identities**: Generate virtual identities with virtual (working) email addresses that are assigned to one or more passwords.
- **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.
> Note: AliasVault is currently in development and not yet ready for production use. The project is still in the early stages and many features are not yet implemented. You are welcome to contribute to the project by submitting pull requests or opening issues.
> 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.
## 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.
@@ -39,32 +41,35 @@ To install AliasVault on your own machine, follow the steps below. Note: the ins
$ git clone https://github.com/lanedirt/AliasVault.git
```
### 2. Run the init script.
This script will create a .env file in the root directory of the project if it does not yet exist and populate it with a random encryption secret.
### 2. Run the install script.
The script 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 docker compose command for the first time, it may take a few minutes to build the Docker image.
- When running the init script for the first time, it may take a few minutes for Docker to download all dependencies. Subsequent builds will be faster.
- A SQLite database file will be created in `./database/AliasServerDb.sqlite`. This file will store all (encrypted) password vaults. It should be kept secure and not shared.
After the Docker containers have started the app will be available at http://localhost:80
#### Other useful commands:
- To reset the admin password, run the install.sh script with the `--reset-admin-password` flag.
- To uninstall AliasVault, make the uninstall script executable with `chmod +x uninstall.sh` first, then run the script: `./uninstall.sh`.
This will remove all containers, images, and volumes related to AliasVault. It will keep all files and configuration intact however, so you can easily reinstall AliasVault later.
## Tech stack / credits
The following technologies, frameworks and libraries are used in this project:
@@ -80,4 +85,5 @@ The following technologies, frameworks and libraries are used in this project:
- [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.
- [SqliteWasmHelper](https://github.com/JeremyLikness/SqliteWasmHelper) - The AliasVault SQLite WASM implementation is loosely based on this library.
- [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

@@ -15,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
@@ -29,11 +29,27 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasServerDb", "src\Databa
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.WebApp.Server", "src\Tests\Server\AliasVault.E2ETests.WebApp.Server\AliasVault.E2ETests.WebApp.Server.csproj", "{DD1F496F-CF10-47D1-A57F-5FA256479332}"
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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsvImportExport", "src\Utilities\CsvImportExport\CsvImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
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
@@ -89,6 +105,34 @@ Global
{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
@@ -103,6 +147,12 @@ Global
{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}

View File

@@ -1,14 +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
env_file:
- .env
api:
image: aliasvault-api
@@ -18,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

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

@@ -27,4 +27,15 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Identity\Implementations\Dictionaries\en\firstnames_female" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\en\firstnames_male" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\en\lastnames" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\nl\firstnames_female" />
<None Remove="Identity\Implementations\Lists\nl\firstnames" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\nl\firstnames_male" />
<None Remove="Identity\Implementations\Lists\nl\lastnames" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\nl\lastnames" />
</ItemGroup>
</Project>

View File

@@ -4,6 +4,7 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasGenerators.Identity;
/// <summary>

View File

@@ -0,0 +1,137 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGenerator.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 AliasGenerators.Identity.Implementations.Base;
using System.Reflection;
using AliasGenerators.Identity;
using AliasGenerators.Identity.Models;
/// <summary>
/// Abstract identity generator which implements IIdentityGenerator and generates
/// random identities for a certain language.
/// </summary>
public abstract class IdentityGenerator : IIdentityGenerator
{
/// <summary>
/// List of male first names in memory.
/// </summary>
private readonly List<string> _firstNamesMale;
/// <summary>
/// List of female first names in memory.
/// </summary>
private readonly List<string> _firstNamesFemale;
/// <summary>
/// List of last names in memory.
/// </summary>
private readonly List<string> _lastNames;
/// <summary>
/// Random instance.
/// </summary>
private readonly Random _random = new();
/// <summary>
/// Initializes a new instance of the <see cref="IdentityGenerator"/> class.
/// </summary>
protected IdentityGenerator()
{
_firstNamesMale = LoadList(FirstNamesListMale);
_firstNamesFemale = LoadList(FirstNamesListFemale);
_lastNames = LoadList(LastNamesList);
}
/// <summary>
/// Gets namespace path to the male first names list for the correct language.
/// </summary>
protected virtual string FirstNamesListMale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_male";
/// <summary>
/// Gets namespace path to the female first names list for the correct language.
/// </summary>
protected virtual string FirstNamesListFemale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_female";
/// <summary>
/// Gets namespace path to the last names list for the correct language.
/// </summary>
protected virtual string LastNamesList => "AliasGenerators.Identity.Implementations.Dictionaries.nl.lastnames";
/// <inheritdoc/>
public async Task<Identity> GenerateRandomIdentityAsync()
{
await Task.Yield(); // Add an await statement to make the method truly asynchronous.
// Generate identity.
var identity = new Identity();
// Determine gender.
if (_random.Next(2) == 0)
{
identity.FirstName = _firstNamesMale[_random.Next(_firstNamesMale.Count)];
identity.Gender = Gender.Male;
}
else
{
identity.FirstName = _firstNamesFemale[_random.Next(_firstNamesFemale.Count)];
identity.Gender = Gender.Female;
}
identity.LastName = _lastNames[_random.Next(_lastNames.Count)];
// Generate random date of birth between 21 and 65 years of age.
identity.BirthDate = GenerateRandomDateOfBirth();
identity.EmailPrefix = new UsernameEmailGenerator().GenerateEmailPrefix(identity);
identity.NickName = new UsernameEmailGenerator().GenerateUsername(identity);
return identity;
}
/// <summary>
/// Load a list of words from a resource file.
/// </summary>
/// <param name="resourceName">Name of the resource file to load.</param>
/// <returns>List of words from the resource file.</returns>
/// <exception cref="FileNotFoundException">Thrown if resource file cannot be found.</exception>
private static List<string> LoadList(string resourceName)
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new FileNotFoundException("Resource '" + resourceName + "' not found.", resourceName);
}
using var reader = new StreamReader(stream);
var words = new List<string>();
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
if (line != null)
{
words.Add(line);
}
}
return words;
}
/// <summary>
/// Generate a random date of birth.
/// </summary>
/// <returns>DateTime representing date of birth.</returns>
private DateTime GenerateRandomDateOfBirth()
{
// Generate random date of birth between 21 and 65 years of age.
var now = DateTime.Now;
var minDob = now.AddYears(-65);
var maxDob = now.AddYears(-21);
return minDob.AddDays(_random.Next((int)(maxDob - minDob).TotalDays));
}
}

View File

@@ -0,0 +1,153 @@
Emily
Emma
Olivia
Ava
Sophia
Isabella
Mia
Charlotte
Amelia
Harper
Evelyn
Abigail
Elizabeth
Sofia
Avery
Ella
Madison
Scarlett
Victoria
Aria
Grace
Chloe
Camila
Penelope
Riley
Layla
Zoey
Nora
Lily
Eleanor
Hannah
Lillian
Addison
Aubrey
Ellie
Stella
Natalie
Zoe
Leah
Hazel
Violet
Aurora
Savannah
Audrey
Brooklyn
Bella
Claire
Skylar
Lucy
Paisley
Everly
Anna
Caroline
Nova
Genesis
Emilia
Kennedy
Samantha
Maya
Willow
Kinsley
Naomi
Aaliyah
Elena
Sarah
Ariana
Allison
Gabriella
Alice
Madelyn
Cora
Ruby
Eva
Serenity
Autumn
Adeline
Hailey
Gianna
Valentina
Isla
Eliana
Quinn
Nevaeh
Ivy
Sadie
Piper
Lydia
Alexa
Josephine
Emery
Julia
Delilah
Arianna
Vivian
Kaylee
Sophie
Brielle
Madeline
Peyton
Rylee
Clara
Hadley
Melanie
Mackenzie
Reagan
Adalyn
Liliana
Aubree
Jade
Katherine
Isabelle
Natalia
Raelynn
Maria
Athena
Ximena
Arya
Leilani
Taylor
Faith
Rose
Kylie
Alexandra
Mary
Margaret
Lyla
Ashley
Amaya
Eliza
Brianna
Bailey
Andrea
Khloe
Jasmine
Melody
Iris
Isabel
Norah
Annabelle
Valeria
Emerson
Adalynn
Ryleigh
Eden
Emersyn
Anastasia
Kayla
Alyssa
Anna
Juliana
Charlie
Lucia
Stella

View File

@@ -0,0 +1,142 @@
Michael
Christopher
Matthew
Joshua
Daniel
David
Andrew
Joseph
James
John
Robert
William
Ryan
Jason
Nicholas
Jonathan
Jacob
Brandon
Tyler
Zachary
Kevin
Justin
Benjamin
Anthony
Samuel
Thomas
Alexander
Ethan
Noah
Dylan
Nathan
Christian
Austin
Adam
Caleb
Cody
Jordan
Logan
Aaron
Kyle
Jose
Brian
Gabriel
Timothy
Luke
Jared
Connor
Sean
Evan
Isaac
Jack
Cameron
Hunter
Jackson
Charles
Devin
Stephen
Patrick
Steven
Elijah
Scott
Mark
Jeffrey
Corey
Juan
Luis
Derek
Chase
Travis
Alex
Spencer
Ian
Trevor
Bryan
Tanner
Marcus
Jeremy
Eric
Jaden
Garrett
Isaiah
Dustin
Jesse
Seth
Blake
Nathaniel
Mason
Liam
Paul
Carlos
Mitchell
Parker
Lucas
Richard
Cole
Adrian
Colin
Bradley
Jesus
Peter
Kenneth
Joel
Victor
Bryce
Casey
Vincent
Edward
Henry
Dominic
Riley
Shane
Dalton
Grant
Shawn
Braden
Caden
Max
Hayden
Owen
Brett
Trevor
Philip
Brendan
Wesley
Aidan
Brady
Colton
Tristan
George
Gavin
Dawson
Miguel
Antonio
Nolan
Dakota
Jace
Collin
Preston
Levi
Alan
Jorge
Carson

View File

@@ -0,0 +1,167 @@
Smith
Johnson
Williams
Brown
Jones
Garcia
Miller
Davis
Rodriguez
Martinez
Hernandez
Lopez
Gonzalez
Wilson
Anderson
Thomas
Taylor
Moore
Jackson
Martin
Lee
Perez
Thompson
White
Harris
Sanchez
Clark
Ramirez
Lewis
Robinson
Walker
Young
Allen
King
Wright
Scott
Torres
Nguyen
Hill
Flores
Green
Adams
Nelson
Baker
Hall
Rivera
Campbell
Mitchell
Carter
Roberts
Gomez
Phillips
Evans
Turner
Diaz
Parker
Cruz
Edwards
Collins
Reyes
Stewart
Morris
Morales
Murphy
Cook
Rogers
Gutierrez
Ortiz
Morgan
Cooper
Peterson
Bailey
Reed
Kelly
Howard
Ramos
Kim
Cox
Ward
Richardson
Watson
Brooks
Chavez
Wood
James
Bennett
Gray
Mendoza
Ruiz
Hughes
Price
Alvarez
Castillo
Sanders
Patel
Myers
Long
Ross
Foster
Jimenez
Powell
Jenkins
Perry
Russell
Sullivan
Bell
Coleman
Butler
Henderson
Barnes
Gonzales
Fisher
Vasquez
Simmons
Romero
Jordan
Patterson
Alexander
Hamilton
Graham
Reynolds
Griffin
Wallace
Moreno
West
Cole
Hayes
Bryant
Herrera
Gibson
Ellis
Tran
Medina
Aguilar
Stevens
Murray
Ford
Castro
Marshall
Owens
Harrison
Fernandez
McDonald
Woods
Washington
Kennedy
Wells
Vargas
Henry
Chen
Freeman
Webb
Tucker
Guzman
Burns
Crawford
Olson
Simpson
Porter
Hunter
Gordon
Mendez
Silva
Shaw
Snyder
Mason
Dixon

View File

@@ -0,0 +1,104 @@
Emma
Sophie
Julia
Mila
Tess
Sara
Anna
Noor
Lotte
Liv
Eva
Nora
Zoë
Evi
Yara
Saar
Nina
Fenna
Lieke
Fleur
Isa
Roos
Lynn
Sofie
Sarah
Milou
Olivia
Maud
Lisa
Vera
Luna
Lina
Noa
Feline
Loïs
Lena
Floor
Charlotte
Esmee
Julie
Iris
Lara
Amber
Hailey
Mia
Lize
Isabelle
Cato
Fenne
Sanne
Norah
Sophia
Ella
Nova
Elin
Femke
Lizzy
Linde
Lauren
Rosalie
Lana
Emily
Elise
Esmée
Anne
Isabelle
Demi
Hannah
Liva
Suze
Fay
Isabel
Benthe
Evi
Amy
Jasmijn
Niene
Sterre
Fenna
Fiene
Liz
Ise
Mara
Nienke
Indy
Romy
Lola
Puck
Nora
Merel
Bente
Eline
Lily
Leah
Naomi
Mirthe
Valerie
Noor
Liva
Jade
Juul
Lise
Myrthe
Veerle

View File

@@ -0,0 +1,101 @@
Daan
Luuk
Sem
Finn
Milan
Levi
Noah
Lucas
Jesse
Thijs
Jayden
Bram
Lars
Ruben
Thomas
Tim
Sam
Liam
Julian
Mees
Ties
Sven
Max
Gijs
David
Stijn
Jasper
Niels
Jens
Timo
Cas
Joep
Roan
Tom
Tygo
Teun
Siem
Mats
Thijmen
Rens
Niek
Tobias
Dex
Hugo
Robin
Nick
Floris
Pepijn
Boaz
Olivier
Luca
Jurre
Jelle
Guus
Koen
Bart
Olaf
Wessel
Daniël
Job
Sander
Tijmen
Kai
Quinten
Owen
Morris
Fedde
Joris
Jesper
Mick
Ryan
Milo
Stan
Benjamin
Melle
Jip
Dylan
Brent
Mick
Dean
Otis
Abel
Luc
Sepp
Vince
Rayan
Noud
Hidde
Fabian
Jort
Damian
Boris
Sil
Moos
Aiden
Sep
Mika
Mijs
Mika
Felix
Merlijn

View File

@@ -0,0 +1,106 @@
de Jong
Jansen
de Vries
van den Berg
van Dijk
Bakker
Janssen
Visser
Smit
Meijer
de Boer
Mulder
de Groot
Bos
Vos
Peters
Hendriks
van Leeuwen
Dekker
Brouwer
de Wit
Dijkstra
Smits
de Graaf
van der Meer
van der Linden
Kok
Jacobs
de Haan
Vermeulen
van den Heuvel
van der Veen
van den Broek
de Bruijn
de Bruin
van der Heijden
Schouten
van Beek
Willems
van Vliet
van de Ven
Hoekstra
Maas
Verhoeven
Koster
van Dam
van der Wal
Prins
Blom
Huisman
Peeters
Kuipers
van Veen
van Dongen
Veenstra
Kramer
van den Bosch
van der Meulen
Mol
Zwart
van der Laan
Martens
van de Pol
Postma
Tromp
Borst
Boon
van Doorn
Jonker
van der Velden
Willemsen
van Wijk
Groen
Gerritsen
Bosch
van Loon
van der Ploeg
de Ruiter
Molenaar
Boer
Klein
de Koning
van de Kamp
van der Horst
Verbeek
Vink
Goossens
Scholten
Hartman
van Dalen
van Elst
Brink
Boekel
van de Berg
Berends
van der Hoek
Kuiper
Kooijman
de Lange
van der Sluis
van Gelder
Martens
van Asselt
Timmermans
van Vliet
van Rijn

View File

@@ -0,0 +1,32 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGeneratorFactory.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Implementations.Base;
/// <summary>
/// Identity generator factory which creates an identity generator based on the language code.
/// </summary>
public static class IdentityGeneratorFactory
{
/// <summary>
/// Creates an identity generator based on the language code.
/// </summary>
/// <param name="languageCode">Two letter language code.</param>
/// <returns>The IdentityGenerator for the requested language.</returns>
/// <exception cref="ArgumentException">Thrown if no identity generator is found for the requested language.</exception>
public static IdentityGenerator CreateIdentityGenerator(string languageCode)
{
return languageCode.ToLower() switch
{
"nl" => new IdentityGeneratorNl(),
"en" => new IdentityGeneratorEn(),
_ => throw new ArgumentException($"Unsupported language code: {languageCode}", nameof(languageCode)),
};
}
}

View File

@@ -1,39 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="FigIdentityGenerator.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 AliasGenerators.Identity.Implementations;
using System.Text.Json;
/// <summary>
/// Identity generator which generates random identities using the identiteitgenerator.nl semi-public API.
/// </summary>
public class FigIdentityGenerator : IIdentityGenerator
{
private static readonly HttpClient HttpClient = new();
private static readonly string Url = "https://api.identiteitgenerator.nl/generate/identity";
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <inheritdoc/>
public async Task<Identity.Models.Identity> GenerateRandomIdentityAsync()
{
var response = await HttpClient.GetAsync(Url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var identity = JsonSerializer.Deserialize<Identity.Models.Identity>(json, JsonSerializerOptions);
if (identity is null)
{
throw new InvalidOperationException("Failed to deserialize the identity from FIG WebApi.");
}
return identity;
}
}

View File

@@ -0,0 +1,26 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGeneratorEn.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Implementations.Base;
/// <summary>
/// Dutch identity generator which implements IIdentityGenerator and generates
/// random dutch identities.
/// </summary>
public class IdentityGeneratorEn : IdentityGenerator
{
/// <inheritdoc cref="IdentityGenerator.FirstNamesListMale" />
protected override string FirstNamesListMale => "AliasGenerators.Identity.Implementations.Dictionaries.en.firstnames_male";
/// <inheritdoc cref="IdentityGenerator.FirstNamesListFemale" />
protected override string FirstNamesListFemale => "AliasGenerators.Identity.Implementations.Dictionaries.en.firstnames_female";
/// <inheritdoc cref="IdentityGenerator.LastNamesList" />
protected override string LastNamesList => "AliasGenerators.Identity.Implementations.Dictionaries.en.lastnames";
}

View File

@@ -0,0 +1,26 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGeneratorNl.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Implementations.Base;
/// <summary>
/// Dutch identity generator which implements IIdentityGenerator and generates
/// random dutch identities.
/// </summary>
public class IdentityGeneratorNl : IdentityGenerator
{
/// <inheritdoc cref="IdentityGenerator.FirstNamesListMale" />
protected override string FirstNamesListMale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_male";
/// <inheritdoc cref="IdentityGenerator.FirstNamesListFemale" />
protected override string FirstNamesListFemale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_female";
/// <inheritdoc cref="IdentityGenerator.LastNamesList" />
protected override string LastNamesList => "AliasGenerators.Identity.Implementations.Dictionaries.nl.lastnames";
}

View File

@@ -1,27 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="StaticIdentityGenerator.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity;
/// <summary>
/// Static identity generator which implements IIdentityGenerator but always returns
/// the same static identity for testing purposes.
/// </summary>
public class StaticIdentityGenerator : IIdentityGenerator
{
/// <inheritdoc/>
public async Task<Identity.Models.Identity> GenerateRandomIdentityAsync()
{
await Task.Yield(); // Add an await statement to make the method truly asynchronous.
return new Identity.Models.Identity
{
FirstName = "John",
LastName = "Doe",
};
}
}

View File

@@ -1,38 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Address.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 AliasGenerators.Identity.Models;
/// <summary>
/// Address model.
/// </summary>
public class Address
{
/// <summary>
/// Gets or sets the street.
/// </summary>
public string Street { get; set; } = null!;
/// <summary>
/// Gets or sets the city.
/// </summary>
public string City { get; set; } = null!;
/// <summary>
/// Gets or sets the state.
/// </summary>
public string State { get; set; } = null!;
/// <summary>
/// Gets or sets the zip code.
/// </summary>
public string ZipCode { get; set; } = null!;
/// <summary>
/// Gets or sets the country.
/// </summary>
public string Country { get; set; } = null!;
}

View File

@@ -0,0 +1,24 @@
//-----------------------------------------------------------------------
// <copyright file="Gender.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 AliasGenerators.Identity.Models;
/// <summary>
/// Identity model.
/// </summary>
public enum Gender
{
/// <summary>
/// Male gender.
/// </summary>
Male,
/// <summary>
/// Female gender.
/// </summary>
Female,
}

View File

@@ -4,6 +4,7 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasGenerators.Identity.Models;
/// <summary>
@@ -19,7 +20,7 @@ public class Identity
/// <summary>
/// Gets or sets the gender.
/// </summary>
public int Gender { get; set; }
public Gender Gender { get; set; }
/// <summary>
/// Gets or sets the first name.
@@ -41,48 +42,8 @@ public class Identity
/// </summary>
public DateTime BirthDate { get; set; }
/// <summary>
/// Gets or sets the address.
/// </summary>
public Address Address { get; set; } = null!;
/// <summary>
/// Gets or sets the job.
/// </summary>
public Job Job { get; set; } = null!;
/// <summary>
/// Gets or sets the hobbies.
/// </summary>
public List<string> Hobbies { get; set; } = null!;
/// <summary>
/// Gets or sets the email address prefix.
/// </summary>
public string EmailPrefix { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the phone mobile.
/// </summary>
public string PhoneMobile { get; set; } = null!;
/// <summary>
/// Gets or sets the bank account IBAN.
/// </summary>
public string BankAccountIBAN { get; set; } = null!;
/// <summary>
/// Gets or sets the profile photo in base64 format.
/// </summary>
public string ProfilePhotoBase64 { get; set; } = null!;
/// <summary>
/// Gets or sets the profile photo prompt.
/// </summary>
public string ProfilePhotoPrompt { get; set; } = null!;
}

View File

@@ -1,38 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Job.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 AliasGenerators.Identity.Models;
/// <summary>
/// Job model.
/// </summary>
public class Job
{
/// <summary>
/// Gets or sets the title.
/// </summary>
public string Title { get; set; } = null!;
/// <summary>
/// Gets or sets the company.
/// </summary>
public string Company { get; set; } = null!;
/// <summary>
/// Gets or sets the salary.
/// </summary>
public string Salary { get; set; } = null!;
/// <summary>
/// Gets or sets the calculated salary.
/// </summary>
public decimal SalaryCalculated { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public string Description { get; set; } = null!;
}

View File

@@ -0,0 +1,143 @@
//-----------------------------------------------------------------------
// <copyright file="UsernameEmailGenerator.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 AliasGenerators.Identity;
using System.Text.RegularExpressions;
/// <summary>
/// Generates usernames and email prefixes based on an identity.
/// </summary>
public class UsernameEmailGenerator
{
/// <summary>
/// Minimum length of the generated username.
/// </summary>
private const int MinLength = 6;
/// <summary>
/// Maximum length of the generated username.
/// </summary>
private const int MaxLength = 20;
/// <summary>
/// Create a new random instance for generating random values.
/// </summary>
private readonly Random _random = new();
/// <summary>
/// List of allowed symbols to use in usernames.
/// </summary>
private readonly List<string> _symbols = [".", "-"];
/// <summary>
/// Generates a username based on a identity.
/// </summary>
/// <param name="identity">Identity to generate username for.</param>
/// <returns>Username as string.</returns>
public string GenerateUsername(Models.Identity identity)
{
// Generate username based on email prefix but strip all non-alphanumeric characters
string username = GenerateEmailPrefix(identity);
username = Regex.Replace(username, @"[^a-zA-Z0-9]", string.Empty, RegexOptions.NonBacktracking);
// Adjust length
if (username.Length < MinLength)
{
username += GenerateRandomString(MinLength - username.Length);
}
else if (username.Length > MaxLength)
{
username = username.Substring(0, MaxLength);
}
return username;
}
/// <summary>
/// Generates a valid email prefix based on an identity.
/// </summary>
/// <param name="identity">Identity to generate email prefix for.</param>
/// <returns>Valid email prefix as string.</returns>
public string GenerateEmailPrefix(Models.Identity identity)
{
var parts = new List<string>();
// Use first initial + last name
if (_random.Next(2) == 0)
{
parts.Add(identity.FirstName.Substring(0, 1).ToLower() + identity.LastName.ToLower());
}
else
{
// Use full name
parts.Add((identity.FirstName + identity.LastName).ToLower());
}
// Add birth year
if (_random.Next(2) == 0)
{
parts.Add(identity.BirthDate.Year.ToString().Substring(2));
}
// Join parts and sanitize
var emailPrefix = string.Join(GetRandomSymbol(), parts);
emailPrefix = SanitizeEmailPrefix(emailPrefix);
// Adjust length
if (emailPrefix.Length < MinLength)
{
emailPrefix += GenerateRandomString(MinLength - emailPrefix.Length);
}
else if (emailPrefix.Length > MaxLength)
{
emailPrefix = emailPrefix.Substring(0, MaxLength);
}
return emailPrefix;
}
/// <summary>
/// Sanitize the email prefix by removing invalid characters and ensuring it's a valid email prefix.
/// </summary>
/// <param name="input">The input string to sanitize.</param>
/// <returns>The sanitized string.</returns>
private static string SanitizeEmailPrefix(string input)
{
// Remove any character that's not a letter, number, dot, underscore, or hyphen including special characters
string sanitized = Regex.Replace(input, @"[^a-zA-Z0-9._-]", string.Empty, RegexOptions.NonBacktracking);
// Remove consecutive dots, underscores, or hyphens
sanitized = Regex.Replace(sanitized, @"[-_.]{2,}", m => m.Value[0].ToString(), RegexOptions.NonBacktracking);
// Ensure it doesn't start or end with a dot, underscore, or hyphen
sanitized = sanitized.Trim('.', '_', '-');
return sanitized;
}
/// <summary>
/// Get a random symbol from the list of symbols.
/// </summary>
/// <returns>Random symbol.</returns>
private string GetRandomSymbol()
{
return _random.Next(3) == 0 ? _symbols[_random.Next(_symbols.Count)] : string.Empty;
}
/// <summary>
/// Generate a random string of a given length.
/// </summary>
/// <param name="length">Length of string to generate.</param>
/// <returns>String with random characters.</returns>
private string GenerateRandomString(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[_random.Next(s.Length)]).ToArray());
}
}

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,55 @@
<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>
<ItemGroup>
<_ContentIncludedByDefault Remove="Main\Components\Refresh\RefreshButton.razor" />
</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

@@ -0,0 +1,12 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using AliasVault.Admin
@using AliasVault.Admin.Main
@using AliasServerDb

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

@@ -0,0 +1,88 @@
@implements IDisposable
@inject NavigationManager NavigationManager
@if (Messages.Count == 0)
{
return;
}
<div class="messages-container grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
@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" />
}
}
</div>
<style>
.messages-container > :last-child {
margin-bottom: 0 !important;
}
</style>
@code {
private List<KeyValuePair<string, string>> Messages { get; set; } = new();
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
RefreshAddMessages();
GlobalNotificationService.OnChange += RefreshAddMessages;
NavigationManager.LocationChanged += HandleLocationChanged;
}
}
/// <inheritdoc />
public void Dispose()
{
GlobalNotificationService.OnChange -= RefreshAddMessages;
NavigationManager.LocationChanged -= HandleLocationChanged;
}
/// <summary>
/// Refreshes the messages on navigation to another page.
/// </summary>
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
{
RefreshAddMessages();
InvokeAsync(StateHasChanged);
}
/// <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

@@ -1,5 +1,3 @@
@using AliasVault.Shared.Models.WebApi
@if (_errors.Any())
{
@foreach (var error in _errors)

View File

@@ -31,7 +31,6 @@
}
</ol>
</nav>
<GlobalNotificationDisplay />
@code {
/// <summary>

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

@@ -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,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,59 @@
@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>
<GlobalNotificationDisplay />
@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,27 +1,32 @@
@inherits MainBase
@using AliasVault.WebApp.Main.Pages;
@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="/credentials" 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">
Credentials
<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="/emails" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Emails
</NavLink>
<NavLink href="/logs" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Logs
</NavLink>
</ul>
</div>
</div>
<div class="flex justify-end items-center lg:order-2">
<DbStatusIndicator />
<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>
@@ -44,7 +49,7 @@
</div>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
<a href="settings/vault" 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">Vault 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">
@@ -102,15 +107,15 @@
/// </summary>
public void Dispose()
{
NavigationManager.LocationChanged -= LocationChanged;
NavigationService.LocationChanged -= LocationChanged;
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
_username = await GetUsernameAsync();
NavigationManager.LocationChanged += LocationChanged;
_username = GetUsername();
NavigationService.LocationChanged += LocationChanged;
}
/// <inheritdoc />

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.");
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.");
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,24 @@
@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">
<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

@@ -0,0 +1,37 @@
@page "/Error"
@using System.Diagnostics
<LayoutPageTitle>Error</LayoutPageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
/// <inheritdoc />
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

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

@@ -0,0 +1,40 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:12292",
"sslPort": 44398
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5216",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==",
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7025;http://localhost:5216",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.WebApp.Services;
namespace AliasVault.Admin.Services;
/// <summary>
/// Global loading service that can be used to show or hide a global layout loading spinner.

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

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

Some files were not shown because too many files have changed in this diff Show More