-
+
AliasVault
Admin
diff --git a/src/AliasVault.Admin/wwwroot/css/tailwind.css b/src/AliasVault.Admin/wwwroot/css/tailwind.css
index 393441ff7..33edcaf04 100644
--- a/src/AliasVault.Admin/wwwroot/css/tailwind.css
+++ b/src/AliasVault.Admin/wwwroot/css/tailwind.css
@@ -687,6 +687,10 @@ video {
margin-bottom: 2rem;
}
+.ml-1 {
+ margin-left: 0.25rem;
+}
+
.ml-2 {
margin-left: 0.5rem;
}
@@ -719,6 +723,10 @@ video {
margin-right: 1rem;
}
+.ms-1 {
+ margin-inline-start: 0.25rem;
+}
+
.mt-0 {
margin-top: 0px;
}
@@ -747,14 +755,6 @@ video {
margin-top: 2rem;
}
-.ms-1 {
- margin-inline-start: 0.25rem;
-}
-
-.ml-1 {
- margin-left: 0.25rem;
-}
-
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
@@ -798,6 +798,14 @@ video {
height: 2.5rem;
}
+.h-20 {
+ height: 5rem;
+}
+
+.h-3 {
+ height: 0.75rem;
+}
+
.h-4 {
height: 1rem;
}
@@ -826,10 +834,6 @@ video {
height: 100%;
}
-.h-3 {
- height: 0.75rem;
-}
-
.w-1\/2 {
width: 50%;
}
@@ -846,6 +850,14 @@ video {
width: 66.666667%;
}
+.w-20 {
+ width: 5rem;
+}
+
+.w-3 {
+ width: 0.75rem;
+}
+
.w-4 {
width: 1rem;
}
@@ -882,10 +894,6 @@ video {
width: 100%;
}
-.w-3 {
- width: 0.75rem;
-}
-
.max-w-2xl {
max-width: 42rem;
}
@@ -1038,12 +1046,6 @@ video {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
-.space-y-3 > :not([hidden]) ~ :not([hidden]) {
- --tw-space-y-reverse: 0;
- margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
- margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
-}
-
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
@@ -1130,11 +1132,6 @@ video {
border-top-width: 1px;
}
-.border-amber-200 {
- --tw-border-opacity: 1;
- border-color: rgb(253 230 138 / var(--tw-border-opacity));
-}
-
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
@@ -1160,26 +1157,21 @@ video {
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
-.border-sky-200 {
- --tw-border-opacity: 1;
- border-color: rgb(186 230 253 / var(--tw-border-opacity));
-}
-
.border-yellow-500 {
--tw-border-opacity: 1;
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
-.bg-amber-50 {
- --tw-bg-opacity: 1;
- background-color: rgb(255 251 235 / var(--tw-bg-opacity));
-}
-
.bg-blue-100 {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
}
+.bg-blue-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 246 255 / var(--tw-bg-opacity));
+}
+
.bg-blue-500 {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
@@ -1310,11 +1302,6 @@ video {
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
-.bg-sky-50 {
- --tw-bg-opacity: 1;
- background-color: rgb(240 249 255 / var(--tw-bg-opacity));
-}
-
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@@ -1325,21 +1312,16 @@ video {
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
}
-.bg-yellow-500 {
- --tw-bg-opacity: 1;
- background-color: rgb(234 179 8 / var(--tw-bg-opacity));
-}
-
-.bg-blue-50 {
- --tw-bg-opacity: 1;
- background-color: rgb(239 246 255 / var(--tw-bg-opacity));
-}
-
.bg-yellow-50 {
--tw-bg-opacity: 1;
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
}
+.bg-yellow-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(234 179 8 / var(--tw-bg-opacity));
+}
+
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1490,6 +1472,11 @@ video {
line-height: 2rem;
}
+.text-5xl {
+ font-size: 3rem;
+ line-height: 1;
+}
+
.text-base {
font-size: 1rem;
line-height: 1.5rem;
@@ -1547,11 +1534,6 @@ video {
line-height: 2.25rem;
}
-.text-amber-700 {
- --tw-text-opacity: 1;
- color: rgb(180 83 9 / var(--tw-text-opacity));
-}
-
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
@@ -1627,11 +1609,6 @@ video {
color: rgb(153 27 27 / var(--tw-text-opacity));
}
-.text-sky-700 {
- --tw-text-opacity: 1;
- color: rgb(3 105 161 / var(--tw-text-opacity));
-}
-
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -1855,10 +1832,6 @@ video {
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
}
-.focus\:ring-offset-2:focus {
- --tw-ring-offset-width: 2px;
-}
-
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
@@ -1873,9 +1846,9 @@ video {
border-width: 1px;
}
-.dark\:border-amber-800:is(.dark *) {
+.dark\:border-blue-500:is(.dark *) {
--tw-border-opacity: 1;
- border-color: rgb(146 64 14 / var(--tw-border-opacity));
+ border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.dark\:border-gray-500:is(.dark *) {
@@ -1893,14 +1866,9 @@ video {
border-color: rgb(55 65 81 / var(--tw-border-opacity));
}
-.dark\:border-sky-800:is(.dark *) {
+.dark\:border-green-500:is(.dark *) {
--tw-border-opacity: 1;
- border-color: rgb(7 89 133 / var(--tw-border-opacity));
-}
-
-.dark\:border-blue-500:is(.dark *) {
- --tw-border-opacity: 1;
- border-color: rgb(59 130 246 / var(--tw-border-opacity));
+ border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
.dark\:border-red-500:is(.dark *) {
@@ -1908,16 +1876,21 @@ video {
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
-.dark\:border-green-500:is(.dark *) {
- --tw-border-opacity: 1;
- border-color: rgb(34 197 94 / var(--tw-border-opacity));
-}
-
.dark\:border-yellow-500:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
+.dark\:bg-blue-800:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 64 175 / var(--tw-bg-opacity));
+}
+
+.dark\:bg-blue-900:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(30 58 138 / var(--tw-bg-opacity));
+}
+
.dark\:bg-gray-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
@@ -1943,9 +1916,14 @@ video {
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
}
-.dark\:bg-primary-500:is(.dark *) {
+.dark\:bg-green-800:is(.dark *) {
--tw-bg-opacity: 1;
- background-color: rgb(244 149 65 / var(--tw-bg-opacity));
+ background-color: rgb(22 101 52 / var(--tw-bg-opacity));
+}
+
+.dark\:bg-green-900:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(20 83 45 / var(--tw-bg-opacity));
}
.dark\:bg-primary-600:is(.dark *) {
@@ -1958,34 +1936,14 @@ video {
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
-.dark\:bg-red-900:is(.dark *) {
- --tw-bg-opacity: 1;
- background-color: rgb(127 29 29 / var(--tw-bg-opacity));
-}
-
-.dark\:bg-slate-800:is(.dark *) {
- --tw-bg-opacity: 1;
- background-color: rgb(30 41 59 / var(--tw-bg-opacity));
-}
-
-.dark\:bg-yellow-900:is(.dark *) {
- --tw-bg-opacity: 1;
- background-color: rgb(113 63 18 / var(--tw-bg-opacity));
-}
-
-.dark\:bg-blue-800:is(.dark *) {
- --tw-bg-opacity: 1;
- background-color: rgb(30 64 175 / var(--tw-bg-opacity));
-}
-
.dark\:bg-red-800:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
-.dark\:bg-green-800:is(.dark *) {
+.dark\:bg-red-900:is(.dark *) {
--tw-bg-opacity: 1;
- background-color: rgb(22 101 52 / var(--tw-bg-opacity));
+ background-color: rgb(127 29 29 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-800:is(.dark *) {
@@ -1993,23 +1951,18 @@ video {
background-color: rgb(133 77 14 / var(--tw-bg-opacity));
}
-.dark\:bg-blue-900:is(.dark *) {
+.dark\:bg-yellow-900:is(.dark *) {
--tw-bg-opacity: 1;
- background-color: rgb(30 58 138 / var(--tw-bg-opacity));
-}
-
-.dark\:bg-green-900:is(.dark *) {
- --tw-bg-opacity: 1;
- background-color: rgb(20 83 45 / var(--tw-bg-opacity));
+ background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
-.dark\:text-amber-300:is(.dark *) {
+.dark\:text-blue-300:is(.dark *) {
--tw-text-opacity: 1;
- color: rgb(252 211 77 / var(--tw-text-opacity));
+ color: rgb(147 197 253 / var(--tw-text-opacity));
}
.dark\:text-blue-400:is(.dark *) {
@@ -2042,9 +1995,9 @@ video {
color: rgb(75 85 99 / var(--tw-text-opacity));
}
-.dark\:text-green-400:is(.dark *) {
+.dark\:text-green-300:is(.dark *) {
--tw-text-opacity: 1;
- color: rgb(74 222 128 / var(--tw-text-opacity));
+ color: rgb(134 239 172 / var(--tw-text-opacity));
}
.dark\:text-primary-200:is(.dark *) {
@@ -2072,11 +2025,6 @@ video {
color: rgb(248 113 113 / var(--tw-text-opacity));
}
-.dark\:text-sky-300:is(.dark *) {
- --tw-text-opacity: 1;
- color: rgb(125 211 252 / var(--tw-text-opacity));
-}
-
.dark\:text-white:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -2092,16 +2040,6 @@ video {
color: rgb(254 240 138 / var(--tw-text-opacity));
}
-.dark\:text-blue-300:is(.dark *) {
- --tw-text-opacity: 1;
- color: rgb(147 197 253 / var(--tw-text-opacity));
-}
-
-.dark\:text-green-300:is(.dark *) {
- --tw-text-opacity: 1;
- color: rgb(134 239 172 / var(--tw-text-opacity));
-}
-
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
@@ -2131,11 +2069,6 @@ video {
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
-.dark\:hover\:bg-primary-600:hover:is(.dark *) {
- --tw-bg-opacity: 1;
- background-color: rgb(214 131 56 / var(--tw-bg-opacity));
-}
-
.dark\:hover\:bg-primary-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
diff --git a/src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png b/src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png
deleted file mode 100644
index b3a037d75..000000000
Binary files a/src/AliasVault.Admin/wwwroot/horizontal-logo-cropped.png and /dev/null differ
diff --git a/src/AliasVault.Admin/wwwroot/img/logo.svg b/src/AliasVault.Admin/wwwroot/img/logo.svg
new file mode 100644
index 000000000..5876600fe
--- /dev/null
+++ b/src/AliasVault.Admin/wwwroot/img/logo.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/src/AliasVault.Admin/wwwroot/img/service-placeholder.webp b/src/AliasVault.Admin/wwwroot/img/service-placeholder.webp
deleted file mode 100644
index 85460a537..000000000
Binary files a/src/AliasVault.Admin/wwwroot/img/service-placeholder.webp and /dev/null differ
diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs
index c7c2d1767..8d760285f 100644
--- a/src/AliasVault.Api/Controllers/AuthController.cs
+++ b/src/AliasVault.Api/Controllers/AuthController.cs
@@ -298,7 +298,7 @@ public class AuthController(IDbContextFactory
dbContextFac
// Check if the refresh token is valid.
var deviceIdentifier = GenerateDeviceIdentifier(Request);
- var existingToken = context.AliasVaultUserRefreshTokens.FirstOrDefault(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
+ var existingToken = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
if (existingToken == null || existingToken.Value != model.RefreshToken)
{
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.Logout, AuthFailureReason.InvalidRefreshToken);
@@ -321,10 +321,11 @@ public class AuthController(IDbContextFactory dbContextFac
[HttpPost("register")]
public async Task Register([FromBody] RegisterRequest model)
{
- // Validate username, disallow "admin" as a username.
- if (string.Equals(model.Username, "admin", StringComparison.OrdinalIgnoreCase))
+ // Validate the username.
+ var (isValid, errorMessage) = ValidateUsername(model.Username);
+ if (!isValid)
{
- return BadRequest(ServerValidationErrorResponse.Create(["Username 'admin' is not allowed."], 400));
+ return BadRequest(ServerValidationErrorResponse.Create([errorMessage], 400));
}
var user = new AliasVaultUser
@@ -395,6 +396,97 @@ public class AuthController(IDbContextFactory dbContextFac
return Ok(new PasswordChangeInitiateResponse(latestVaultEncryptionSettings.Salt, ephemeral.Public, latestVaultEncryptionSettings.EncryptionType, latestVaultEncryptionSettings.EncryptionSettings));
}
+ ///
+ /// Validate username endpoint used to check if a username is available.
+ ///
+ /// ValidateUsernameRequest model.
+ /// IActionResult.
+ [HttpPost("validate-username")]
+ [AllowAnonymous]
+ public async Task ValidateUsername([FromBody] ValidateUsernameRequest model)
+ {
+ if (string.IsNullOrWhiteSpace(model.Username))
+ {
+ return BadRequest("Username is required.");
+ }
+
+ var normalizedUsername = NormalizeUsername(model.Username);
+ var existingUser = await userManager.FindByNameAsync(normalizedUsername);
+
+ if (existingUser != null)
+ {
+ return BadRequest("Username is already in use.");
+ }
+
+ // Validate the username
+ var (isValid, errorMessage) = ValidateUsername(normalizedUsername);
+
+ if (!isValid)
+ {
+ return BadRequest(errorMessage);
+ }
+
+ return Ok("Username is available.");
+ }
+
+ ///
+ /// Normalizes a username by trimming and lowercasing it.
+ ///
+ /// The username to normalize.
+ /// The normalized username.
+ private static string NormalizeUsername(string username)
+ {
+ return username.ToLowerInvariant().Trim();
+ }
+
+ ///
+ /// Validates if a given username meets the required criteria.
+ ///
+ /// The username to validate.
+ /// A tuple containing a boolean indicating if the username is valid, and an error message if it's invalid.
+ private static (bool IsValid, string ErrorMessage) ValidateUsername(string username)
+ {
+ const int minimumUsernameLength = 3;
+ const string adminUsername = "admin";
+
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ return (false, "Username cannot be empty or whitespace.");
+ }
+
+ if (username.Length < minimumUsernameLength)
+ {
+ return (false, $"Username must be at least {minimumUsernameLength} characters long.");
+ }
+
+ if (string.Equals(username, adminUsername, StringComparison.OrdinalIgnoreCase))
+ {
+ return (false, "Username 'admin' is not allowed.");
+ }
+
+ // Check if it's a valid email address
+ if (username.Contains('@'))
+ {
+ try
+ {
+ var addr = new System.Net.Mail.MailAddress(username);
+ return (addr.Address == username, string.Empty);
+ }
+ catch
+ {
+ return (false, $"'{username}' is not a valid email address.");
+ }
+ }
+
+ // If it's not an email, check if it only contains letters and digits
+ if (!username.All(char.IsLetterOrDigit))
+ {
+ return (false, $"Username '{username}' is invalid, can only contain letters or digits.");
+ }
+
+ return (true, string.Empty);
+ }
+
///
/// Generate a device identifier based on request headers. This is used to associate refresh tokens
/// with a specific device for a specific user.
diff --git a/src/AliasVault.Client/Auth/Components/Logo.razor b/src/AliasVault.Client/Auth/Components/Logo.razor
index 464e819ed..275f8806f 100644
--- a/src/AliasVault.Client/Auth/Components/Logo.razor
+++ b/src/AliasVault.Client/Auth/Components/Logo.razor
@@ -1,3 +1,8 @@
-
-
+
+
+

+
AliasVault
+
+
+
diff --git a/src/AliasVault.Client/Auth/Layout/EmptyLayout.razor b/src/AliasVault.Client/Auth/Layout/EmptyLayout.razor
new file mode 100644
index 000000000..e1a9a7567
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Layout/EmptyLayout.razor
@@ -0,0 +1,3 @@
+@inherits LayoutComponentBase
+
+@Body
diff --git a/src/AliasVault.Client/Auth/Layout/MainLayout.razor b/src/AliasVault.Client/Auth/Layout/MainLayout.razor
index ee6fbe510..33f34e513 100644
--- a/src/AliasVault.Client/Auth/Layout/MainLayout.razor
+++ b/src/AliasVault.Client/Auth/Layout/MainLayout.razor
@@ -1,7 +1,7 @@
@inherits LayoutComponentBase
@using AliasVault.Client.Auth.Components
-
+
diff --git a/src/AliasVault.Client/Auth/Layout/MainLayout.razor.css b/src/AliasVault.Client/Auth/Layout/MainLayout.razor.css
deleted file mode 100644
index ecf25e5b2..000000000
--- a/src/AliasVault.Client/Auth/Layout/MainLayout.razor.css
+++ /dev/null
@@ -1,77 +0,0 @@
-.page {
- position: relative;
- display: flex;
- flex-direction: column;
-}
-
-main {
- flex: 1;
-}
-
-.sidebar {
- background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
-}
-
-.top-row {
- background-color: #f7f7f7;
- border-bottom: 1px solid #d6d5d5;
- justify-content: flex-end;
- height: 3.5rem;
- display: flex;
- align-items: center;
-}
-
- .top-row ::deep a, .top-row ::deep .btn-link {
- white-space: nowrap;
- margin-left: 1.5rem;
- text-decoration: none;
- }
-
- .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
- text-decoration: underline;
- }
-
- .top-row ::deep a:first-child {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
-@media (max-width: 640.98px) {
- .top-row {
- justify-content: space-between;
- }
-
- .top-row ::deep a, .top-row ::deep .btn-link {
- margin-left: 0;
- }
-}
-
-@media (min-width: 641px) {
- .page {
- flex-direction: row;
- }
-
- .sidebar {
- width: 250px;
- height: 100vh;
- position: sticky;
- top: 0;
- }
-
- .top-row {
- position: sticky;
- top: 0;
- z-index: 1;
- }
-
- .top-row.auth ::deep a:first-child {
- flex: 1;
- text-align: right;
- width: 0;
- }
-
- .top-row, article {
- padding-left: 2rem !important;
- padding-right: 1.5rem !important;
- }
-}
diff --git a/src/AliasVault.Client/Auth/Pages/Base/LoginBase.cs b/src/AliasVault.Client/Auth/Pages/Base/LoginBase.cs
index 707f5e7ac..3716b5fab 100644
--- a/src/AliasVault.Client/Auth/Pages/Base/LoginBase.cs
+++ b/src/AliasVault.Client/Auth/Pages/Base/LoginBase.cs
@@ -27,6 +27,12 @@ public class LoginBase : OwningComponentBase
[Inject]
public required NavigationManager NavigationManager { get; set; }
+ ///
+ /// Gets or sets the UserRegistrationService.
+ ///
+ [Inject]
+ public required UserRegistrationService UserRegistrationService { get; set; }
+
///
/// Gets or sets the HttpClient.
///
@@ -51,6 +57,12 @@ public class LoginBase : OwningComponentBase
[Inject]
public required IJSRuntime Js { get; set; }
+ ///
+ /// Gets or sets the JsInteropService.
+ ///
+ [Inject]
+ public required JsInteropService JsInteropService { get; set; }
+
///
/// Gets or sets the DbService.
///
diff --git a/src/AliasVault.Client/Auth/Pages/Login.razor b/src/AliasVault.Client/Auth/Pages/Login.razor
index 8d80bb115..1585a60c3 100644
--- a/src/AliasVault.Client/Auth/Pages/Login.razor
+++ b/src/AliasVault.Client/Auth/Pages/Login.razor
@@ -9,26 +9,26 @@
@using AliasVault.Cryptography.Client
@using SecureRemotePassword
-@if (ShowTwoFactorAuthStep)
+@if (_showTwoFactorAuthStep)
{
Two-factor authentication
-
+
Your login is protected with an authenticator app. Enter your authenticator code below.
-
+
-
-
+
+
-
+
@@ -42,25 +42,25 @@
.
}
-else if (ShowLoginWithRecoveryCodeStep)
+else if (_showLoginWithRecoveryCodeStep)
{
Recovery code verification
-
+
You have requested to log in with a recovery code. A recovery code is a one-time code that can be used to log in to your account.
Note that if you don't manually disable 2FA after login, you will be asked for an authenticator code again at the next login.
-
+
-
-
+
+
@@ -73,28 +73,28 @@ else if (ShowLoginWithRecoveryCodeStep)
else
{
- Sign in to AliasVault
+ Log in to AliasVault
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
+
@@ -104,23 +104,23 @@ else
- Not registered?
Create account
+ No account yet?
Create new vault
}
@code {
- private readonly LoginModel LoginModel = new();
- private readonly LoginModel2Fa LoginModel2Fa = new();
- private readonly LoginModelRecoveryCode LoginModelRecoveryCode = new();
- private FullScreenLoadingIndicator LoadingIndicator = new();
- private ServerValidationErrors ServerValidationErrors = new();
- private bool ShowTwoFactorAuthStep;
- private bool ShowLoginWithRecoveryCodeStep;
+ private readonly LoginModel _loginModel = new();
+ private readonly LoginModel2Fa _loginModel2Fa = new();
+ private readonly LoginModelRecoveryCode _loginModelRecoveryCode = new();
+ private FullScreenLoadingIndicator _loadingIndicator = new();
+ private ServerValidationErrors _serverValidationErrors = new();
+ private bool _showTwoFactorAuthStep;
+ private bool _showLoginWithRecoveryCodeStep;
- private SrpEphemeral ClientEphemeral = new();
- private SrpSession ClientSession = new();
- private byte[] PasswordHash = [];
+ private SrpEphemeral _clientEphemeral = new();
+ private SrpSession _clientSession = new();
+ private byte[] _passwordHash = [];
///
protected override async Task OnInitializedAsync()
@@ -133,17 +133,30 @@ else
}
}
- private void LoginWithAuthenticator()
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
{
- ShowLoginWithRecoveryCodeStep = false;
- ShowTwoFactorAuthStep = true;
+ if (firstRender)
+ {
+ await Task.Delay(300); // Give time for the DOM to update
+ await JsInteropService.FocusElementById("email");
+ }
+ }
+
+ private async Task LoginWithAuthenticator()
+ {
+ _showLoginWithRecoveryCodeStep = false;
+ _showTwoFactorAuthStep = true;
StateHasChanged();
+
+ await Task.Delay(100); // Give time for the DOM to update
+ await JsInteropService.FocusElementById("two-factor-code");
}
private void LoginWithRecoveryCode()
{
- ShowLoginWithRecoveryCodeStep = true;
- ShowTwoFactorAuthStep = false;
+ _showLoginWithRecoveryCodeStep = true;
+ _showTwoFactorAuthStep = false;
StateHasChanged();
}
@@ -152,33 +165,33 @@ else
///
private async Task HandleLogin()
{
- LoadingIndicator.Show();
- ServerValidationErrors.Clear();
+ _loadingIndicator.Show();
+ _serverValidationErrors.Clear();
try
{
var errors = await ProcessLoginAsync();
foreach (var error in errors)
{
- ServerValidationErrors.AddError(error);
+ _serverValidationErrors.AddError(error);
}
}
#if DEBUG
- catch (Exception ex)
+ catch (Exception ex)
{
- // If in debug mode show the actual exception.
- ServerValidationErrors.AddError(ex.ToString());
+ // If in debug mode show the actual exception.
+ _serverValidationErrors.AddError(ex.ToString());
}
#else
catch
{
// If in release mode show a generic error.
- ServerValidationErrors.AddError("An error occurred while processing the login request. Try again (later).");
+ _serverValidationErrors.AddError("An error occurred while processing the login request. Try again (later).");
}
#endif
finally
{
- LoadingIndicator.Hide();
+ _loadingIndicator.Hide();
}
}
@@ -186,12 +199,12 @@ else
/// Process the login request using username and password.
///
///
List of errors if something went wrong.
- protected async Task
> ProcessLoginAsync()
+ private async Task> ProcessLoginAsync()
{
GlobalNotificationService.ClearMessages();
// Sanitize username
- var username = LoginModel.Username.ToLowerInvariant().Trim();
+ var username = _loginModel.Username.ToLowerInvariant().Trim();
// Send request to server with username to get server ephemeral public key.
var result = await Http.PostAsJsonAsync("api/v1/Auth/login", new LoginInitiateRequest(username));
@@ -208,24 +221,24 @@ else
return
[
"An error occurred while processing the login request.",
- ];
+ ];
}
// 3. Client derives shared session key.
- PasswordHash = await Encryption.DeriveKeyFromPasswordAsync(LoginModel.Password, loginResponse.Salt, loginResponse.EncryptionType, loginResponse.EncryptionSettings);
- var passwordHashString = BitConverter.ToString(PasswordHash).Replace("-", string.Empty);
+ _passwordHash = await Encryption.DeriveKeyFromPasswordAsync(_loginModel.Password, loginResponse.Salt, loginResponse.EncryptionType, loginResponse.EncryptionSettings);
+ var passwordHashString = BitConverter.ToString(_passwordHash).Replace("-", string.Empty);
- ClientEphemeral = Srp.GenerateEphemeralClient();
+ _clientEphemeral = Srp.GenerateEphemeralClient();
var privateKey = Srp.DerivePrivateKey(loginResponse.Salt, username, passwordHashString);
- ClientSession = Srp.DeriveSessionClient(
+ _clientSession = Srp.DeriveSessionClient(
privateKey,
- ClientEphemeral.Secret,
+ _clientEphemeral.Secret,
loginResponse.ServerEphemeral,
loginResponse.Salt,
username);
// 4. Client sends proof of session key to server.
- result = await Http.PostAsJsonAsync("api/v1/Auth/validate", new ValidateLoginRequest(username, LoginModel.RememberMe, ClientEphemeral.Public, ClientSession.Proof));
+ result = await Http.PostAsJsonAsync("api/v1/Auth/validate", new ValidateLoginRequest(username, _loginModel.RememberMe, _clientEphemeral.Public, _clientSession.Proof));
responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
@@ -239,14 +252,13 @@ else
return
[
"An error occurred while processing the login request.",
- ];
+ ];
}
// Check if 2FA is required, if yes, show 2FA step.
if (validateLoginResponse.RequiresTwoFactor)
{
- ShowTwoFactorAuthStep = true;
- StateHasChanged();
+ await LoginWithAuthenticator();
return [];
}
@@ -259,23 +271,23 @@ else
///
private async Task HandleRecoveryCode()
{
- LoadingIndicator.Show();
- ServerValidationErrors.Clear();
+ _loadingIndicator.Show();
+ _serverValidationErrors.Clear();
try
{
// Sanitize username
- var username = LoginModel.Username.ToLowerInvariant().Trim();
+ var username = _loginModel.Username.ToLowerInvariant().Trim();
// Validate 2-factor auth code auth and login
- var result = await Http.PostAsJsonAsync("api/v1/Auth/validate-recovery-code", new ValidateLoginRequestRecoveryCode(username, LoginModel.RememberMe, ClientEphemeral.Public, ClientSession.Proof, LoginModelRecoveryCode.RecoveryCode));
+ var result = await Http.PostAsJsonAsync("api/v1/Auth/validate-recovery-code", new ValidateLoginRequestRecoveryCode(username, _loginModel.RememberMe, _clientEphemeral.Public, _clientSession.Proof, _loginModelRecoveryCode.RecoveryCode));
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
foreach (var error in ApiResponseUtility.ParseErrorResponse(responseContent))
{
- ServerValidationErrors.AddError(error);
+ _serverValidationErrors.AddError(error);
}
return;
}
@@ -283,32 +295,32 @@ else
var validateLoginResponse = JsonSerializer.Deserialize(responseContent);
if (validateLoginResponse == null)
{
- ServerValidationErrors.AddError("An error occurred while processing the login request.");
+ _serverValidationErrors.AddError("An error occurred while processing the login request.");
return;
}
var errors = await ProcessLoginVerify(validateLoginResponse);
foreach (var error in errors)
{
- ServerValidationErrors.AddError(error);
+ _serverValidationErrors.AddError(error);
}
}
#if DEBUG
catch (Exception ex)
{
// If in debug mode show the actual exception.
- ServerValidationErrors.AddError(ex.ToString());
+ _serverValidationErrors.AddError(ex.ToString());
}
#else
catch
{
// If in release mode show a generic error.
- ServerValidationErrors.AddError("An error occurred while processing the login request. Try again (later).");
+ _serverValidationErrors.AddError("An error occurred while processing the login request. Try again (later).");
}
#endif
finally
{
- LoadingIndicator.Hide();
+ _loadingIndicator.Hide();
}
}
@@ -317,23 +329,23 @@ else
///
private async Task Handle2Fa()
{
- LoadingIndicator.Show();
- ServerValidationErrors.Clear();
+ _loadingIndicator.Show();
+ _serverValidationErrors.Clear();
try
{
// Sanitize username
- var username = LoginModel.Username.ToLowerInvariant().Trim();
+ var username = _loginModel.Username.ToLowerInvariant().Trim();
// Validate 2-factor auth code auth and login
- var result = await Http.PostAsJsonAsync("api/v1/Auth/validate-2fa", new ValidateLoginRequest2Fa(username, LoginModel.RememberMe, ClientEphemeral.Public, ClientSession.Proof, LoginModel2Fa.TwoFactorCode ?? 0));
+ var result = await Http.PostAsJsonAsync("api/v1/Auth/validate-2fa", new ValidateLoginRequest2Fa(username, _loginModel.RememberMe, _clientEphemeral.Public, _clientSession.Proof, _loginModel2Fa.TwoFactorCode ?? 0));
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
foreach (var error in ApiResponseUtility.ParseErrorResponse(responseContent))
{
- ServerValidationErrors.AddError(error);
+ _serverValidationErrors.AddError(error);
}
return;
}
@@ -341,32 +353,32 @@ else
var validateLoginResponse = JsonSerializer.Deserialize(responseContent);
if (validateLoginResponse == null)
{
- ServerValidationErrors.AddError("An error occurred while processing the login request.");
+ _serverValidationErrors.AddError("An error occurred while processing the login request.");
return;
}
var errors = await ProcessLoginVerify(validateLoginResponse);
foreach (var error in errors)
{
- ServerValidationErrors.AddError(error);
+ _serverValidationErrors.AddError(error);
}
}
#if DEBUG
catch (Exception ex)
{
// If in debug mode show the actual exception.
- ServerValidationErrors.AddError(ex.ToString());
+ _serverValidationErrors.AddError(ex.ToString());
}
#else
catch
{
// If in release mode show a generic error.
- ServerValidationErrors.AddError("An error occurred while processing the login request. Try again (later).");
+ _serverValidationErrors.AddError("An error occurred while processing the login request. Try again (later).");
}
#endif
finally
{
- LoadingIndicator.Hide();
+ _loadingIndicator.Hide();
}
}
@@ -376,14 +388,14 @@ else
private async Task> ProcessLoginVerify(ValidateLoginResponse validateLoginResponse)
{
// 5. Client verifies proof.
- Srp.VerifySession(ClientEphemeral.Public, ClientSession, validateLoginResponse.ServerSessionProof);
+ Srp.VerifySession(_clientEphemeral.Public, _clientSession, validateLoginResponse.ServerSessionProof);
// Store the tokens in local storage.
await AuthService.StoreAccessTokenAsync(validateLoginResponse.Token!.Token);
await AuthService.StoreRefreshTokenAsync(validateLoginResponse.Token!.RefreshToken);
// Store the encryption key in memory.
- await AuthService.StoreEncryptionKeyAsync(PasswordHash);
+ await AuthService.StoreEncryptionKeyAsync(_passwordHash);
await AuthStateProvider.GetAuthenticationStateAsync();
GlobalNotificationService.ClearMessages();
diff --git a/src/AliasVault.Client/Auth/Pages/Register.razor b/src/AliasVault.Client/Auth/Pages/Register.razor
index 251034c67..4033dda18 100644
--- a/src/AliasVault.Client/Auth/Pages/Register.razor
+++ b/src/AliasVault.Client/Auth/Pages/Register.razor
@@ -1,50 +1,42 @@
@page "/user/register"
+@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
@attribute [AllowAnonymous]
@layout Auth.Layout.MainLayout
-@inject HttpClient Http
-@inject AuthenticationStateProvider AuthStateProvider
-@inject NavigationManager NavigationManager
-@inject AuthService AuthService
-@inject IConfiguration Configuration
-@using System.Text.Json
@using AliasVault.Shared.Models.WebApi.Auth
@using AliasVault.Client.Auth.Components
-@using AliasVault.Client.Utilities
-@using AliasVault.Cryptography.Client
-@using SecureRemotePassword
Create a new AliasVault account
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -55,73 +47,27 @@
@code {
- private readonly RegisterModel RegisterModel = new();
- private FullScreenLoadingIndicator LoadingIndicator = new();
- private ServerValidationErrors ServerValidationErrors = new();
+ private readonly RegisterModel _registerModel = new();
+ private FullScreenLoadingIndicator _loadingIndicator = new();
+ private ServerValidationErrors _serverValidationErrors = new();
- async Task HandleRegister()
+ private async Task HandleRegister()
{
- LoadingIndicator.Show();
- ServerValidationErrors.Clear();
+ _loadingIndicator.Show();
+ _serverValidationErrors.Clear();
- try
+ var (success, errorMessage) = await UserRegistrationService.RegisterUserAsync(_registerModel.Username, _registerModel.Password);
+
+ if (success)
{
- var client = new SrpClient();
- var salt = client.GenerateSalt();
-
- byte[] passwordHash;
- string encryptionType = Defaults.EncryptionType;
- string encryptionSettings = Defaults.EncryptionSettings;
- if (Configuration["CryptographyOverrideType"] is not null && Configuration["CryptographyOverrideSettings"] is not null) {
- // If cryptography type and settings override are present in appsettings.json, use them instead of defaults
- // declared in code. This is used in certain cases e.g. E2E tests to speed up the process.
- encryptionType = Configuration["CryptographyOverrideType"]!;
- encryptionSettings = Configuration["CryptographyOverrideSettings"]!;
- }
-
- passwordHash = await Encryption.DeriveKeyFromPasswordAsync(RegisterModel.Password, salt, encryptionType, encryptionSettings);
- var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty);
- var srpSignup = Srp.PasswordChangeAsync(client, salt, RegisterModel.Username, passwordHashString);
-
- var registerRequest = new RegisterRequest(srpSignup.Username, srpSignup.Salt, srpSignup.Verifier, encryptionType, encryptionSettings);
- var result = await Http.PostAsJsonAsync("api/v1/Auth/register", registerRequest);
- var responseContent = await result.Content.ReadAsStringAsync();
-
- if (!result.IsSuccessStatusCode)
- {
- foreach (var error in ApiResponseUtility.ParseErrorResponse(responseContent))
- {
- ServerValidationErrors.AddError(error);
- }
- StateHasChanged();
- return;
- }
-
- var tokenObject = JsonSerializer.Deserialize(responseContent);
-
- if (tokenObject != null)
- {
- // Store the encryption key in memory.
- await AuthService.StoreEncryptionKeyAsync(passwordHash);
-
- // Store the token as a plain string in local storage
- await AuthService.StoreAccessTokenAsync(tokenObject.Token);
- await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken);
- await AuthStateProvider.GetAuthenticationStateAsync();
- }
- else
- {
- // Handle the case where the token is not present in the response
- ServerValidationErrors.AddError("An error occured during registration.");
- StateHasChanged();
- return;
- }
-
NavigationManager.NavigateTo("/");
}
- finally
+ else
{
- LoadingIndicator.Hide();
+ _serverValidationErrors.AddError(errorMessage ?? "An error occurred during registration.");
+ StateHasChanged();
}
+
+ _loadingIndicator.Hide();
}
}
diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Components/CreatingStep.razor b/src/AliasVault.Client/Auth/Pages/Setup/Components/CreatingStep.razor
new file mode 100644
index 000000000..c09cd1562
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Pages/Setup/Components/CreatingStep.razor
@@ -0,0 +1,65 @@
+@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
+@layout Auth.Layout.EmptyLayout
+@attribute [AllowAnonymous]
+@inject IConfiguration Configuration
+@using System.Text.Json
+@using AliasVault.Client.Utilities
+@using AliasVault.Cryptography.Client
+@using AliasVault.Shared.Models.WebApi.Auth
+@using SecureRemotePassword
+
+
+
+
+ @if (IsLoading)
+ {
+
+ }
+
+
+
+@code {
+ ///
+ /// The username to use for the new account.
+ ///
+ [Parameter]
+ public string Username { get; set; } = string.Empty;
+
+ ///
+ /// The password to use for the new account.
+ ///
+ [Parameter]
+ public string Password { get; set; } = string.Empty;
+
+ private bool IsLoading { get; set; } = true;
+
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ await base.OnAfterRenderAsync(firstRender);
+ if (firstRender)
+ {
+ await CompleteSetup();
+ }
+ }
+
+ private async Task CompleteSetup()
+ {
+ StateHasChanged();
+
+ var (success, errorMessage) = await UserRegistrationService.RegisterUserAsync(Username, Password);
+
+ if (success)
+ {
+ NavigationManager.NavigateTo("/");
+ }
+ else
+ {
+ IsLoading = false;
+ GlobalNotificationService.AddErrorMessage(errorMessage ?? "An error occurred during registration.", true);
+ StateHasChanged();
+ }
+ }
+}
diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor b/src/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor
new file mode 100644
index 000000000..708c0faae
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor
@@ -0,0 +1,231 @@
+@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
+@implements IDisposable
+@using System.Timers
+
+
+ @if (_isLoading)
+ {
+
+ }
+
+
+
+
+

+
+
+
+ Great! Now, let's set up your master password for AliasVault.
+
+
+
+
+
+
+
+ Important: This master password will be used to encrypt your vault. It should be a long, complex string that you can remember. If you forget this password, your data will be permanently inaccessible.
+
+
+ - Your master password never leaves your device
+ - The server has no access to your unencrypted data
+ - Even the server admin cannot restore your access if you forget this password
+
+
+
+
+
+
+
+
+
+
+
+ @if (_isValidating)
+ {
+
Validating password...
+ }
+ else if (_isValid)
+ {
+ @if (!string.IsNullOrEmpty(_errorMessage))
+ {
+
@_errorMessage
+ }
+ else
+ {
+
Password is valid and strong!
+ }
+ }
+ else if (!string.IsNullOrEmpty(_errorMessage))
+ {
+
@_errorMessage
+ }
+
+
+
+
+
+@code {
+ ///
+ /// The event callback for when the password changes.
+ ///
+ [Parameter]
+ public EventCallback OnPasswordChange { get; set; }
+
+ private string Password
+ {
+ get => _password;
+ set
+ {
+ if (_password != value)
+ {
+ _password = value;
+ ValidatePassword();
+ }
+ }
+ }
+
+ private string ConfirmPassword
+ {
+ get => _confirmPassword;
+ set
+ {
+ if (_confirmPassword != value)
+ {
+ _confirmPassword = value;
+ ValidatePassword();
+ }
+ }
+ }
+
+ private string _password = string.Empty;
+ private string _confirmPassword = string.Empty;
+ private bool _isValid = false;
+ private bool _isValidating = false;
+ private string _errorMessage = string.Empty;
+ private Timer? _debounceTimer;
+
+ private bool _isLoading = true;
+ private Timer? _loadingTimer;
+
+ ///
+ public void Dispose()
+ {
+ _loadingTimer?.Dispose();
+ _debounceTimer?.Dispose();
+ }
+
+ ///
+ protected override void OnInitialized()
+ {
+ _loadingTimer = new Timer(300);
+ _loadingTimer.Elapsed += (sender, e) => FinishLoading();
+ _loadingTimer.AutoReset = false;
+ _loadingTimer.Start();
+
+ _debounceTimer = new Timer(300);
+ _debounceTimer.Elapsed += async (sender, e) => await ValidatePasswordDebounced();
+ _debounceTimer.AutoReset = false;
+ }
+
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ await base.OnAfterRenderAsync(firstRender);
+ if (firstRender)
+ {
+ await Task.Delay(100); // Give time for the DOM to update
+ await JsInteropService.FocusElementById("password");
+ }
+ }
+
+ ///
+ /// Finishes the loading animation.
+ ///
+ private void FinishLoading()
+ {
+ _isLoading = false;
+ InvokeAsync(StateHasChanged);
+ }
+
+ ///
+ /// Validates the password immediately.
+ ///
+ private void ValidatePassword()
+ {
+ _isValidating = true;
+ _isValid = false;
+ _errorMessage = string.Empty;
+ StateHasChanged();
+
+ _debounceTimer?.Stop();
+ _debounceTimer?.Start();
+ }
+
+ ///
+ /// Validates the password after input has stopped.
+ ///
+ private async Task ValidatePasswordDebounced()
+ {
+ await InvokeAsync(async () =>
+ {
+ if (Password.Length < 10)
+ {
+ _isValidating = false;
+ _isValid = false;
+ _errorMessage = "Master password must be at least 10 characters long.";
+ await OnPasswordChange.InvokeAsync(string.Empty);
+ StateHasChanged();
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(ConfirmPassword))
+ {
+ _isValidating = false;
+ _isValid = false;
+ _errorMessage = "Confirm your password by entering it again.";
+ await OnPasswordChange.InvokeAsync(string.Empty);
+ StateHasChanged();
+ return;
+ }
+
+ if (Password != ConfirmPassword)
+ {
+ _isValidating = false;
+ _isValid = false;
+ _errorMessage = "Passwords do not match.";
+ await OnPasswordChange.InvokeAsync(string.Empty);
+ StateHasChanged();
+ return;
+ }
+
+ // If password is valid.
+ _isValid = true;
+ _errorMessage = string.Empty;
+
+ // Show warning for passwords between 10 and 13 characters.
+ if (Password.Length < 14)
+ {
+ _errorMessage = "Password is valid, but could be stronger if made longer.";
+ }
+
+ await OnPasswordChange.InvokeAsync(Password);
+
+ _isValidating = false;
+ StateHasChanged();
+ });
+ }
+
+ ///
+ /// Handles the password input focus.
+ ///
+ private void OnPasswordInputFocus(FocusEventArgs args)
+ {
+ // Reset validation state when the input is focused.
+ _isValid = false;
+ _isValidating = false;
+ _errorMessage = string.Empty;
+ StateHasChanged();
+ }
+}
diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Components/TermsAndConditionsStep.razor b/src/AliasVault.Client/Auth/Pages/Setup/Components/TermsAndConditionsStep.razor
new file mode 100644
index 000000000..78347e277
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Pages/Setup/Components/TermsAndConditionsStep.razor
@@ -0,0 +1,89 @@
+@implements IDisposable
+@using System.Timers
+
+
+ @if (_isLoading)
+ {
+
+ }
+
+
+
+ Please read and agree to the following terms and conditions before proceeding.
+
+
+
Terms and Conditions
+
+ AliasVault is designed to enhance your online security and protect your privacy. With AliasVault, you can create unique identities and email aliases for your various online accounts, helping you maintain control over your personal information and reduce the risk of identity theft.
+
+ By using AliasVault, you agree to the following terms:
+
+ 1. You will not use AliasVault for any illegal purposes, including but not limited to fraud, identity theft, or impersonating real individuals.
+
+ 2. You are responsible for maintaining the confidentiality of your account and any aliases created through AliasVault.
+
+ 3. AliasVault reserves the right to terminate your account if we suspect any misuse or violation of these terms.
+
+ 4. You understand that while AliasVault enhances your privacy, no system is completely foolproof, and you use the service at your own risk.
+
+
+
+
+
+
+
+
+
+
+@code {
+ ///
+ /// Gets or sets a value indicating whether the user has agreed to the terms and conditions.
+ ///
+ [Parameter]
+ public bool AgreedToTerms { get; set; }
+
+ ///
+ /// The event callback for when the user has agreed to the terms and conditions.
+ ///
+ [Parameter]
+ public EventCallback OnAgreedToTermsChanged { get; set; }
+
+ private bool _isLoading = true;
+ private Timer? _loadingTimer;
+
+ ///
+ public void Dispose()
+ {
+ _loadingTimer?.Dispose();
+ }
+
+ ///
+ protected override void OnInitialized()
+ {
+ _loadingTimer = new Timer(300);
+ _loadingTimer.Elapsed += (sender, e) => FinishLoading();
+ _loadingTimer.AutoReset = false;
+ _loadingTimer.Start();
+ }
+
+ ///
+ /// Finishes the loading animation.
+ ///
+ private void FinishLoading()
+ {
+ _isLoading = false;
+ InvokeAsync(StateHasChanged);
+ }
+
+ ///
+ /// Handles the user agreeing to the terms and conditions.
+ ///
+ private async Task OnAgreedToTerms()
+ {
+ await OnAgreedToTermsChanged.InvokeAsync(AgreedToTerms);
+ }
+}
diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Components/UsernameStep.razor b/src/AliasVault.Client/Auth/Pages/Setup/Components/UsernameStep.razor
new file mode 100644
index 000000000..eb18fa467
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Pages/Setup/Components/UsernameStep.razor
@@ -0,0 +1,213 @@
+@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
+@implements IDisposable
+@using System.Timers
+
+
+ @if (_isLoading)
+ {
+
+ }
+
+
+
+
+

+
+
+
+ Great! Now, let's set up your username for AliasVault.
+
+
+ Please enter a username you'd like to use. This can be your email address or any unique name you prefer.
+
+
+ Remember: This is what you'll use to log in later, so make sure it's something you'll remember!
+
+
+
+
+
+
+
+
+ @if (_isValidating)
+ {
+
Validating username...
+ }
+ else if (_isValid)
+ {
+
Username is available!
+ }
+ else if (!string.IsNullOrEmpty(_errorMessage))
+ {
+
@_errorMessage
+ }
+
+
+
+
+
+@code {
+ ///
+ /// The username that is previously entered by the user. When a user navigates with back/continue
+ /// and entered a username already, the existing username might be provided by the parent component.
+ ///
+ [Parameter]
+ public string DefaultUsername { get; set; } = string.Empty;
+
+ ///
+ /// The event callback for when the username changes.
+ ///
+ [Parameter]
+ public EventCallback OnUsernameChange { get; set; }
+
+ private string _username = string.Empty;
+ private bool _isValid = false;
+ private bool _isValidating = false;
+ private string _errorMessage = string.Empty;
+ private Timer? _debounceTimer;
+
+ ///
+ /// The username that is entered by the user. This is the value that will be validated and sent to the parent component.
+ ///
+ private string Username
+ {
+ get => _username;
+ set
+ {
+ if (_username != value)
+ {
+ _username = value;
+ ValidateUsername();
+ }
+ }
+ }
+
+ private bool _isLoading = true;
+ private Timer? _loadingTimer;
+
+ ///
+ public void Dispose()
+ {
+ _loadingTimer?.Dispose();
+ _debounceTimer?.Dispose();
+ }
+
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ // Set the default username if provided.
+ _username = DefaultUsername;
+
+ if (!string.IsNullOrWhiteSpace(DefaultUsername))
+ {
+ await ValidateUsernameDebounced();
+ }
+
+ _loadingTimer = new Timer(300);
+ _loadingTimer.Elapsed += (sender, e) => FinishLoading();
+ _loadingTimer.AutoReset = false;
+ _loadingTimer.Start();
+
+ _debounceTimer = new Timer(300);
+ _debounceTimer.Elapsed += async (sender, e) => await ValidateUsernameDebounced();
+ _debounceTimer.AutoReset = false;
+ }
+
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ await base.OnAfterRenderAsync(firstRender);
+ if (firstRender)
+ {
+ await Task.Delay(100); // Give time for the DOM to update
+ await JsInteropService.FocusElementById("username");
+ }
+ }
+
+ ///
+ /// Finishes the loading animation.
+ ///
+ private void FinishLoading()
+ {
+ _isLoading = false;
+ InvokeAsync(StateHasChanged);
+ }
+
+ ///
+ /// Validates the username immediately.
+ ///
+ private void ValidateUsername()
+ {
+ _isValidating = true;
+ _isValid = false;
+ _errorMessage = string.Empty;
+ StateHasChanged();
+
+ _debounceTimer?.Stop();
+ _debounceTimer?.Start();
+ }
+
+ ///
+ /// Validates the username after input has stopped.
+ ///
+ private async Task ValidateUsernameDebounced()
+ {
+ await InvokeAsync(async () =>
+ {
+ if (string.IsNullOrWhiteSpace(Username))
+ {
+ _isValidating = false;
+ _isValid = false;
+ _errorMessage = "Username is required.";
+ await OnUsernameChange.InvokeAsync(string.Empty);
+ StateHasChanged();
+ return;
+ }
+
+ try
+ {
+ var response = await Http.PostAsJsonAsync("api/v1/Auth/validate-username", new { Username });
+
+ if (response.IsSuccessStatusCode)
+ {
+ _isValid = true;
+ _errorMessage = string.Empty;
+ await OnUsernameChange.InvokeAsync(Username);
+ }
+ else
+ {
+ var error = await response.Content.ReadAsStringAsync();
+ _errorMessage = error;
+ _isValid = false;
+ await OnUsernameChange.InvokeAsync(string.Empty);
+ }
+ }
+ catch
+ {
+ _errorMessage = "An error occurred while validating the username.";
+ _isValid = false;
+ await OnUsernameChange.InvokeAsync(string.Empty);
+ }
+ finally
+ {
+ _isValidating = false;
+ StateHasChanged();
+ }
+ });
+ }
+
+ ///
+ /// Handles the username input focus.
+ ///
+ private void OnUsernameInputFocus(FocusEventArgs args)
+ {
+ // Reset validation state when the input is focused
+ _isValid = false;
+ _isValidating = false;
+ _errorMessage = string.Empty;
+ StateHasChanged();
+ }
+}
diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Components/WelcomeStep.razor b/src/AliasVault.Client/Auth/Pages/Setup/Components/WelcomeStep.razor
new file mode 100644
index 000000000..d15be703c
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Pages/Setup/Components/WelcomeStep.razor
@@ -0,0 +1,14 @@
+
+
+ AliasVault is a secure app which help you create and manage your online identities and passwords.
+ Let's get you set up with your new vault.
+
+
+
+@code {
+ ///
+ /// The event that is triggered when the user clicks the next button.
+ ///
+ [Parameter]
+ public EventCallback OnNext { get; set; }
+}
diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Setup.razor b/src/AliasVault.Client/Auth/Pages/Setup/Setup.razor
new file mode 100644
index 000000000..d5e86f532
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Pages/Setup/Setup.razor
@@ -0,0 +1,198 @@
+@page "/user/setup"
+@using AliasVault.Client.Auth.Pages.Setup.Components
+@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
+@layout Auth.Layout.EmptyLayout
+@attribute [AllowAnonymous]
+
+
+
+
+
+
+
+
+
@GetStepTitle(_currentStep)
+
+
+
+ @if (GetProgressPercentage() > 0)
+ {
+
+ }
+
+ @switch (_currentStep)
+ {
+ case SetupStep.Welcome:
+
+ break;
+ case SetupStep.TermsAndConditions:
+
+ break;
+ case SetupStep.Username:
+
+ break;
+ case SetupStep.Password:
+
+ break;
+ case SetupStep.Creating:
+
+ break;
+ }
+
+
+ @if (_currentStep == SetupStep.Password && !string.IsNullOrWhiteSpace(_setupData.Password))
+ {
+
+ }
+ else if (_currentStep != SetupStep.Creating)
+ {
+
+ }
+
+
+
+
+
+@code {
+ private SetupStep _currentStep = SetupStep.Welcome;
+ private readonly SetupData _setupData = new();
+
+ ///
+ /// Determines if the "Continue" button is enabled based on the current step and setup data.
+ ///
+ private bool IsNextEnabled => _currentStep switch
+ {
+ SetupStep.Welcome => true,
+ SetupStep.TermsAndConditions => _setupData.AgreedToTerms,
+ SetupStep.Username => !string.IsNullOrWhiteSpace(_setupData.Username),
+ SetupStep.Password => !string.IsNullOrWhiteSpace(_setupData.Password),
+ _ => false
+ };
+
+ ///
+ /// Get the title for the setup step.
+ ///
+ /// The current setup step.
+ /// The title for the setup step.
+ private static string GetStepTitle(SetupStep step)
+ {
+ return step switch
+ {
+ SetupStep.Welcome => "Welcome to AliasVault",
+ SetupStep.TermsAndConditions => "Using AliasVault",
+ SetupStep.Username => "Choose Username",
+ SetupStep.Password => "Set Password",
+ SetupStep.Creating => "Creating Vault",
+ _ => "Setup"
+ };
+ }
+
+ ///
+ /// Navigates to the previous step in the setup process.
+ ///
+ private void GoBack()
+ {
+ switch (_currentStep)
+ {
+ case SetupStep.TermsAndConditions:
+ _currentStep = SetupStep.Welcome;
+ break;
+ case SetupStep.Username:
+ _currentStep = SetupStep.TermsAndConditions;
+ break;
+ case SetupStep.Password:
+ _currentStep = SetupStep.Username;
+ break;
+ case SetupStep.Creating:
+ _currentStep = SetupStep.Password;
+ break;
+ }
+ }
+
+ ///
+ /// Navigates to the next step in the setup process.
+ ///
+ private void GoNext()
+ {
+ _currentStep = _currentStep switch
+ {
+ SetupStep.Welcome => SetupStep.TermsAndConditions,
+ SetupStep.TermsAndConditions => SetupStep.Username,
+ SetupStep.Username => SetupStep.Password,
+ SetupStep.Password => SetupStep.Creating,
+ _ => _currentStep
+ };
+ }
+
+ ///
+ /// Cancels the setup process and navigates to the start page.
+ ///
+ private void CancelSetup()
+ {
+ NavigationManager.NavigateTo("/");
+ }
+
+ ///
+ /// Handles the change of the terms and conditions agreement.
+ ///
+ /// True if the terms and conditions are agreed to, false otherwise.
+ private void HandleAgreedToTermsChanged(bool agreed)
+ {
+ _setupData.AgreedToTerms = agreed;
+ StateHasChanged();
+ }
+
+ ///
+ /// Enum representing the different steps in the setup process.
+ ///
+ private enum SetupStep
+ {
+ Welcome,
+ TermsAndConditions,
+ Username,
+ Password,
+ Creating
+ }
+
+ ///
+ /// Data class for storing setup data.
+ ///
+ private sealed class SetupData
+ {
+ public bool AgreedToTerms { get; set; }
+ public string Username { get; set; } = string.Empty;
+ public string Password { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Calculates the progress percentage based on the current step in the setup process.
+ ///
+ /// The progress percentage as an integer.
+ private int GetProgressPercentage()
+ {
+ return (int)_currentStep * 100 / (Enum.GetValues(typeof(SetupStep)).Length - 1);
+ }
+}
diff --git a/src/AliasVault.Client/Auth/Pages/Start.razor b/src/AliasVault.Client/Auth/Pages/Start.razor
new file mode 100644
index 000000000..354a2eb9e
--- /dev/null
+++ b/src/AliasVault.Client/Auth/Pages/Start.razor
@@ -0,0 +1,50 @@
+@page "/user/start"
+@using AliasVault.Client.Auth.Components
+@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
+@layout Auth.Layout.EmptyLayout
+@attribute [AllowAnonymous]
+
+
+
+
+
+

+
+
+
+
+
+
+
+ Password & Identity manager
+
+
+ Your Privacy. Protected.
+
+
+
+
+
+
+
+
+
+@code {
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ await AuthStateProvider.GetAuthenticationStateAsync();
+ var authState = await AuthStateProvider.GetAuthenticationStateAsync();
+ if (authState.User.Identity?.IsAuthenticated == true) {
+ // Already authenticated, redirect to home page.
+ NavigationManager.NavigateTo("/");
+ }
+ }
+}
diff --git a/src/AliasVault.Client/Auth/Pages/Unlock.razor b/src/AliasVault.Client/Auth/Pages/Unlock.razor
index 9ba86bd2f..86d2187a2 100644
--- a/src/AliasVault.Client/Auth/Pages/Unlock.razor
+++ b/src/AliasVault.Client/Auth/Pages/Unlock.razor
@@ -21,7 +21,7 @@ else if (IsWebAuthnLoading) {
else
{
-

+
@Username
diff --git a/src/AliasVault.Client/Main/Components/Alerts/GlobalNotificationDisplay.razor b/src/AliasVault.Client/Main/Components/Alerts/GlobalNotificationDisplay.razor
index 05a687c65..b4d820301 100644
--- a/src/AliasVault.Client/Main/Components/Alerts/GlobalNotificationDisplay.razor
+++ b/src/AliasVault.Client/Main/Components/Alerts/GlobalNotificationDisplay.razor
@@ -7,7 +7,7 @@
return;
}
-
+
@foreach (var message in Messages)
{
if (message.Key == "success")
diff --git a/src/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor b/src/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor
index 3cff88e2b..2822dfa3b 100644
--- a/src/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor
+++ b/src/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor
@@ -2,7 +2,9 @@
Notes
- @((MarkupString)ConvertUrlsToLinks(Notes.Replace(Environment.NewLine, "
")))
+
+ @((MarkupString)ConvertUrlsToLinks(Notes).Replace(Environment.NewLine, "
"))
+
@code {
@@ -15,6 +17,14 @@
private static string ConvertUrlsToLinks(string text)
{
string urlPattern = @"(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})";
- return Regex.Replace(text, urlPattern, match => $"
{match.Value}", RegexOptions.None, TimeSpan.FromMilliseconds(100));
+ return Regex.Replace(text, urlPattern, match =>
+ {
+ string url = match.Value;
+ if (!url.StartsWith("http://") && !url.StartsWith("https://"))
+ {
+ url = "http://" + url;
+ }
+ return $"
{match.Value}";
+ }, RegexOptions.None, TimeSpan.FromMilliseconds(200));
}
}
diff --git a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor
index 9f1ee164d..9f8693c53 100644
--- a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor
+++ b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor
@@ -2,11 +2,11 @@
@if (Type == "textarea")
{
-
+
}
else
{
-
+
}
@@ -47,6 +47,12 @@
[Parameter]
public EventCallback
ValueChanged { get; set; }
+ ///
+ /// Placeholder text for the input field.
+ ///
+ [Parameter]
+ public string Placeholder { get; set; } = string.Empty;
+
private async Task OnInputChanged(ChangeEventArgs e)
{
Value = e.Value?.ToString() ?? string.Empty;
diff --git a/src/AliasVault.Client/Main/Components/RedirectToLogin.razor b/src/AliasVault.Client/Main/Components/RedirectToLogin.razor
index 251b29977..ac1456e7b 100644
--- a/src/AliasVault.Client/Main/Components/RedirectToLogin.razor
+++ b/src/AliasVault.Client/Main/Components/RedirectToLogin.razor
@@ -4,6 +4,6 @@
///
protected override void OnInitialized()
{
- Navigation.NavigateTo("/user/login");
+ Navigation.NavigateTo("/user/start");
}
}
diff --git a/src/AliasVault.Client/Main/Layout/Footer.razor b/src/AliasVault.Client/Main/Layout/Footer.razor
index 5d4209955..f2a038cb0 100644
--- a/src/AliasVault.Client/Main/Layout/Footer.razor
+++ b/src/AliasVault.Client/Main/Layout/Footer.razor
@@ -1,20 +1,29 @@
@inject NavigationManager NavigationManager
@implements IDisposable
-