Merge pull request #1366 from aliasvault/1347-feature-request-unlock-vault-with-mobile-device

Add "unlock with mobile" option to web app and browser extension
This commit is contained in:
Leendert de Borst
2025-11-20 05:11:25 +00:00
committed by GitHub
125 changed files with 7414 additions and 965 deletions

View File

@@ -24,6 +24,7 @@
}
],
"settings": {
"java.configuration.updateBuildConfiguration": "disabled"
"java.configuration.updateBuildConfiguration": "disabled",
"i18n-ally.keystyle": "nested"
}
}

View File

@@ -10,8 +10,6 @@ The basic premise is that the master password chosen by the user upon registrati
- **Vault Data**: Your entire vault (passwords, usernames, notes, passkeys, email addresses, etc.) is fully encrypted client-side before being sent to the server. The server cannot decrypt any vault contents.
- **Email Contents**: When emails are received by the server, their contents are immediately encrypted with your public key (from your vault) before being saved. Only you can decrypt and read these emails with your private key.
*Note: email aliases are stored on the server as "claims" which are linked to internal user IDs for routing purposes.*
This ensures that even if the AliasVault servers are compromised, vault contents and email messages remain secure and unreadable.
## Encryption algorithms
@@ -25,6 +23,7 @@ The following encryption algorithms and standards are used by AliasVault:
### Additional Features
- [RSA-OAEP](#rsa-oaep) - Email encryption
- [Passkeys (WebAuthn)](#passkeys-webauthn) - Passwordless authentication
- [Login with Mobile](#login-with-mobile) - Unlock vault in web app / browser extension via mobile app
Below is a detailed explanation of each encryption algorithm and standard.
@@ -138,3 +137,31 @@ All implementations follow the WebAuthn Level 2 specification and use:
- Eliminates phishing risks through cryptographic domain binding
More information about WebAuthn can be found on the [WebAuthn specification](https://www.w3.org/TR/webauthn-2/) page.
### Login with Mobile
AliasVault provides a secure "Login with Mobile" feature that allows users to unlock their vault on web browsers or browser extensions by scanning a QR code with their authenticated mobile app. This convenient authentication method maintains zero-knowledge security through hybrid encryption.
#### Implementation Details
The mobile login system combines RSA-2048 asymmetric encryption with AES-256-GCM symmetric encryption:
1. **Initiation**: Browser/extension client generates an RSA-2048 key pair locally and sends the public key to the server, which returns a unique request ID displayed as a QR code.
2. **Authorization**: Mobile app scans the QR code, encrypts the user's vault decryption key with the RSA public key, and sends it to the server.
3. **Retrieval**: Browser client polls the server for completion. When ready, the server:
- Generates fresh JWT tokens for the session
- Creates a random AES-256 symmetric key
- Encrypts tokens and username with the symmetric key
- Encrypts the symmetric key with the client's RSA public key
- Returns encrypted data and immediately purges it from the database
4. **Decryption**: Client uses its RSA private key to decrypt the symmetric key, then uses the symmetric key to decrypt tokens and username, and the RSA private key to decrypt the vault decryption key.
#### Security Properties
- **Zero-Knowledge**: Server never accesses the vault decryption key in plaintext
- **One-Time Use**: Requests cannot be retrieved twice; data is immediately cleared after retrieval
- **Automatic Expiration**: Unfulfilled requests expire after 2 minutes client-side (3 minutes server-side); fulfilled but unretrieved requests auto-delete within 24 hours
- **MITM Protection**: Only the client with the RSA private key can decrypt the response
- **Limited Attack Surface**: Short timeout window minimizes QR code interception risks
More information about the mobile login flow can be found in the [Architecture Documentation](https://docs.aliasvault.net/architecture/#6-login-with-mobile).

View File

@@ -15,6 +15,7 @@
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
@@ -30,6 +31,7 @@
"@types/chrome": "^0.0.280",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.13.10",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",
@@ -2116,6 +2118,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
@@ -2973,7 +2985,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4101,7 +4112,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -4114,7 +4124,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -4428,6 +4437,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
@@ -4571,6 +4589,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -6000,7 +6024,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -6810,7 +6833,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8701,6 +8723,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz",
@@ -8830,7 +8861,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8990,6 +9020,15 @@
"pathe": "^2.0.3"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -9291,6 +9330,191 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
@@ -9647,12 +9871,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -9976,6 +10205,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
@@ -11900,6 +12135,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",

View File

@@ -32,6 +32,7 @@
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
@@ -47,6 +48,7 @@
"@types/chrome": "^0.0.280",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.13.10",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",

View File

@@ -112,7 +112,7 @@ const AppContent: React.FC<{
<PasskeyLayout>
{loadingOverlay}
{message && (
<p className="text-red-500 mb-4">{message}</p>
<p className="mb-4 text-red-500 dark:text-red-400 text-sm">{message}</p>
)}
{routesComponent}
</PasskeyLayout>
@@ -135,8 +135,8 @@ const AppContent: React.FC<{
}}
>
{message && (
<div className="p-4">
<p className="text-red-500">{message}</p>
<div className="px-4 pt-0">
<p className="text-red-500 dark:text-red-400 text-sm">{message}</p>
</div>
)}
{routesComponent}

View File

@@ -0,0 +1,244 @@
import QRCode from 'qrcode';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MobileLoginErrorCode } from '@/entrypoints/popup/types/MobileLoginErrorCode';
import { MobileLoginUtility } from '@/entrypoints/popup/utils/MobileLoginUtility';
import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
import type { WebApiService } from '@/utils/WebApiService';
interface IMobileUnlockModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (result: MobileLoginResult) => Promise<void>;
webApi: WebApiService;
mode?: 'login' | 'unlock';
}
/**
* Modal component for mobile login/unlock via QR code scanning.
*/
const MobileUnlockModal: React.FC<IMobileUnlockModalProps> = ({
isOpen,
onClose,
onSuccess,
webApi,
mode = 'login'
}) => {
const { t } = useTranslation();
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [error, setError] = useState<MobileLoginErrorCode | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number>(120); // 2 minutes in seconds
const mobileLoginRef = useRef<MobileLoginUtility | null>(null);
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
/**
* Get translated error message for error code.
*/
const getErrorMessage = (errorCode: MobileLoginErrorCode): string => {
switch (errorCode) {
case MobileLoginErrorCode.TIMEOUT:
return t('auth.errors.mobileLoginRequestExpired');
case MobileLoginErrorCode.GENERIC:
default:
return t('common.errors.unknownError');
}
};
// Countdown timer effect
useEffect(() => {
if (qrCodeUrl && timeRemaining > 0 && isOpen) {
countdownIntervalRef.current = setInterval(() => {
setTimeRemaining(prev => {
if (prev <= 1) {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
return 0;
}
return prev - 1;
});
}, 1000);
return (): void => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
};
}
}, [qrCodeUrl, timeRemaining, isOpen]);
// Initialize mobile login when modal opens
useEffect(() => {
if (!isOpen) {
return;
}
/**
* Initialize mobile login on modal open.
*/
const initiateMobileLogin = async (): Promise<void> => {
try {
setError(null);
setQrCodeUrl(null);
setTimeRemaining(120);
// Initialize mobile login utility
if (!mobileLoginRef.current) {
mobileLoginRef.current = new MobileLoginUtility(webApi);
}
// Initiate mobile login and get QR code data
const requestId = await mobileLoginRef.current.initiate();
// Generate QR code with AliasVault prefix for mobile login
const qrData = `aliasvault://open/mobile-unlock/${requestId}`;
const qrDataUrl = await QRCode.toDataURL(qrData, {
width: 256,
margin: 2,
});
setQrCodeUrl(qrDataUrl);
// Start polling for response
await mobileLoginRef.current.startPolling(
async (result: MobileLoginResult) => {
try {
// Call success callback (parent handles loading state)
await onSuccess(result);
// Close modal after successful processing
handleClose();
} catch {
// Show error if success handler fails and hide QR code
setQrCodeUrl(null);
setError(MobileLoginErrorCode.GENERIC);
}
},
(errorCode) => {
// Hide QR code when error occurs
setQrCodeUrl(null);
setError(errorCode);
}
);
} catch (err) {
// err is a MobileLoginErrorCode thrown by initiate()
if (typeof err === 'string' && Object.values(MobileLoginErrorCode).includes(err as MobileLoginErrorCode)) {
setError(err as MobileLoginErrorCode);
} else {
setError(MobileLoginErrorCode.GENERIC);
}
}
};
initiateMobileLogin();
// Cleanup on unmount or when modal closes
return (): void => {
if (mobileLoginRef.current) {
mobileLoginRef.current.cleanup();
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
/**
* Handle modal close.
*/
const handleClose = (): void => {
if (mobileLoginRef.current) {
mobileLoginRef.current.cleanup();
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
setQrCodeUrl(null);
setError(null);
setTimeRemaining(120);
onClose();
};
/**
* Format time remaining as MM:SS.
*/
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (!isOpen) {
return null;
}
const title = mode === 'unlock' ? t('auth.unlockWithMobile') : t('auth.loginWithMobile');
const description = t('auth.scanQrCode');
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div className="fixed inset-0 bg-black bg-opacity-80 transition-opacity" onClick={handleClose} />
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-5 text-left shadow-xl transition-all w-full max-w-md">
{/* Close button */}
<button
type="button"
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={handleClose}
>
<span className="sr-only">{t('common.close')}</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Content */}
<div className="mt-3">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{description}
</p>
{error && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-400 text-sm">
{getErrorMessage(error)}
</div>
)}
{qrCodeUrl && (
<div className="flex flex-col items-center mb-4">
<img src={qrCodeUrl} alt="QR Code" className="border-4 border-gray-200 dark:border-gray-600 rounded mb-3" />
<div className="text-gray-700 dark:text-gray-300 text-sm font-medium">
{formatTime(timeRemaining)}
</div>
</div>
)}
{!qrCodeUrl && !error && (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)}
<button
type="button"
onClick={handleClose}
className="mt-4 w-full inline-flex justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{t('common.cancel')}
</button>
</div>
</div>
</div>
</div>
);
};
export default MobileUnlockModal;

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
@@ -21,6 +22,7 @@ import { AppInfo } from '@/utils/AppInfo';
import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
import { storage } from '#imports';
@@ -47,6 +49,7 @@ const Login: React.FC = () => {
const [twoFactorCode, setTwoFactorCode] = useState('');
const [clientUrl, setClientUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showMobileLoginModal, setShowMobileLoginModal] = useState(false);
const webApi = useWebApi();
const srpUtil = new SrpUtility(webApi);
@@ -272,6 +275,63 @@ const Login: React.FC = () => {
}
};
/**
* Handle successful mobile login
*/
const handleMobileLoginSuccess = async (result: MobileLoginResult): Promise<void> => {
showLoading();
try {
// Clear global message if set
app.clearGlobalMessage();
// Fetch vault from server with the new auth token
const vaultResponse = await webApi.authFetch<VaultResponse>('Vault', {
method: 'GET',
headers: {
'Authorization': `Bearer ${result.token}`,
},
});
// Store auth tokens and username
await app.setAuthTokens(result.username, result.token, result.refreshToken);
// Store the encryption key and derivation params
await dbContext.storeEncryptionKey(result.decryptionKey);
await dbContext.storeEncryptionKeyDerivationParams({
salt: result.salt,
encryptionType: result.encryptionType,
encryptionSettings: result.encryptionSettings,
});
// Initialize the database with the vault data
const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey);
// Check for pending migrations
try {
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
setIsInitialLoading(false);
return;
}
} catch (err) {
await app.logout();
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
hideLoading();
return;
}
// Navigate to reinitialize page
hideLoading();
setIsInitialLoading(false);
navigate('/reinitialize', { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
hideLoading();
throw err; // Re-throw to let modal show error
}
};
/**
* Handle change
*/
@@ -330,7 +390,7 @@ const Login: React.FC = () => {
}}
variant="secondary"
>
{t('auth.cancel')}
{t('common.cancel')}
</Button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
@@ -342,82 +402,113 @@ const Login: React.FC = () => {
}
return (
<div>
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
{error && (
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
{error}
<div className="flex flex-col items-center justify-center p-4">
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
{/* Title */}
<div className="text-center mb-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t('auth.loginTitle')}</h2>
<LoginServerInfo />
</div>
)}
<h2 className="text-xl font-bold dark:text-gray-200">{t('auth.loginTitle')}</h2>
<LoginServerInfo />
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 font-bold mb-2" htmlFor="username">
{t('auth.username')}
</label>
<input
className="shadow text-sm appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
name="username"
placeholder={t('auth.usernamePlaceholder')}
value={credentials.username}
onChange={handleChange}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 font-bold mb-2" htmlFor="password">
{t('auth.password')}
</label>
<div className="relative">
{/* Error Message */}
{error && (
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 font-medium mb-2" htmlFor="username">
{t('auth.username')}
</label>
<input
className="shadow text-sm appearance-none border rounded w-full py-2 px-3 pr-10 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type={showPassword ? "text" : "password"}
name="password"
placeholder={t('auth.passwordPlaceholder')}
value={credentials.password}
className="shadow appearance-none border rounded-lg w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-700 dark:border-gray-600 leading-tight focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
id="username"
type="text"
name="username"
placeholder={t('auth.usernamePlaceholder')}
value={credentials.username}
onChange={handleChange}
required
/>
<button
type="button"
className="absolute right-2 top-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
<HeaderIcon type={showPassword ? HeaderIconType.EYE_OFF : HeaderIconType.EYE} className="w-5 h-5 text-gray-400 dark:text-gray-500" />
</button>
</div>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">{t('auth.rememberMe')}</span>
</label>
</div>
<div className="flex w-full">
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 font-medium mb-2" htmlFor="password">
{t('auth.password')}
</label>
<div className="relative">
<input
className="shadow appearance-none border rounded-lg w-full py-2 px-3 pr-10 text-gray-700 dark:text-gray-200 dark:bg-gray-700 dark:border-gray-600 leading-tight focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
id="password"
type={showPassword ? "text" : "password"}
name="password"
placeholder={t('auth.passwordPlaceholder')}
value={credentials.password}
onChange={handleChange}
required
/>
<button
type="button"
className="absolute right-2 top-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
<HeaderIcon type={showPassword ? HeaderIconType.EYE_OFF : HeaderIconType.EYE} className="w-5 h-5 text-gray-400 dark:text-gray-500" />
</button>
</div>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">{t('auth.rememberMe')}</span>
</label>
</div>
<Button type="submit">
{t('auth.loginButton')}
<div className="flex items-center justify-center gap-2">
{t('auth.loginButton')}
</div>
</Button>
</div>
</form>
<div className="text-center text-gray-600 dark:text-gray-400">
{t('auth.noAccount')}{' '}
<a
href={clientUrl ?? ''}
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500"
>
{t('auth.createVault')}
</a>
{/* Mobile Login Button */}
<button
type="button"
onClick={() => setShowMobileLoginModal(true)}
className="w-full max-w-md mt-4 px-4 py-2 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-600 dark:text-white dark:border-gray-500 dark:hover:bg-gray-500 dark:focus:ring-gray-700 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
{t('auth.loginWithMobile')}
</button>
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
{t('auth.noAccount')}{' '}
<a
href={clientUrl ?? ''}
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500"
>
{t('auth.createVault')}
</a>
</div>
</form>
{/* Mobile Login Modal */}
<MobileUnlockModal
isOpen={showMobileLoginModal}
onClose={() => setShowMobileLoginModal(false)}
onSuccess={handleMobileLoginSuccess}
webApi={webApi}
mode="login"
/>
</div>
</div>
);

View File

@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import AlertMessage from '@/entrypoints/popup/components/AlertMessage';
import Button from '@/entrypoints/popup/components/Button';
import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import UsernameAvatar from '@/entrypoints/popup/components/Unlock/UsernameAvatar';
@@ -31,6 +32,7 @@ import {
unlockWithPin
} from '@/utils/PinUnlockService';
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
import { storage } from '#imports';
@@ -71,6 +73,9 @@ const Unlock: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
// Mobile unlock state
const [showMobileUnlockModal, setShowMobileUnlockModal] = useState(false);
/**
* Make status call to API which acts as health check.
* This runs only once during component mount.
@@ -356,6 +361,59 @@ const Unlock: React.FC = () => {
app.logout();
};
/**
* Handle successful mobile unlock
*/
const handleMobileUnlockSuccess = async (result: MobileLoginResult): Promise<void> => {
showLoading();
try {
// Revoke current tokens before setting new ones (since we're already logged in)
await webApi.revokeTokens();
// Set new auth tokens
await authContext.setAuthTokens(result.username, result.token, result.refreshToken);
// Fetch vault from server with the new auth token
const vaultResponse = await webApi.get<VaultResponse>('Vault');
// Store the encryption key and derivation params
await dbContext.storeEncryptionKey(result.decryptionKey);
await dbContext.storeEncryptionKeyDerivationParams({
salt: result.salt,
encryptionType: result.encryptionType,
encryptionSettings: result.encryptionSettings,
});
// Initialize the database with the vault data
const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey);
// Check if there are pending migrations
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
return;
}
// Clear dismiss until
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
// Reset PIN failed attempts on successful unlock
await resetFailedAttempts();
navigate('/reinitialize', { replace: true });
} catch (err) {
// Check if it's a version incompatibility error
if (err instanceof VaultVersionIncompatibleError) {
await app.logout(err.message);
} else {
setError(t('common.errors.unknownErrorTryAgain'));
}
console.error('Mobile unlock error:', err);
} finally {
hideLoading();
}
};
/**
* Switch to password mode
*/
@@ -524,6 +582,18 @@ const Unlock: React.FC = () => {
{t('auth.unlockVault')}
</Button>
{/* Mobile Unlock Button */}
<button
type="button"
onClick={() => setShowMobileUnlockModal(true)}
className="w-full max-w-md mt-4 px-4 py-2 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-600 dark:text-white dark:border-gray-500 dark:hover:bg-gray-500 dark:focus:ring-gray-700 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
{t('auth.unlockWithMobile')}
</button>
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
{t('auth.switchAccounts')} <button type="button" onClick={handleLogout} className="text-primary-600 hover:text-primary-700 dark:text-primary-500 dark:hover:text-primary-400 hover:underline font-medium">{t('auth.logout')}</button>
</div>
@@ -535,6 +605,15 @@ const Unlock: React.FC = () => {
<button type="button" onClick={switchToPin} className="text-primary-600 hover:text-primary-700 dark:text-primary-500 dark:hover:text-primary-400 hover:underline font-medium">{t('auth.unlockWithPin')}</button>
</div>
)}
{/* Mobile Unlock Modal */}
<MobileUnlockModal
isOpen={showMobileUnlockModal}
onClose={() => setShowMobileUnlockModal(false)}
onSuccess={handleMobileUnlockSuccess}
webApi={webApi}
mode="unlock"
/>
</div>
);
};

View File

@@ -176,381 +176,7 @@ const Settings: React.FC = () => {
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">{t('settings.title')}</h2>
</div>
{/* User Menu Section */}
<section>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span className="text-primary-600 dark:text-primary-400 text-lg font-medium">
{app.username?.[0]?.toUpperCase() || '?'}
</span>
</div>
</div>
<div>
<p className="text font-medium text-gray-900 dark:text-white">
{app.username}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('settings.loggedIn')}
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleLock}
title={t('settings.lock')}
className="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-label={t('settings.lock')}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</button>
<button
onClick={() => setShowLogoutConfirm(true)}
title={t('settings.logout')}
className="p-2 bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 rounded-md transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-label={t('settings.logout')}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
</section>
{/* Settings Navigation Section */}
<section>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{/* Vault Unlock Method */}
<button
onClick={navigateToUnlockMethodSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.unlockMethod.title')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Auto-lock Settings */}
<button
onClick={navigateToAutoLockSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.autoLockTimeout')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Autofill Settings */}
<button
onClick={navigateToAutofillSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.autofillSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Passkey Settings */}
<button
onClick={navigateToPasskeySettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.passkeySettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</section>
{/* Additional Settings Section */}
<section>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{/* Clipboard Settings */}
<button
onClick={navigateToClipboardSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.clipboardSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Context Menu Settings */}
<button
onClick={navigateToContextMenuSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 6h16M4 12h16m-7 6h7"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.contextMenuSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Language Settings */}
<button
onClick={navigateToLanguageSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.language')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</section>
{/* Appearance Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.appearance')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<p className="font-medium text-gray-900 dark:text-white mb-2">{t('settings.theme')}</p>
<div className="flex flex-col space-y-2">
<label className="flex items-center">
<input
type="radio"
name="theme"
value="system"
checked={theme === 'system'}
onChange={() => setThemePreference('system')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{t('settings.useDefault')}</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="theme"
value="light"
checked={theme === 'light'}
onChange={() => setThemePreference('light')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{t('settings.light')}</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="theme"
value="dark"
checked={theme === 'dark'}
onChange={() => setThemePreference('dark')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{t('settings.dark')}</span>
</label>
</div>
</div>
</div>
</div>
</section>
{/* Keyboard Shortcuts Section */}
{import.meta.env.CHROME && (
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.keyboardShortcuts')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-white">{t('settings.configureKeyboardShortcuts')}</p>
</div>
<button
onClick={openKeyboardShortcuts}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-md transition-colors"
>
{t('settings.configure')}
</button>
</div>
</div>
</div>
</section>
)}
<div className="text-center text-gray-400 dark:text-gray-600">
{t('settings.versionPrefix')}{AppInfo.VERSION} ({getDisplayUrl()})
</div>
<>
{/* Logout Confirmation Modal */}
{showLogoutConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@@ -578,7 +204,382 @@ const Settings: React.FC = () => {
</div>
</div>
)}
</div>
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">{t('settings.title')}</h2>
</div>
{/* User Menu Section */}
<section>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span className="text-primary-600 dark:text-primary-400 text-lg font-medium">
{app.username?.[0]?.toUpperCase() || '?'}
</span>
</div>
</div>
<div>
<p className="text font-medium text-gray-900 dark:text-white">
{app.username}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('settings.loggedIn')}
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleLock}
title={t('settings.lock')}
className="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-label={t('settings.lock')}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</button>
<button
onClick={() => setShowLogoutConfirm(true)}
title={t('settings.logout')}
className="p-2 bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 rounded-md transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-label={t('settings.logout')}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
</section>
{/* Settings Navigation Section */}
<section>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{/* Vault Unlock Method */}
<button
onClick={navigateToUnlockMethodSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.unlockMethod.title')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Auto-lock Settings */}
<button
onClick={navigateToAutoLockSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.autoLockTimeout')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Autofill Settings */}
<button
onClick={navigateToAutofillSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.autofillSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Passkey Settings */}
<button
onClick={navigateToPasskeySettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.passkeySettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</section>
{/* Additional Settings Section */}
<section>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{/* Clipboard Settings */}
<button
onClick={navigateToClipboardSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.clipboardSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Context Menu Settings */}
<button
onClick={navigateToContextMenuSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 6h16M4 12h16m-7 6h7"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.contextMenuSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Language Settings */}
<button
onClick={navigateToLanguageSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
<span className="text-gray-900 dark:text-white">{t('settings.language')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</section>
{/* Appearance Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.appearance')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<p className="font-medium text-gray-900 dark:text-white mb-2">{t('settings.theme')}</p>
<div className="flex flex-col space-y-2">
<label className="flex items-center">
<input
type="radio"
name="theme"
value="system"
checked={theme === 'system'}
onChange={() => setThemePreference('system')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{t('settings.useDefault')}</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="theme"
value="light"
checked={theme === 'light'}
onChange={() => setThemePreference('light')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{t('settings.light')}</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="theme"
value="dark"
checked={theme === 'dark'}
onChange={() => setThemePreference('dark')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{t('settings.dark')}</span>
</label>
</div>
</div>
</div>
</div>
</section>
{/* Keyboard Shortcuts Section */}
{import.meta.env.CHROME && (
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.keyboardShortcuts')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-white">{t('settings.configureKeyboardShortcuts')}</p>
</div>
<button
onClick={openKeyboardShortcuts}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-md transition-colors"
>
{t('settings.configure')}
</button>
</div>
</div>
</div>
</section>
)}
<div className="text-center text-gray-400 dark:text-gray-600">
{t('settings.versionPrefix')}{AppInfo.VERSION} ({getDisplayUrl()})
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,15 @@
/**
* Error codes for mobile login operations.
* These codes are used to provide translatable error messages to users.
*/
export enum MobileLoginErrorCode {
/**
* The mobile login request has timed out after 2 minutes.
*/
TIMEOUT = 'TIMEOUT',
/**
* A generic error occurred during mobile login.
*/
GENERIC = 'GENERIC',
}

View File

@@ -0,0 +1,201 @@
import { Buffer } from 'buffer';
import { MobileLoginErrorCode } from '@/entrypoints/popup/types/MobileLoginErrorCode';
import type { LoginResponse, MobileLoginInitiateResponse, MobileLoginPollResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
import type { WebApiService } from '@/utils/WebApiService';
/**
* Utility class for mobile login operations
*/
export class MobileLoginUtility {
private webApi: WebApiService;
private pollingInterval: NodeJS.Timeout | null = null;
private requestId: string | null = null;
private privateKey: string | null = null;
/**
* Constructor for the MobileLoginUtility class.
*
* @param {WebApiService} webApi - The WebApiService instance.
*/
public constructor(webApi: WebApiService) {
this.webApi = webApi;
}
/**
* Initiates a mobile login request and returns the QR code data
* @throws {MobileLoginErrorCode} If initiation fails
*/
public async initiate(): Promise<string> {
try {
// Generate RSA key pair
const keyPair = await EncryptionUtility.generateRsaKeyPair();
this.privateKey = keyPair.privateKey;
// Send public key to server (no auth required)
const response = await this.webApi.rawFetch('auth/mobile-login/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientPublicKey: keyPair.publicKey,
}),
});
if (!response.ok) {
throw MobileLoginErrorCode.GENERIC;
}
const data = await response.json() as MobileLoginInitiateResponse;
this.requestId = data.requestId;
// Return QR code data (request ID)
return this.requestId;
} catch (error) {
if (typeof error === 'string' && Object.values(MobileLoginErrorCode).includes(error as MobileLoginErrorCode)) {
throw error;
}
throw MobileLoginErrorCode.GENERIC;
}
}
/**
* Starts polling the server for mobile login response
*/
public async startPolling(
onSuccess: (result: MobileLoginResult) => void,
onError: (errorCode: MobileLoginErrorCode) => void
): Promise<void> {
if (!this.requestId || !this.privateKey) {
throw new Error('Must call initiate() before starting polling');
}
/**
* Polls the server for mobile login response
*/
const pollFn = async (): Promise<void> => {
try {
if (!this.requestId) {
this.stopPolling();
return;
}
const response = await this.webApi.rawFetch(
`auth/mobile-login/poll/${this.requestId}`,
{
method: 'GET',
}
);
if (!response.ok) {
if (response.status === 404) {
// Request expired or not found
this.stopPolling();
this.privateKey = null;
this.requestId = null;
onError(MobileLoginErrorCode.TIMEOUT);
return;
}
throw new Error(`Polling failed: ${response.status}`);
}
const data = await response.json() as MobileLoginPollResponse;
if (data.fulfilled && data.encryptedSymmetricKey) {
// Stop polling
this.stopPolling();
// Decrypt the encrypted decryption key with RSA private key
const decryptionKeyBytes = await EncryptionUtility.decryptWithPrivateKey(data.encryptedDecryptionKey!, this.privateKey!);
const decryptionKey = Buffer.from(decryptionKeyBytes).toString('base64');
// Decrypt the other encrypted fields with the symmetric key
const symmetricKeyBytes = await EncryptionUtility.decryptWithPrivateKey(data.encryptedSymmetricKey, this.privateKey!);
const symmetricKey = Buffer.from(symmetricKeyBytes).toString('base64');
const token = await EncryptionUtility.symmetricDecrypt(data.encryptedToken!, symmetricKey);
const refreshToken = await EncryptionUtility.symmetricDecrypt(data.encryptedRefreshToken!, symmetricKey);
const username = await EncryptionUtility.symmetricDecrypt(data.encryptedUsername!, symmetricKey);
// Clear sensitive data
this.privateKey = null;
this.requestId = null;
// Call /login endpoint with username to get salt and encryption settings
const loginResponse = await this.webApi.rawFetch('auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
}),
});
if (!loginResponse.ok) {
onError(MobileLoginErrorCode.GENERIC);
return;
}
const loginData = await loginResponse.json() as LoginResponse;
// Create result object using the MobileLoginResult type
const result: MobileLoginResult = {
username: username,
token: token,
refreshToken: refreshToken,
decryptionKey: decryptionKey,
salt: loginData.salt,
encryptionType: loginData.encryptionType,
encryptionSettings: loginData.encryptionSettings,
};
// Call success callback with result object
onSuccess(result);
}
} catch {
this.stopPolling();
this.privateKey = null;
this.requestId = null;
onError(MobileLoginErrorCode.GENERIC);
}
};
// Poll every 3 seconds
this.pollingInterval = setInterval(pollFn, 3000);
// Stop polling after 3.5 minutes (adds 1.5 minute buffer to default 2 minute timer for edge cases)
setTimeout(() => {
if (this.pollingInterval) {
this.stopPolling();
this.privateKey = null;
this.requestId = null;
onError(MobileLoginErrorCode.TIMEOUT);
}
}, 210000);
}
/**
* Stops polling the server
*/
public stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
/**
* Cleans up resources
*/
public cleanup(): void {
this.stopPolling();
this.privateKey = null;
this.requestId = null;
}
}

View File

@@ -6,17 +6,16 @@
"password": "Password",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"loginButton": "Login",
"loginButton": "Log in",
"noAccount": "No account yet?",
"createVault": "Create new vault",
"twoFactorTitle": "Please enter the authentication code from your authenticator app.",
"authCode": "Authentication Code",
"authCodePlaceholder": "Enter 6-digit code",
"verify": "Verify",
"cancel": "Cancel",
"twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"masterPassword": "Master Password",
"unlockVault": "Unlock Vault",
"unlockVault": "Unlock",
"unlockWithPin": "Unlock with PIN",
"enterPinToUnlock": "Enter your PIN to unlock your vault",
"useMasterPassword": "Use Master Password",
@@ -30,13 +29,17 @@
"connectingTo": "Connecting to",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"loginWithMobile": "Log in using Mobile App",
"unlockWithMobile": "Unlock using Mobile App",
"scanQrCode": "Scan this QR code with your AliasVault mobile app to log in and unlock your vault.",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"sessionExpired": "Your session has expired. Please log in again."
"sessionExpired": "Your session has expired. Please log in again.",
"mobileLoginRequestExpired": "Mobile login request timed out. Please reload the page and try again."
}
},
"menu": {
@@ -95,6 +98,7 @@
"clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.",
"browserExtensionOutdated": "This browser extension is outdated and cannot be used to access this vault. Please update this browser extension to continue.",
"serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.",
"serverVersionTooOld": "The AliasVault server needs to be updated to a newer version in order to use this feature. Please contact the server admin if you need help.",
"unknownError": "An unknown error occurred",
"unknownErrorTryAgain": "An unknown error occurred. Please try again.",
"vaultNotAvailable": "Vault not available",

View File

@@ -103,15 +103,19 @@ type ValidateLoginRequest2Fa = {
clientPublicEphemeral: string;
clientSessionProof: string;
};
/**
* Token model type.
*/
type TokenModel = {
token: string;
refreshToken: string;
};
/**
* Validate login response type.
*/
type ValidateLoginResponse = {
requiresTwoFactor: boolean;
token?: {
token: string;
refreshToken: string;
};
token?: TokenModel;
serverSessionProof: string;
};
@@ -350,6 +354,10 @@ declare enum AuthEventType {
* Represents a user logout event.
*/
Logout = 3,
/**
* Represents a mobile login attempt (login via QR code from mobile app).
*/
MobileLogin = 4,
/**
* Represents JWT access token refresh event issued by client to API.
*/
@@ -380,4 +388,35 @@ declare enum AuthEventType {
AccountDeletion = 99
}
export { type ApiErrorResponse, AuthEventType, type AuthLogModel, type BadRequestResponse, type DeleteAccountInitiateRequest, type DeleteAccountInitiateResponse, type DeleteAccountRequest, type Email, type EmailAttachment, type FaviconExtractModel, type LoginRequest, type LoginResponse, type MailboxBulkRequest, type MailboxBulkResponse, type MailboxEmail, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse };
/**
* Mobile login initiate request type.
*/
type MobileLoginInitiateRequest = {
clientPublicKey: string;
};
/**
* Mobile login initiate response type.
*/
type MobileLoginInitiateResponse = {
requestId: string;
};
/**
* Mobile login submit request type.
*/
type MobileLoginSubmitRequest = {
requestId: string;
encryptedDecryptionKey: string;
};
/**
* Mobile login poll response type.
*/
type MobileLoginPollResponse = {
fulfilled: boolean;
encryptedSymmetricKey: string | null;
encryptedToken: string | null;
encryptedRefreshToken: string | null;
encryptedDecryptionKey: string | null;
encryptedUsername: string | null;
};
export { type ApiErrorResponse, AuthEventType, type AuthLogModel, type BadRequestResponse, type DeleteAccountInitiateRequest, type DeleteAccountInitiateResponse, type DeleteAccountRequest, type Email, type EmailAttachment, type FaviconExtractModel, type LoginRequest, type LoginResponse, type MailboxBulkRequest, type MailboxBulkResponse, type MailboxEmail, type MobileLoginInitiateRequest, type MobileLoginInitiateResponse, type MobileLoginPollResponse, type MobileLoginSubmitRequest, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type TokenModel, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse };

View File

@@ -7,6 +7,7 @@ var AuthEventType = /* @__PURE__ */ ((AuthEventType2) => {
AuthEventType2[AuthEventType2["Login"] = 1] = "Login";
AuthEventType2[AuthEventType2["TwoFactorAuthentication"] = 2] = "TwoFactorAuthentication";
AuthEventType2[AuthEventType2["Logout"] = 3] = "Logout";
AuthEventType2[AuthEventType2["MobileLogin"] = 4] = "MobileLogin";
AuthEventType2[AuthEventType2["TokenRefresh"] = 10] = "TokenRefresh";
AuthEventType2[AuthEventType2["PasswordReset"] = 20] = "PasswordReset";
AuthEventType2[AuthEventType2["PasswordChange"] = 21] = "PasswordChange";

View File

@@ -0,0 +1,39 @@
/**
* Result of a successful mobile login containing decrypted authentication data.
*/
export type MobileLoginResult = {
/**
* The username.
*/
username: string;
/**
* The JWT access token.
*/
token: string;
/**
* The refresh token.
*/
refreshToken: string;
/**
* The vault decryption key (base64 encoded).
*/
decryptionKey: string;
/**
* The user's salt for key derivation.
*/
salt: string;
/**
* The encryption type (e.g., "Argon2id").
*/
encryptionType: string;
/**
* The encryption settings JSON string.
*/
encryptionSettings: string;
}

View File

@@ -149,7 +149,7 @@ class MainActivity : ReactActivity() {
try {
vaultStore.storeEncryptionKey(encryptionKeyBase64)
vaultStore.unlockVault()
promise.resolve(null)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("UNLOCK_ERROR", "Failed to unlock vault: ${e.message}", e)
}

View File

@@ -398,7 +398,7 @@ class AutofillService : AutofillService() {
val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
// Create deep link URL
val deepLinkUrl = "net.aliasvault.app://credentials/add-edit-page?serviceUrl=$encodedUrl"
val deepLinkUrl = "aliasvault://credentials/add-edit-page?serviceUrl=$encodedUrl"
// Add a click listener to open AliasVault app with deep link
val intent = Intent(Intent.ACTION_VIEW).apply {
@@ -477,7 +477,7 @@ class AutofillService : AutofillService() {
val dataSetBuilder = Dataset.Builder(presentation)
// Create deep link URL
val deepLinkUrl = "net.aliasvault.app://reinitialize"
val deepLinkUrl = "aliasvault://reinitialize"
// Add a click listener to open AliasVault app with deep link
val intent = Intent(Intent.ACTION_VIEW).apply {
@@ -526,7 +526,7 @@ class AutofillService : AutofillService() {
val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
// Create deep link URL to credentials page with service URL
val deepLinkUrl = "net.aliasvault.app://credentials?serviceUrl=$encodedUrl"
val deepLinkUrl = "aliasvault://credentials?serviceUrl=$encodedUrl"
// Add a click listener to open AliasVault app with deep link
val intent = Intent(Intent.ACTION_VIEW).apply {
@@ -569,7 +569,7 @@ class AutofillService : AutofillService() {
// Create deep link URL to open the credentials page
val appInfo = fieldFinder.getAppInfo()
val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
val deepLinkUrl = "net.aliasvault.app://credentials?serviceUrl=$encodedUrl"
val deepLinkUrl = "aliasvault://credentials?serviceUrl=$encodedUrl"
// Add a click listener to open AliasVault app with deep link
val intent = Intent(Intent.ACTION_VIEW).apply {

View File

@@ -284,6 +284,26 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
}
}
/**
* Encrypt the decryption key for mobile login.
* @param publicKeyJWK The public key in JWK format
* @param promise The promise to resolve
*/
@ReactMethod
override fun encryptDecryptionKeyForMobileLogin(publicKeyJWK: String, promise: Promise) {
try {
val encryptedKey = vaultStore.encryptDecryptionKeyForMobileLogin(publicKeyJWK)
promise.resolve(encryptedKey)
} catch (e: Exception) {
Log.e(TAG, "Error encrypting key for mobile login", e)
promise.reject(
"ERR_ENCRYPT_KEY_MOBILE_LOGIN",
"Failed to encrypt key for mobile login: ${e.message}",
e,
)
}
}
/**
* Check if the encrypted database exists.
* @param promise The promise to resolve
@@ -1163,6 +1183,24 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
}
}
// MARK: - Server Version Management
/**
* Check if the stored server version is greater than or equal to the specified version.
* @param targetVersion The version to compare against (e.g., "0.25.0")
* @param promise The promise to resolve.
*/
@ReactMethod
override fun isServerVersionGreaterThanOrEqualTo(targetVersion: String, promise: Promise) {
try {
val isGreaterOrEqual = vaultStore.metadata.isServerVersionGreaterThanOrEqualTo(targetVersion)
promise.resolve(isGreaterOrEqual)
} catch (e: Exception) {
Log.e(TAG, "Error comparing server version", e)
promise.reject("ERR_COMPARE_SERVER_VERSION", "Failed to compare server version: ${e.message}", e)
}
}
// MARK: - Offline Mode Management
/**
@@ -1388,4 +1426,63 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
val intent = Intent(activity, net.aliasvault.app.pinunlock.PinUnlockActivity::class.java)
activity.startActivityForResult(intent, PIN_UNLOCK_REQUEST_CODE)
}
/**
* Authenticate the user using biometric or PIN unlock.
* This method automatically detects which authentication method is enabled and uses it.
* Returns true if authentication succeeded, false otherwise.
*
* @param title The title for authentication. If null or empty, uses default.
* @param subtitle The subtitle for authentication. If null or empty, uses default.
* @param promise The promise to resolve with authentication result.
*/
@ReactMethod
override fun authenticateUser(title: String?, subtitle: String?, promise: Promise) {
CoroutineScope(Dispatchers.Main).launch {
try {
// Check if PIN is enabled first
val pinEnabled = vaultStore.isPinEnabled()
if (pinEnabled) {
// PIN is enabled, show PIN unlock UI
try {
// Store promise for later resolution by MainActivity
pendingActivityResultPromise = promise
// Launch PIN unlock activity
val activity = currentActivity
if (activity == null) {
promise.reject("NO_ACTIVITY", "No activity available", null)
return@launch
}
val intent = Intent(activity, net.aliasvault.app.pinunlock.PinUnlockActivity::class.java)
// Add custom title/subtitle if provided
if (!title.isNullOrEmpty()) {
intent.putExtra(net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_CUSTOM_TITLE, title)
}
if (!subtitle.isNullOrEmpty()) {
intent.putExtra(net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_CUSTOM_SUBTITLE, subtitle)
}
activity.startActivityForResult(intent, PIN_UNLOCK_REQUEST_CODE)
} catch (e: Exception) {
Log.e(TAG, "PIN authentication failed", e)
promise.reject("AUTH_ERROR", "PIN authentication failed: ${e.message}", e)
}
} else {
// Use biometric authentication
try {
val authenticated = vaultStore.issueBiometricAuthentication(title)
promise.resolve(authenticated)
} catch (e: Exception) {
Log.e(TAG, "Biometric authentication failed", e)
promise.resolve(false)
}
}
} catch (e: Exception) {
Log.e(TAG, "Authentication failed", e)
promise.reject("AUTH_ERROR", "Authentication failed: ${e.message}", e)
}
}
}
}

View File

@@ -64,6 +64,12 @@ class PinUnlockActivity : AppCompatActivity() {
/** Intent extra key for the encryption key to use during setup. */
const val EXTRA_SETUP_ENCRYPTION_KEY = "setup_encryption_key"
/** Intent extra key for custom title (optional). */
const val EXTRA_CUSTOM_TITLE = "custom_title"
/** Intent extra key for custom subtitle (optional). */
const val EXTRA_CUSTOM_SUBTITLE = "custom_subtitle"
/** Mode: Unlock vault with existing PIN. */
const val MODE_UNLOCK = "unlock"
@@ -113,9 +119,11 @@ class PinUnlockActivity : AppCompatActivity() {
else -> PinMode.UNLOCK
}
setupEncryptionKey = intent.getStringExtra(EXTRA_SETUP_ENCRYPTION_KEY)
val customTitle = intent.getStringExtra(EXTRA_CUSTOM_TITLE)
val customSubtitle = intent.getStringExtra(EXTRA_CUSTOM_SUBTITLE)
// Initialize configuration
configuration = viewModel.initializeConfiguration(mode)
configuration = viewModel.initializeConfiguration(mode, customTitle, customSubtitle)
// Initialize views
initializeViews()

View File

@@ -17,23 +17,30 @@ class PinViewModel(
) {
/**
* Initialize PIN configuration based on mode.
* @param mode The PIN mode (unlock or setup)
* @param customTitle Optional custom title to override default
* @param customSubtitle Optional custom subtitle to override default
*/
fun initializeConfiguration(mode: PinMode): PinConfiguration {
fun initializeConfiguration(
mode: PinMode,
customTitle: String? = null,
customSubtitle: String? = null,
): PinConfiguration {
return when (mode) {
PinMode.UNLOCK -> {
val pinLength = vaultStore.getPinLength()
PinConfiguration(
mode = PinMode.UNLOCK,
title = context.getString(R.string.pin_unlock_vault),
subtitle = context.getString(R.string.pin_enter_to_unlock),
title = customTitle ?: context.getString(R.string.pin_unlock_vault),
subtitle = customSubtitle ?: context.getString(R.string.pin_enter_to_unlock),
pinLength = pinLength,
)
}
PinMode.SETUP -> {
PinConfiguration(
mode = PinMode.SETUP,
title = context.getString(R.string.pin_setup_title),
subtitle = context.getString(R.string.pin_setup_description),
title = customTitle ?: context.getString(R.string.pin_setup_title),
subtitle = customSubtitle ?: context.getString(R.string.pin_setup_description),
pinLength = null, // Allow any length from 4-8
setupStep = PinSetupStep.ENTER_NEW,
)

View File

@@ -10,6 +10,7 @@ import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback
import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreProvider
import net.aliasvault.app.vaultstore.storageprovider.StorageProvider
import org.json.JSONObject
import java.math.BigInteger
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
@@ -250,4 +251,63 @@ class VaultCrypto(
}
// endregion
// region Mobile Login
/**
* Encrypts the vault's encryption key using an RSA public key for mobile login.
*/
fun encryptDecryptionKeyForMobileLogin(publicKeyJWK: String, authMethods: String): String {
var result: String? = null
var error: Exception? = null
val latch = java.util.concurrent.CountDownLatch(1)
getEncryptionKey(
object : CryptoOperationCallback {
override fun onSuccess(key: String) {
try {
val keyBytes = Base64.decode(key, Base64.NO_WRAP)
result = encryptWithPublicKey(keyBytes, publicKeyJWK)
} catch (e: Exception) {
error = e
Log.e(TAG, "Error encrypting key for mobile login", e)
} finally {
latch.countDown()
}
}
override fun onError(e: Exception) {
error = e
Log.e(TAG, "Error getting encryption key", e)
latch.countDown()
}
},
authMethods,
)
latch.await()
error?.let { throw it }
return result ?: throw Exception("Failed to encrypt key for mobile login")
}
private fun encryptWithPublicKey(data: ByteArray, publicKeyJWK: String): String {
val jwk = JSONObject(publicKeyJWK)
val nStr = jwk.getString("n")
val eStr = jwk.getString("e")
val modulus = BigInteger(1, Base64.decode(nStr, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
val exponent = BigInteger(1, Base64.decode(eStr, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
val keySpec = java.security.spec.RSAPublicKeySpec(modulus, exponent)
val keyFactory = java.security.KeyFactory.getInstance("RSA")
val publicKey = keyFactory.generatePublic(keySpec)
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
val encryptedBytes = cipher.doFinal(data)
return Base64.encodeToString(encryptedBytes, Base64.NO_WRAP)
}
// endregion
}

View File

@@ -3,6 +3,7 @@ package net.aliasvault.app.vaultstore
import android.util.Log
import net.aliasvault.app.vaultstore.models.VaultMetadata
import net.aliasvault.app.vaultstore.storageprovider.StorageProvider
import net.aliasvault.app.vaultstore.utils.VersionComparison
import org.json.JSONArray
import org.json.JSONObject
@@ -103,6 +104,41 @@ class VaultMetadataManager(
// endregion
// region Server Version Management
/**
* Set the server API version.
*/
fun setServerVersion(version: String) {
storageProvider.setServerVersion(version)
}
/**
* Get the server API version.
*/
fun getServerVersion(): String? {
return storageProvider.getServerVersion()
}
/**
* Clear the server version.
*/
fun clearServerVersion() {
storageProvider.clearServerVersion()
}
/**
* Check if the stored server version is greater than or equal to the specified version.
* @param targetVersion The version to compare against (e.g., "0.25.0")
* @return true if stored server version >= targetVersion, false if server version not available or less than target
*/
fun isServerVersionGreaterThanOrEqualTo(targetVersion: String): Boolean {
val serverVersion = getServerVersion() ?: return false // No server version stored yet
return VersionComparison.isGreaterThanOrEqualTo(serverVersion, targetVersion)
}
// endregion
// region Internal Helpers
/**

View File

@@ -2,12 +2,16 @@ package net.aliasvault.app.vaultstore
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.requery.android.database.sqlite.SQLiteDatabase
import kotlinx.coroutines.suspendCancellableCoroutine
import net.aliasvault.app.vaultstore.interfaces.CredentialOperationCallback
import net.aliasvault.app.vaultstore.interfaces.CryptoOperationCallback
import net.aliasvault.app.vaultstore.keystoreprovider.BiometricAuthCallback
import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreProvider
import net.aliasvault.app.vaultstore.models.Credential
import net.aliasvault.app.vaultstore.storageprovider.StorageProvider
import kotlin.coroutines.resume
/**
* The vault store that manages the encrypted vault and all input/output operations on it.
@@ -65,7 +69,7 @@ class VaultStore(
private val crypto = VaultCrypto(keystoreProvider, storageProvider)
private val databaseComponent = VaultDatabase(storageProvider, crypto)
private val query = VaultQuery(databaseComponent)
private val metadata = VaultMetadataManager(storageProvider)
internal val metadata = VaultMetadataManager(storageProvider)
private val auth = VaultAuth(storageProvider) { cache.clearCache() }
private val sync = VaultSync(databaseComponent, metadata, crypto)
private val mutate = VaultMutate(databaseComponent, query, metadata)
@@ -196,6 +200,13 @@ class VaultStore(
return crypto.deriveKeyFromPassword(password, salt, encryptionType, encryptionSettings)
}
/**
* Encrypts the vault's encryption key using an RSA public key for mobile login.
*/
fun encryptDecryptionKeyForMobileLogin(publicKeyJWK: String): String {
return crypto.encryptDecryptionKeyForMobileLogin(publicKeyJWK, auth.getAuthMethods())
}
// endregion
// region Database Methods
@@ -619,4 +630,52 @@ class VaultStore(
}
// endregion
// region Re-authentication
/**
* Authenticate the user using biometric authentication only.
* Note: This method only handles biometric authentication.
* Returns true if authentication succeeded, false otherwise.
*
* @param title The title for authentication. Optional, defaults to "Unlock Vault".
* @return True if biometric authentication succeeded, false if authentication failed.
*/
suspend fun issueBiometricAuthentication(title: String?): Boolean {
// Use title if provided, otherwise default
val authReason = title?.takeIf { it.isNotEmpty() } ?: "Unlock Vault"
// Check if biometric authentication is enabled
val authMethods = auth.getAuthMethods()
val isBiometricEnabled = authMethods.contains("faceid")
if (!isBiometricEnabled) {
Log.e("VaultStore", "No authentication method enabled")
return false
}
// Check if biometric is available
if (!keystoreProvider.isBiometricAvailable()) {
Log.e("VaultStore", "Biometric authentication not available")
return false
}
// Trigger biometric authentication with a custom prompt
return suspendCancellableCoroutine { continuation ->
keystoreProvider.authenticateWithBiometric(
authReason,
object : BiometricAuthCallback {
override fun onSuccess() {
continuation.resume(true)
}
override fun onFailure() {
continuation.resume(false)
}
},
)
}
}
// endregion
}

View File

@@ -121,6 +121,9 @@ class VaultSync(
throw VaultSyncError.ServerVersionNotSupported()
}
// Store server version in metadata
metadata.setServerVersion(status.serverVersion)
validateSrpSalt(status.srpSalt)
return status
}

View File

@@ -356,4 +356,57 @@ class AndroidKeystoreProvider(
// Show biometric prompt
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
/**
* Trigger standalone biometric authentication (no key retrieval).
* This is used for re-authentication before sensitive operations.
* @param title The title to show in the biometric prompt
* @param callback The callback to handle the result
*/
override fun authenticateWithBiometric(title: String, callback: BiometricAuthCallback) {
val currentActivity = getCurrentActivity() as? FragmentActivity
if (currentActivity == null) {
Log.e(TAG, "Current activity is not a FragmentActivity")
callback.onFailure()
return
}
// Create BiometricPrompt
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
)
.build()
val biometricPrompt = BiometricPrompt(
currentActivity,
_executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) {
_mainHandler.post {
callback.onSuccess()
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
Log.e(TAG, "Biometric authentication error: $errString")
_mainHandler.post {
callback.onFailure()
}
}
override fun onAuthenticationFailed() {
// Don't call callback here, user can retry
Log.w(TAG, "Biometric authentication failed, user can retry")
}
},
)
// Show biometric prompt
biometricPrompt.authenticate(promptInfo)
}
}

View File

@@ -40,6 +40,29 @@ interface KeystoreProvider {
* Clear all stored keys.
*/
fun clearKeys()
/**
* Trigger standalone biometric authentication (no key retrieval).
* This is used for re-authentication before sensitive operations.
* @param title The title to show in the biometric prompt
* @param callback The callback to handle the result
*/
fun authenticateWithBiometric(title: String, callback: BiometricAuthCallback)
}
/**
* Callback interface for standalone biometric authentication.
*/
interface BiometricAuthCallback {
/**
* Called when authentication succeeds.
*/
fun onSuccess()
/**
* Called when authentication fails or is cancelled.
*/
fun onFailure()
}
/**

View File

@@ -28,4 +28,9 @@ class TestKeystoreProvider : KeystoreProvider {
override fun clearKeys() {
// Do nothing in test implementation
}
override fun authenticateWithBiometric(title: String, callback: BiometricAuthCallback) {
// Always fail in test implementation since biometrics are not available
callback.onFailure()
}
}

View File

@@ -131,4 +131,23 @@ class AndroidStorageProvider(private val context: Context) : StorageProvider {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getBoolean("offline_mode", false)
}
override fun setServerVersion(version: String) {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
putString("server_version", version)
}
}
override fun getServerVersion(): String? {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getString("server_version", null)
}
override fun clearServerVersion() {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
remove("server_version")
}
}
}

View File

@@ -106,4 +106,21 @@ interface StorageProvider {
* @return True if app is in offline mode, false otherwise
*/
fun getOfflineMode(): Boolean
/**
* Set the server API version.
* @param version The server version to store
*/
fun setServerVersion(version: String)
/**
* Get the server API version.
* @return The server version or null if not set
*/
fun getServerVersion(): String?
/**
* Clear the server version.
*/
fun clearServerVersion()
}

View File

@@ -15,6 +15,7 @@ class TestStorageProvider : StorageProvider {
private var tempAutoLockTimeout = defaultAutoLockTimeout
private var username: String? = null
private var offlineMode: Boolean = false
private var serverVersion: String? = null
override fun getEncryptedDatabaseFile(): File = tempFile
@@ -87,4 +88,16 @@ class TestStorageProvider : StorageProvider {
override fun getOfflineMode(): Boolean {
return offlineMode
}
override fun setServerVersion(version: String) {
serverVersion = version
}
override fun getServerVersion(): String? {
return serverVersion
}
override fun clearServerVersion() {
serverVersion = null
}
}

View File

@@ -13,9 +13,10 @@ import net.aliasvault.app.utils.AppInfo
*/
object VersionComparison {
/**
* Checks if version1 is greater than or equal to version2, following SemVer rules.
* Checks if version1 is greater than or equal to version2, ignoring pre-release suffixes.
*
* Pre-release versions (e.g., -alpha, -beta, -dev) are considered lower than release versions.
* Pre-release suffixes (e.g., -alpha, -beta, -dev) are stripped from version1 before comparison.
* This allows server versions like "0.25.0-alpha-dev" to be treated as "0.25.0".
*
* @param version1 First version string (e.g., "1.2.3" or "1.2.3-beta")
* @param version2 Second version string (e.g., "1.2.0" or "1.2.0-alpha")
@@ -24,26 +25,23 @@ object VersionComparison {
* Example:
* ```kotlin
* VersionComparison.isGreaterThanOrEqualTo("1.2.3", "1.2.0") // true
* VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // false
* VersionComparison.isGreaterThanOrEqualTo("1.2.0", "1.2.0-alpha") // true
* VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // true (ignores -alpha)
* VersionComparison.isGreaterThanOrEqualTo("1.2.0-dev", "1.2.1") // false (0.25.0 < 0.25.1)
* ```
*/
fun isGreaterThanOrEqualTo(version1: String, version2: String): Boolean {
// Split versions into core and pre-release parts
// Strip pre-release suffix from version1 (server version)
val components1 = version1.split("-", limit = 2)
val components2 = version2.split("-", limit = 2)
val core1 = components1[0]
val core2 = components2[0]
val preRelease1 = components1.getOrNull(1)
val preRelease2 = components2.getOrNull(1)
// Parse core version numbers
val parts1 = core1.split(".").mapNotNull { it.toIntOrNull() }
val parts2 = core2.split(".").mapNotNull { it.toIntOrNull() }
// Compare core versions first
// Compare core versions only
val maxLength = maxOf(parts1.size, parts2.size)
for (i in 0 until maxLength) {
val part1 = parts1.getOrElse(i) { 0 }
@@ -55,14 +53,8 @@ object VersionComparison {
}
}
// If core versions are equal, check pre-release versions
// No pre-release (release version) is greater than pre-release version
return when {
preRelease1 == null && preRelease2 != null -> true
preRelease1 != null && preRelease2 == null -> false
preRelease1 == null && preRelease2 == null -> true
else -> preRelease1!! >= preRelease2!!
}
// Core versions are equal
return true
}
/**

View File

@@ -56,5 +56,10 @@ allprojects {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
maven {
// expo-camera bundles a custom com.google.android:cameraview
url "$rootDir/../node_modules/expo-camera/android/maven"
}
}
}

View File

@@ -5,7 +5,7 @@
"version": "0.25.0-alpha",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "net.aliasvault.app",
"scheme": "aliasvault",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"platforms": [

View File

@@ -126,6 +126,27 @@ export default function SettingsLayout(): React.ReactNode {
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="qr-scanner"
options={{
title: t('settings.qrScanner.title'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="mobile-unlock/[id]"
options={{
title: t('settings.qrScanner.mobileLogin.confirmTitle'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="mobile-unlock/result"
options={{
title: t('common.success'),
...defaultHeaderOptions,
}}
/>
</Stack>
);
}

View File

@@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { router, useFocusEffect } from 'expo-router';
import { useRef, useState, useCallback } from 'react';
import { StyleSheet, View, ScrollView, TouchableOpacity, Animated, Platform, Alert, Linking } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useApiUrl } from '@/utils/ApiUrlUtility';
import { AppInfo } from '@/utils/AppInfo';
@@ -24,6 +25,7 @@ import { useApp } from '@/context/AppContext';
export default function SettingsScreen() : React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { getAuthMethodDisplayKey, shouldShowAutofillReminder, logout } = useApp();
const { getAutoLockTimeout, getClipboardClearTimeout } = useApp();
const { loadApiUrl, getDisplayUrl } = useApiUrl();
@@ -227,6 +229,22 @@ export default function SettingsScreen() : React.ReactNode {
};
const styles = StyleSheet.create({
fab: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 28,
bottom: Platform.OS === 'ios' ? insets.bottom + 60 : 16,
elevation: 4,
height: 56,
justifyContent: 'center',
position: 'absolute',
right: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
width: 56,
},
scrollContent: {
paddingBottom: 80,
paddingTop: Platform.OS === 'ios' ? 42 : 16,
@@ -511,6 +529,15 @@ export default function SettingsScreen() : React.ReactNode {
<ThemedText style={styles.versionText}>{t('settings.appVersion', { version: AppInfo.VERSION, url: getDisplayUrl() })}</ThemedText>
</View>
</Animated.ScrollView>
{/* Floating Action Button for QR Scanner - shown for testing both options */}
<TouchableOpacity
style={styles.fab}
onPress={() => router.push('/(tabs)/settings/qr-scanner')}
activeOpacity={0.8}
>
<Ionicons name="qr-code-outline" size={32} color={colors.primarySurfaceText} />
</TouchableOpacity>
</ThemedContainer>
);
}

View File

@@ -0,0 +1,290 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState, useEffect } from 'react';
import { View, Alert, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper';
import { useColors } from '@/hooks/useColorScheme';
import { useTranslation } from '@/hooks/useTranslation';
import LoadingIndicator from '@/components/LoadingIndicator';
import { ThemedButton } from '@/components/themed/ThemedButton';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
import { useWebApi } from '@/context/WebApiContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
* QR Code confirmation screen for mobile login.
*/
export default function MobileUnlockConfirmScreen() : React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const webApi = useWebApi();
const insets = useSafeAreaInsets();
const { id } = useLocalSearchParams();
const [isProcessing, setIsProcessing] = useState(false);
const [isValidating, setIsValidating] = useState(true);
/*
* Validate request on component mount
*/
useEffect(() => {
if (!id) {
Alert.alert(
t('common.error'),
t('common.errors.unknownErrorTryAgain'),
[
{
text: t('common.ok'),
/**
* Navigate back to settings.
*/
onPress: (): void => router.back(),
},
]
);
return;
}
/**
* Validate the mobile login request.
*/
const validateRequest = async () : Promise<void> => {
try {
// Check server version compatibility first
const isVersionSupported = await NativeVaultManager.isServerVersionGreaterThanOrEqualTo('0.25.0');
if (!isVersionSupported) {
Alert.alert(
t('common.error'),
t('common.errors.serverVersionTooOld'),
[
{
text: t('common.ok'),
/**
* Navigate back to settings.
*/
onPress: (): void => router.back(),
},
]
);
return;
}
// Validate the request exists by fetching from server
await webApi.authFetch<{ clientPublicKey: string }>(
`auth/mobile-login/request/${id}`,
{ method: 'GET' }
);
// Request is valid
setIsValidating(false);
} catch (error) {
console.error('Request validation error:', error);
let errorMsg = t('common.errors.unknownErrorTryAgain');
if (error instanceof Error && error.message.includes('404')) {
errorMsg = t('auth.errors.mobileLoginRequestExpired');
}
Alert.alert(
t('common.error'),
errorMsg,
[
{
text: t('common.ok'),
/**
* Navigate back to settings.
*/
onPress: (): void => router.back(),
},
]
);
}
};
validateRequest();
}, [id, webApi, t]);
/**
* Handle mobile login QR code.
*/
const handleMobileLogin = async (id: string) : Promise<void> => {
try {
// Fetch the public key from server
const response = await webApi.authFetch<{ clientPublicKey: string }>(
`auth/mobile-login/request/${id}`,
{ method: 'GET' }
);
const publicKeyJWK = response.clientPublicKey;
// Encrypt the decryption key using native module
const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileLogin(publicKeyJWK);
// Submit the encrypted key to the server
await webApi.authFetch(
'auth/mobile-login/submit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requestId: id,
encryptedDecryptionKey: encryptedKey,
}),
}
);
// Success! Navigate to result page
router.replace({
pathname: '/(tabs)/settings/mobile-unlock/result',
params: {
success: 'true',
message: t('settings.qrScanner.mobileLogin.successDescription'),
},
});
} catch (error) {
console.error('Mobile login error:', error);
let errorMsg = t('common.errors.unknownErrorTryAgain');
// Error! Navigate to result page
router.replace({
pathname: '/(tabs)/settings/mobile-unlock/result',
params: {
success: 'false',
message: errorMsg,
},
});
}
};
/**
* Handle confirmation - authenticate user first, then process the scanned QR code.
*/
const handleConfirm = async () : Promise<void> => {
if (!id) {
return;
}
setIsProcessing(true);
try {
// Authenticate user with either biometric or PIN (automatically detected)
const authenticated = await VaultUnlockHelper.authenticateForAction(
t('settings.qrScanner.mobileLogin.confirmTitle'),
t('settings.qrScanner.mobileLogin.confirmSubtitle')
);
if (!authenticated) {
setIsProcessing(false);
return;
}
// Process the mobile login
await handleMobileLogin(id as string);
} catch (error) {
console.error('Authentication or QR code processing error:', error);
Alert.alert(
t('common.error'),
error instanceof Error ? error.message : t('common.errors.unknownError')
);
} finally {
setIsProcessing(false);
}
};
/**
* Handle dismiss - navigate to settings tab.
* Uses replace to handle cases where this page is the first in the navigation stack (deep link).
*/
const handleDismiss = () : void => {
router.replace('/(tabs)/settings');
};
const styles = StyleSheet.create({
confirmationContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 20,
},
confirmationTitle: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
confirmationText: {
fontSize: 16,
lineHeight: 24,
marginBottom: 16,
textAlign: 'center',
},
usernameDisplayContainer: {
marginBottom: 12,
width: '100%',
},
buttonContainer: {
gap: 12,
marginTop: 20,
paddingBottom: insets.bottom + 80,
paddingHorizontal: 20,
},
button: {
width: '100%',
},
cancelButton: {
backgroundColor: colors.secondary,
},
});
// Show loading during validation or processing
if (isValidating || isProcessing) {
return (
<ThemedContainer>
<View style={styles.confirmationContainer}>
<LoadingIndicator />
</View>
</ThemedContainer>
);
}
// Show confirmation screen
return (
<ThemedContainer>
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View style={styles.confirmationContainer}>
<ThemedText style={styles.confirmationTitle}>
{t('settings.qrScanner.mobileLogin.confirmTitle')}
</ThemedText>
<ThemedText style={styles.confirmationText}>
{t('settings.qrScanner.mobileLogin.confirmMessage')}
</ThemedText>
<View style={styles.usernameDisplayContainer}>
<UsernameDisplay />
</View>
</View>
<View style={styles.buttonContainer}>
<ThemedButton
title={t('common.confirm')}
onPress={handleConfirm}
style={styles.button}
/>
<ThemedButton
title={t('common.cancel')}
onPress={handleDismiss}
style={StyleSheet.flatten([styles.button, styles.cancelButton])}
/>
</View>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -0,0 +1,110 @@
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { View, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColors } from '@/hooks/useColorScheme';
import { useTranslation } from '@/hooks/useTranslation';
import { ThemedButton } from '@/components/themed/ThemedButton';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
/**
* QR Code result screen - shows success or error after mobile unlock attempt.
*/
export default function MobileUnlockResultScreen() : React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { success, message } = useLocalSearchParams<{ success: string; message?: string }>();
const isSuccess = success === 'true';
/**
* Handle dismiss - navigate to settings tab.
* Uses replace to handle cases where this page is reached via deep link navigation.
*/
const handleDismiss = () : void => {
router.replace('/(tabs)/settings');
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 20,
},
resultContainer: {
alignItems: 'center',
backgroundColor: (isSuccess ? colors.success : colors.destructive) + '10',
borderColor: isSuccess ? colors.success : colors.destructive,
borderRadius: 12,
borderWidth: 2,
marginBottom: 20,
padding: 20,
width: '100%',
},
icon: {
marginBottom: 16,
},
title: {
color: isSuccess ? colors.success : colors.destructive,
fontSize: 20,
fontWeight: 'bold',
marginBottom: 8,
textAlign: 'center',
},
message: {
fontSize: 14,
lineHeight: 20,
textAlign: 'center',
},
buttonContainer: {
marginTop: 20,
paddingBottom: insets.bottom + 80,
paddingHorizontal: 20,
width: '100%',
},
button: {
width: '100%',
},
});
return (
<ThemedContainer>
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View style={styles.container}>
<View style={styles.resultContainer}>
<Ionicons
name={isSuccess ? 'checkmark-circle' : 'alert-circle'}
size={64}
color={isSuccess ? colors.success : colors.destructive}
style={styles.icon}
/>
<ThemedText style={styles.title}>
{isSuccess
? t('common.success')
: t('common.error')}
</ThemedText>
<ThemedText style={styles.message}>
{message || (isSuccess
? t('settings.qrScanner.mobileLogin.successDescription')
: t('common.errors.unknownErrorTryAgain'))}
</ThemedText>
</View>
</View>
<View style={styles.buttonContainer}>
<ThemedButton
title={t('common.close')}
onPress={handleDismiss}
style={styles.button}
/>
</View>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -0,0 +1,214 @@
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { Href, router, useLocalSearchParams } from 'expo-router';
import { useEffect, useCallback, useRef } from 'react';
import { View, Alert, StyleSheet } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
import { useTranslation } from '@/hooks/useTranslation';
import LoadingIndicator from '@/components/LoadingIndicator';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedText } from '@/components/themed/ThemedText';
// QR Code type prefixes
const QR_CODE_PREFIXES = {
MOBILE_UNLOCK: 'aliasvault://open/mobile-unlock/',
/*
* Future actions:
* PASSKEY_AUTH: 'aliasvault://open/passkey-auth/',
* SHARE_CREDENTIAL: 'aliasvault://open/share-credential/',
*/
} as const;
type QRCodeType = keyof typeof QR_CODE_PREFIXES;
/**
* Scanned QR code data.
*/
type ScannedQRCode = {
type: QRCodeType | null;
payload: string;
rawData: string;
}
/**
* Parse QR code data and determine its type.
*/
function parseQRCode(data: string): ScannedQRCode {
for (const [type, prefix] of Object.entries(QR_CODE_PREFIXES)) {
if (data.startsWith(prefix)) {
return {
type: type as QRCodeType,
payload: data.substring(prefix.length),
rawData: data,
};
}
}
return { type: null, payload: data, rawData: data };
}
/**
* General QR code scanner screen for AliasVault.
*/
export default function QRScannerScreen() : React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const [permission, requestPermission] = useCameraPermissions();
const { url } = useLocalSearchParams<{ url?: string }>();
const hasProcessedUrl = useRef(false);
const processedUrls = useRef(new Set<string>());
// Request camera permission on mount
useEffect(() => {
/**
* Request camera permission.
*/
const requestCameraPermission = async () : Promise<void> => {
if (!permission) {
return; // Still loading permission status
}
if (!permission.granted && permission.canAskAgain) {
// Request permission
await requestPermission();
} else if (!permission.granted && !permission.canAskAgain) {
// Permission was permanently denied
Alert.alert(
t('settings.qrScanner.cameraPermissionTitle'),
t('settings.qrScanner.cameraPermissionMessage'),
[{ text: t('common.ok'), /**
* Go back to the settings tab.
*/
onPress: (): void => router.back() }]
);
}
};
requestCameraPermission();
}, [permission, requestPermission, t]);
/*
* Handle barcode scanned - parse and navigate to appropriate page.
* Only processes AliasVault QR codes, silently ignores others.
* Validation is handled by the destination page.
*/
const handleBarcodeScanned = useCallback(({ data }: { data: string }) : void => {
// Prevent processing the same URL multiple times
if (processedUrls.current.has(data)) {
return;
}
// Parse the QR code to determine its type
const parsedData = parseQRCode(data);
// Silently ignore non-AliasVault QR codes
if (!parsedData.type) {
return;
}
// Mark this URL as processed
processedUrls.current.add(data);
/*
* Navigate to the appropriate page based on QR code type
* Validation will be handled by the destination page
*/
if (parsedData.type === 'MOBILE_UNLOCK') {
router.replace(`/(tabs)/settings/mobile-unlock/${parsedData.payload}` as Href);
}
}, []);
/**
* Reset hasProcessedUrl when URL changes to allow processing new URLs.
*/
useEffect(() => {
hasProcessedUrl.current = false;
}, [url]);
/**
* Handle QR code URL passed from deep link (e.g., from native camera).
*/
useEffect(() => {
if (url && typeof url === 'string' && !hasProcessedUrl.current) {
hasProcessedUrl.current = true;
handleBarcodeScanned({ data: url });
}
}, [url, handleBarcodeScanned]);
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 0,
},
camera: {
flex: 1,
},
cameraContainer: {
backgroundColor: colors.black,
flex: 1,
},
cameraOverlay: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
bottom: 0,
justifyContent: 'center',
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
cameraOverlayText: {
color: colors.white,
fontSize: 16,
marginTop: 20,
paddingHorizontal: 40,
textAlign: 'center',
},
closeButton: {
position: 'absolute',
right: 16,
top: 16,
zIndex: 10,
},
loadingContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 20,
},
});
// Show permission request screen
if (!permission || !permission.granted) {
return (
<ThemedContainer>
<View style={styles.loadingContainer}>
<LoadingIndicator />
</View>
</ThemedContainer>
);
}
return (
<ThemedContainer style={styles.container}>
<View style={styles.cameraContainer}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
onBarcodeScanned={handleBarcodeScanned}
>
<View style={styles.cameraOverlay}>
<Ionicons name="qr-code-outline" size={100} color={colors.white} />
<ThemedText style={styles.cameraOverlayText}>
{t('settings.qrScanner.scanningMessage')}
</ThemedText>
</View>
</CameraView>
</View>
</ThemedContainer>
);
}

View File

@@ -1,4 +1,5 @@
import { Link, Stack } from 'expo-router';
import { Link, Stack, usePathname, useGlobalSearchParams } from 'expo-router';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet } from 'react-native';
@@ -10,7 +11,17 @@ import { ThemedView } from '@/components/themed/ThemedView';
*/
export default function NotFoundScreen() : React.ReactNode {
const { t } = useTranslation();
const pathname = usePathname();
const params = useGlobalSearchParams();
useEffect(() => {
console.error('[NotFound] 404 - Page not found:', {
pathname,
params,
timestamp: new Date().toISOString(),
});
}, [pathname, params]);
return (
<>
<Stack.Screen options={{ title: t('app.notFound.title') }} />

View File

@@ -1,6 +1,6 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Href, Stack, useRouter } from 'expo-router';
import { Stack, useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useRef, useState } from 'react';
import { Linking, StyleSheet, Platform } from 'react-native';
@@ -12,12 +12,14 @@ import { install } from 'react-native-quick-crypto';
import { useColors, useColorScheme } from '@/hooks/useColorScheme';
import SpaceMono from '@/assets/fonts/SpaceMono-Regular.ttf';
import LoadingIndicator from '@/components/LoadingIndicator';
import { ThemedView } from '@/components/themed/ThemedView';
import { AliasVaultToast } from '@/components/Toast';
import { AppProvider } from '@/context/AppContext';
import { AuthProvider } from '@/context/AuthContext';
import { ClipboardCountdownProvider } from '@/context/ClipboardCountdownContext';
import { DbProvider } from '@/context/DbContext';
import { NavigationProvider, useNavigation } from '@/context/NavigationContext';
import { WebApiProvider } from '@/context/WebApiContext';
import { initI18n } from '@/i18n';
@@ -30,9 +32,9 @@ function RootLayoutNav() : React.ReactNode {
const colorScheme = useColorScheme();
const colors = useColors();
const router = useRouter();
const navigation = useNavigation();
const [bootComplete, setBootComplete] = useState(false);
const [redirectTarget, setRedirectTarget] = useState<string | null>(null);
const hasBooted = useRef(false);
useEffect(() => {
@@ -51,52 +53,38 @@ function RootLayoutNav() : React.ReactNode {
await initI18n();
hasBooted.current = true;
setRedirectTarget('/initialize');
// Check if we have a pending deep link and pass it to initialize
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
const path = initialUrl
.replace('net.aliasvault.app://', '')
.replace('aliasvault://', '')
.replace('exp+aliasvault://', '');
navigation.setReturnUrl({ path });
}
setBootComplete(true);
};
initializeApp();
}, []);
}, [navigation, router]);
useEffect(() => {
/**
* Redirect to a explicit target page if we have one (in case of non-happy path).
* Otherwise check for a deep link and simulate stack navigation.
* If neither is present, we let the router redirect us to the default route.
*/
const redirect = async () : Promise<void> => {
if (!bootComplete) {
return;
}
if (redirectTarget) {
// If we have an explicit redirect target, we navigate to it. This overrides potential deep link handling.
router.replace(redirectTarget as Href);
} else {
// Check if we have an initial URL to handle (deep link from most likely the autofill extension).
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
/**
* Check for certain supported deep link routes, and if found, ensure we simulate the stack navigation
* as otherwise the "back" button for navigation will not work as expected.
*/
const path = initialUrl.replace('net.aliasvault.app://', '');
const isDetailRoute = path.includes('credentials/');
if (isDetailRoute) {
// First go to the credentials tab.
router.replace('/(tabs)/credentials');
// Then push the target route inside the credentials tab.
setTimeout(() => {
router.push(path as Href);
}, 0);
}
}
}
router.replace('/initialize');
};
redirect();
}, [bootComplete, redirectTarget, router]);
}, [bootComplete, router]);
const styles = StyleSheet.create({
container: {
@@ -110,6 +98,7 @@ function RootLayoutNav() : React.ReactNode {
return (
<ThemedView style={styles.container}>
{/* Loading state while booting */}
<LoadingIndicator />
</ThemedView>
);
}
@@ -182,18 +171,20 @@ export default function RootLayout() : React.ReactNode {
}
return (
<DbProvider>
<AuthProvider>
<WebApiProvider>
<AppProvider>
<ClipboardCountdownProvider>
<GestureHandlerRootView>
<RootLayoutNav />
</GestureHandlerRootView>
</ClipboardCountdownProvider>
</AppProvider>
</WebApiProvider>
</AuthProvider>
</DbProvider>
<NavigationProvider>
<DbProvider>
<AuthProvider>
<WebApiProvider>
<AppProvider>
<ClipboardCountdownProvider>
<GestureHandlerRootView>
<RootLayoutNav />
</GestureHandlerRootView>
</ClipboardCountdownProvider>
</AppProvider>
</WebApiProvider>
</AuthProvider>
</DbProvider>
</NavigationProvider>
);
}

View File

@@ -4,6 +4,8 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultSync } from '@/hooks/useVaultSync';
@@ -11,6 +13,7 @@ import LoadingIndicator from '@/components/LoadingIndicator';
import { ThemedView } from '@/components/themed/ThemedView';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
import { useNavigation } from '@/context/NavigationContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
@@ -27,6 +30,7 @@ export default function Initialize() : React.ReactNode {
const abortControllerRef = useRef<AbortController | null>(null);
const { t } = useTranslation();
const app = useApp();
const navigation = useNavigation();
const { syncVault } = useVaultSync();
const dbContext = useDb();
const colors = useColors();
@@ -72,7 +76,7 @@ export default function Initialize() : React.ReactNode {
// Don't show the alert if we're already in offline mode
if (app.isOffline) {
console.debug('Already in offline mode, skipping offline flow alert');
router.replace('/(tabs)/credentials');
navigation.navigateAfterUnlock();
return;
}
@@ -127,8 +131,8 @@ export default function Initialize() : React.ReactNode {
return;
}
// Success - navigate to credentials
router.replace('/(tabs)/credentials');
// Success - use centralized navigation logic
navigation.navigateAfterUnlock();
} catch (err) {
console.error('Error during offline vault unlock:', err);
router.replace('/unlock');
@@ -169,7 +173,7 @@ export default function Initialize() : React.ReactNode {
}
]
);
}, [dbContext, router, app, t, updateStatus]);
}, [dbContext, router, app, navigation, t, updateStatus]);
useEffect(() => {
// Ensure this only runs once.
@@ -206,33 +210,30 @@ export default function Initialize() : React.ReactNode {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
if (hasEncryptedDatabase) {
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
// Attempt automatic unlock using centralized helper
updateStatus(t('app.status.unlockingVault'));
const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault });
// Only attempt to unlock if FaceID is enabled
if (isFaceIDEnabled) {
// Unlock vault FIRST (before network sync) - this is not skippable
updateStatus(t('app.status.unlockingVault'));
const isUnlocked = await dbContext.unlockVault();
if (!isUnlocked) {
// Failed to unlock, redirect to unlock screen
router.replace('/unlock');
return;
if (!unlockResult.success) {
/*
* Unlock failed or cancelled, redirect to unlock screen.
* Only log non-cancellation errors to avoid noise.
*/
if (!unlockResult.error?.includes('cancelled')) {
console.error('Automatic unlock failed:', unlockResult.error);
}
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
// Vault unlocked successfully - now allow skip button for network operations
canShowSkipButtonRef.current = true;
} else {
// No FaceID, redirect to unlock screen for manual unlock
router.replace('/unlock');
return;
}
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
// Vault unlocked successfully - now allow skip button for network operations
canShowSkipButtonRef.current = true;
}
} catch (err) {
console.error('Error during initial vault unlock:', err);
@@ -272,8 +273,8 @@ export default function Initialize() : React.ReactNode {
* Handle successful vault sync.
*/
onSuccess: async () => {
// Vault already unlocked, just navigate to credentials
router.replace('/(tabs)/credentials');
// Use centralized navigation logic
navigation.navigateAfterUnlock();
},
/**
* Handle offline state and prompt user for action.
@@ -317,7 +318,7 @@ export default function Initialize() : React.ReactNode {
clearTimeout(skipButtonTimeoutRef.current);
}
};
}, [dbContext, syncVault, app, router, t, handleOfflineFlow, updateStatus]);
}, [dbContext, syncVault, app, router, navigation, t, handleOfflineFlow, updateStatus]);
/**
* Handle skip button press by calling the offline handler.

View File

@@ -0,0 +1,68 @@
import { Href, useRouter, useLocalSearchParams, useGlobalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
/**
* Action-based deep link handler for special actions triggered from outside the app.
*
* URL structure: aliasvault://open/[action]/[...params]
*
* Supported actions:
* - mobile-unlock/[requestId] - Mobile device unlock via QR code
*
* This route exists to handle deep links that Expo Router processes before our
* Linking.addEventListener can intercept them. It provides proper navigation
* flow for each action type.
*/
export default function ActionHandler() : null {
const router = useRouter();
const params = useGlobalSearchParams();
const localParams = useLocalSearchParams();
const [hasNavigated, setHasNavigated] = useState(false);
useEffect(() => {
if (hasNavigated) {
return;
}
// Get the path segments (first segment is the action)
const pathSegments = (params.path || localParams.path) as string[] | string | undefined;
const pathArray = Array.isArray(pathSegments) ? pathSegments : pathSegments ? [pathSegments] : [];
if (pathArray.length === 0) {
// No action specified, go to credentials
router.replace('/(tabs)/credentials');
setHasNavigated(true);
return;
}
const [action, ...actionParams] = pathArray;
// Handle different action types
switch (action) {
case 'mobile-unlock': {
// Mobile unlock action: $/mobile-unlock/[requestId]
const requestId = actionParams[0];
if (!requestId) {
console.error('[ActionHandler] mobile-unlock requires requestId');
router.replace('/(tabs)/settings');
setHasNavigated(true);
return;
}
// First navigate to settings tab to establish correct navigation stack
router.replace(`/(tabs)/settings/mobile-unlock/${requestId}` as Href);
setHasNavigated(true);
break;
}
default:
// Unknown action, log and go to credentials
console.warn('[ActionHandler] Unknown action:', action);
router.replace('/(tabs)/credentials');
setHasNavigated(true);
break;
}
}, [params, localParams, router, hasNavigated]);
return null;
}

View File

@@ -1,9 +1,11 @@
import { Ionicons } from '@expo/vector-icons';
import { Href, router } from 'expo-router';
import { router } from 'expo-router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, Alert, TouchableOpacity } from 'react-native';
import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultSync } from '@/hooks/useVaultSync';
@@ -12,6 +14,7 @@ import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
import { useNavigation } from '@/context/NavigationContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
@@ -21,6 +24,7 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
export default function ReinitializeScreen() : React.ReactNode {
const app = useApp();
const dbContext = useDb();
const navigation = useNavigation();
const { syncVault } = useVaultSync();
const [status, setStatus] = useState('');
const [showSkipButton, setShowSkipButton] = useState(false);
@@ -120,38 +124,8 @@ export default function ReinitializeScreen() : React.ReactNode {
return;
}
// Handle navigation based on return URL
if (!app.returnUrl?.path) {
router.replace('/(tabs)/credentials');
return;
}
// Navigate to return URL
const path = app.returnUrl.path as string;
const isDetailRoute = path.includes('credentials/');
if (!isDetailRoute) {
router.replace({
pathname: path as '/',
params: app.returnUrl.params as Record<string, string>
});
app.setReturnUrl(null);
return;
}
// Handle detail routes
const params = app.returnUrl.params as Record<string, string>;
router.replace('/(tabs)/credentials');
setTimeout(() => {
if (params.serviceUrl) {
router.push(`${path}?serviceUrl=${params.serviceUrl}` as Href);
} else if (params.id) {
router.push(`${path}?id=${params.id}` as Href);
} else {
router.push(path as Href);
}
}, 0);
app.setReturnUrl(null);
// Use centralized navigation logic
navigation.navigateAfterUnlock();
} catch (err) {
console.error('Error during offline vault unlock:', err);
router.replace('/unlock');
@@ -186,7 +160,7 @@ export default function ReinitializeScreen() : React.ReactNode {
}
]
);
}, [app, dbContext, t, updateStatus]);
}, [app, dbContext, navigation, t, updateStatus]);
useEffect(() => {
if (hasInitialized.current) {
@@ -195,49 +169,6 @@ export default function ReinitializeScreen() : React.ReactNode {
hasInitialized.current = true;
/**
* Redirect to the return URL.
*/
function redirectToReturnUrl() : void {
/**
* Simulate stack navigation.
*/
function simulateStackNavigation(from: string, to: string) : void {
router.replace(from as Href);
setTimeout(() => {
router.push(to as Href);
}, 0);
}
if (app.returnUrl?.path) {
// Type assertion needed due to router type limitations
const path = app.returnUrl.path as '/';
const isDetailRoute = path.includes('credentials/');
if (isDetailRoute) {
// If there is a "serviceUrl" or "id" param from the return URL, use it.
const params = app.returnUrl.params as Record<string, string>;
if (params.serviceUrl) {
simulateStackNavigation('/(tabs)/credentials', `${path}?serviceUrl=${params.serviceUrl}`);
} else if (params.id) {
simulateStackNavigation('/(tabs)/credentials', `${path}?id=${params.id}`);
} else {
simulateStackNavigation('/(tabs)/credentials', path as string);
}
} else {
router.replace({
pathname: path,
params: app.returnUrl.params as Record<string, string>
});
}
// Clear the return URL after using it
app.setReturnUrl(null);
} else {
// If there is no return URL, navigate to the credentials tab as default entry page.
router.replace('/(tabs)/credentials');
}
}
/**
* Initialize the app.
*/
@@ -257,43 +188,35 @@ export default function ReinitializeScreen() : React.ReactNode {
const isAlreadyUnlocked = await NativeVaultManager.isVaultUnlocked();
if (!isAlreadyUnlocked) {
// Check if we have an encrypted database and if FaceID is enabled
// Check if we have an encrypted database
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
if (hasEncryptedDatabase) {
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
// Attempt automatic unlock using centralized helper
updateStatus(t('app.status.unlockingVault'));
const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault });
// Only attempt to unlock if FaceID is enabled
if (isFaceIDEnabled) {
// Unlock vault FIRST (before network sync) - this is not skippable
updateStatus(t('app.status.unlockingVault'));
const isUnlocked = await dbContext.unlockVault();
if (!isUnlocked) {
// Failed to unlock, redirect to unlock screen
router.replace('/unlock');
return;
}
// Add small delay for UX
await new Promise(resolve => setTimeout(resolve, 500));
updateStatus(t('app.status.decryptingVault'));
await new Promise(resolve => setTimeout(resolve, 750));
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
// Vault unlocked successfully - now allow skip button for network operations
canShowSkipButtonRef.current = true;
} else {
// No FaceID, redirect to unlock screen
if (!unlockResult.success) {
// Unlock failed, redirect to unlock screen
console.error('Automatic unlock failed:', unlockResult.error);
router.replace('/unlock');
return;
}
// Add small delay for UX
await new Promise(resolve => setTimeout(resolve, 300));
updateStatus(t('app.status.decryptingVault'));
await new Promise(resolve => setTimeout(resolve, 500));
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
// Vault unlocked successfully - now allow skip button for network operations
canShowSkipButtonRef.current = true;
} else {
// No encrypted database, redirect to unlock screen
router.replace('/unlock');
@@ -333,8 +256,7 @@ export default function ReinitializeScreen() : React.ReactNode {
* Handle successful vault sync.
*/
onSuccess: async () => {
// Vault already unlocked, just navigate to return URL
redirectToReturnUrl();
navigation.navigateAfterUnlock();
},
/**
* Handle error during vault sync.
@@ -342,8 +264,8 @@ export default function ReinitializeScreen() : React.ReactNode {
*/
onError: (error: string) => {
console.error('Vault sync error during reinitialize:', error);
// Even if sync fails, vault is already unlocked, so navigate to return URL
redirectToReturnUrl();
// Even if sync fails, vault is already unlocked, use centralized navigation
navigation.navigateAfterUnlock();
},
/**
* Handle offline state and prompt user for action.
@@ -361,7 +283,7 @@ export default function ReinitializeScreen() : React.ReactNode {
};
initialize();
}, [syncVault, app, dbContext, t, handleOfflineFlow, updateStatus]);
}, [syncVault, app, dbContext, navigation, t, handleOfflineFlow, updateStatus]);
/**
* Handle skip button press by calling the offline handler.

View File

@@ -20,6 +20,7 @@ import { Avatar } from '@/components/ui/Avatar';
import { RobustPressable } from '@/components/ui/RobustPressable';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
import { useNavigation } from '@/context/NavigationContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
@@ -28,6 +29,7 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
export default function UnlockScreen() : React.ReactNode {
const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams, logout } = useApp();
const dbContext = useDb();
const navigation = useNavigation();
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isBiometricsAvailable, setIsBiometricsAvailable] = useState(false);
@@ -56,90 +58,30 @@ export default function UnlockScreen() : React.ReactNode {
return params;
}, [logout, getEncryptionKeyDerivationParams]);
/**
* Handle PIN unlock using native UI.
* Falls back to showing password input on cancel.
*/
const handlePinUnlock = useCallback(async () : Promise<void> => {
try {
/*
* Show native PIN unlock UI
* This will handle the unlock internally and store the encryption key
*/
await NativeVaultManager.showPinUnlock();
/*
* Check if the vault is ready
*/
if (dbContext.dbAvailable) {
// Check if the vault is up to date, if not, redirect to the upgrade page.
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
/*
* Navigate to initialize page which will handle vault sync and then navigate to credentials
* This ensures we always check for vault updates even after local unlock
*/
router.replace('/initialize');
} else {
// If db is not available for whatever reason, fallback to password unlock.
setIsLoading(false);
Alert.alert(
t('common.error'),
t('auth.errors.incorrectPassword'),
[{ text: t('common.ok'), style: 'default' }]
);
}
} catch (err: unknown) {
// User cancelled or error occurred
setIsLoading(false);
if (err && typeof err === 'object' && 'code' in err) {
// Show password input as fallback on error
console.error('PIN unlock failed:', err);
// Check if PIN is still enabled and update state accordingly
const pinStillEnabled = await NativeVaultManager.isPinEnabled();
setPinAvailable(pinStillEnabled);
return;
}
}
}, [dbContext, t, setPinAvailable]);
useEffect(() => {
getKeyDerivationParams();
/**
* Fetch the biometric config and PIN availability, then attempt unlock.
* Fetch the biometric config and PIN availability.
*/
const fetchConfigAndUnlock = async () : Promise<void> => {
const fetchConfig = async () : Promise<void> => {
// Check if biometrics is available
const enabled = await isBiometricsEnabled();
setIsBiometricsAvailable(enabled);
const displayName = await getBiometricDisplayName();
setBiometricDisplayName(displayName);
// Check PIN availability
// Check if PIN is enabled
const pinEnabled = await NativeVaultManager.isPinEnabled();
setPinAvailable(pinEnabled);
/*
* If PIN is enabled, automatically try PIN unlock first
* Show loading state, then launch native PIN UI
* If user cancels or PIN is not available, loading stops and password input shows
*/
if (pinEnabled) {
await handlePinUnlock();
} else {
// No PIN available, stop loading to show password input
setIsLoading(false);
}
// Stop loading to show password input
setIsLoading(false);
};
fetchConfigAndUnlock();
fetchConfig();
}, [isBiometricsEnabled, getKeyDerivationParams, getBiometricDisplayName, t, handlePinUnlock]);
}, [isBiometricsEnabled, getKeyDerivationParams, getBiometricDisplayName]);
/**
* Handle the unlock.
@@ -188,10 +130,10 @@ export default function UnlockScreen() : React.ReactNode {
}
/*
* Navigate to initialize page which will handle vault sync and then navigate to credentials
* This ensures we always check for vault updates even after local unlock
* Navigate using centralized navigation logic
* This ensures we handle pending deep links and return URLs correctly
*/
router.replace('/initialize');
navigation.navigateAfterUnlock();
} else {
Alert.alert(
t('common.error'),
@@ -231,7 +173,7 @@ export default function UnlockScreen() : React.ReactNode {
/**
* Handle the biometrics retry.
*/
const handleBiometricsRetry = async () : Promise<void> => {
const handleUnlockRetry = async () : Promise<void> => {
router.replace('/reinitialize');
};
@@ -458,7 +400,7 @@ export default function UnlockScreen() : React.ReactNode {
{isBiometricsAvailable && (
<RobustPressable
style={styles.faceIdButton}
onPress={handleBiometricsRetry}
onPress={handleUnlockRetry}
>
<ThemedText style={styles.faceIdButtonText}>{t('auth.tryBiometricAgain', { biometric: biometricDisplayName })}</ThemedText>
</RobustPressable>
@@ -466,15 +408,12 @@ export default function UnlockScreen() : React.ReactNode {
{/* Use PIN Button */}
{pinAvailable && (
<Pressable
style={styles.linkButton}
onPress={() => {
setIsLoading(true);
handlePinUnlock();
}}
<RobustPressable
style={styles.faceIdButton}
onPress={handleUnlockRetry}
>
<ThemedText style={styles.linkButtonText}>{t('auth.unlockWithPin')}</ThemedText>
</Pressable>
<ThemedText style={styles.faceIdButtonText}>{t('auth.tryPinAgain')}</ThemedText>
</RobustPressable>
)}
</View>

View File

@@ -4,13 +4,13 @@ import { StyleSheet, View, Text, Animated, useColorScheme } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
type LoadingIndicatorProps = {
status: string;
status?: string | null;
};
/**
* Loading indicator component.
*/
export default function LoadingIndicator({ status }: LoadingIndicatorProps): React.ReactNode {
export default function LoadingIndicator({ status = '' }: LoadingIndicatorProps): React.ReactNode {
const colors = useColors();
const dot1Anim = useRef(new Animated.Value(0)).current;
const dot2Anim = useRef(new Animated.Value(0)).current;
@@ -89,8 +89,9 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea
* If the status ends with a pipe character (|), don't show any dots
* This provides an explicit way to disable the loading dots animation
*/
const statusTrimmed = status.endsWith('|') ? status.slice(0, -1) : status;
const shouldShowDots = !status.endsWith('|');
const statusText = status || '';
const statusTrimmed = statusText.endsWith('|') ? statusText.slice(0, -1) : statusText;
const shouldShowDots = statusText.length > 0 && !statusText.endsWith('|');
const backgroundColor = colorScheme === 'dark' ? 'transparent' : '#fff';
const shadowColor = '#000';

View File

@@ -83,8 +83,6 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar
const handleContextMenuAction = async (event: OnPressMenuItemEvent): Promise<void> => {
const { name } = event.nativeEvent;
console.log('handleContextMenuAction', name);
switch (name) {
case t('credentials.contextMenu.edit'):
Keyboard.dismiss();

View File

@@ -5,6 +5,7 @@
export const Colors = {
light: {
white: '#ffffff',
text: '#11181C',
textMuted: '#4b5563',
background: '#f3f2f7',

View File

@@ -34,9 +34,6 @@ type AppContextType = {
// Autofill methods
shouldShowAutofillReminder: boolean;
markAutofillConfigured: () => Promise<void>;
// Return URL methods
returnUrl: { path: string; params?: object } | null;
setReturnUrl: (url: { path: string; params?: object } | null) => void;
}
export type AuthMethod = 'faceid' | 'password';
@@ -105,7 +102,6 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
username: auth.username,
isOffline: auth.isOffline,
shouldShowAutofillReminder: auth.shouldShowAutofillReminder,
returnUrl: auth.returnUrl,
// Wrap auth methods
logout,
initializeAuth: auth.initializeAuth,
@@ -126,14 +122,12 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
verifyPassword: auth.verifyPassword,
getEncryptionKeyDerivationParams: auth.getEncryptionKeyDerivationParams,
markAutofillConfigured: auth.markAutofillConfigured,
setReturnUrl: auth.setReturnUrl,
}), [
auth.isInitialized,
auth.isLoggedIn,
auth.username,
auth.isOffline,
auth.shouldShowAutofillReminder,
auth.returnUrl,
auth.initializeAuth,
auth.setAuthTokens,
auth.login,
@@ -151,8 +145,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
auth.verifyPassword,
auth.getEncryptionKeyDerivationParams,
auth.markAutofillConfigured,
auth.setReturnUrl,
logout,
logout
]);
return (

View File

@@ -3,10 +3,8 @@ import { Buffer } from 'buffer';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainerRef, ParamListBase } from '@react-navigation/native';
import * as LocalAuthentication from 'expo-local-authentication';
import { router, useGlobalSearchParams, usePathname } from 'expo-router';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Alert, AppState, Platform } from 'react-native';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { Alert, Platform } from 'react-native';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useDb } from '@/context/DbContext';
@@ -43,9 +41,6 @@ type AuthContextType = {
// Autofill methods
shouldShowAutofillReminder: boolean;
markAutofillConfigured: () => Promise<void>;
// Return URL methods
returnUrl: { path: string; params?: object } | null;
setReturnUrl: (url: { path: string; params?: object } | null) => void;
}
const AUTOFILL_CONFIGURED_KEY = 'autofill_configured';
@@ -59,22 +54,15 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
* AuthProvider to provide the authentication state to the app that components can use.
*/
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const AuthProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [shouldShowAutofillReminder, setShouldShowAutofillReminder] = useState(false);
const [returnUrl, setReturnUrl] = useState<{ path: string; params?: object } | null>(null);
const [isOffline, setIsOffline] = useState(false);
const appState = useRef(AppState.currentState);
const dbContext = useDb();
const pathname = usePathname();
const params = useGlobalSearchParams();
const lastRouteRef = useRef<{ path: string, params?: object }>({ path: pathname, params });
useEffect(() => {
lastRouteRef.current = { path: pathname, params };
}, [pathname, params]);
/**
* Get enabled auth methods from the native module
@@ -196,7 +184,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
* This is called by AppContext after revoking tokens on the server.
*/
const clearAuth = useCallback(async (errorMessage?: string): Promise<void> => {
console.log('Clearing auth --- attempt');
// Clear credential identity store (password and passkey autofill metadata)
try {
await NativeVaultManager.removeCredentialIdentities();
@@ -364,18 +351,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, []);
/**
* Check if the vault is unlocked.
*/
const isVaultUnlocked = useCallback(async (): Promise<boolean> => {
try {
return await NativeVaultManager.isVaultUnlocked();
} catch (error) {
console.error('Failed to check vault status:', error);
return false;
}
}, []);
/**
* Get the encryption key derivation parameters from native storage.
* Returns parsed parameters or null if not available.
@@ -426,46 +401,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return currentPasswordHashBase64;
}, [dbContext, getEncryptionKeyDerivationParams]);
// Handle app state changes
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
/**
* App coming to foreground
* Skip vault re-initialization checks during unlock, login, initialize, and reinitialize flows to prevent race conditions
* where the AppState listener fires during app initialization, especially on iOS release builds.
*/
if (!pathname?.includes('unlock') && !pathname?.includes('login') && !pathname?.includes('initialize') && !pathname?.includes('reinitialize')) {
try {
// Check if vault is unlocked.
const isUnlocked = await isVaultUnlocked();
if (!isUnlocked) {
// Get current full URL including query params
const currentRoute = lastRouteRef.current;
if (currentRoute?.path) {
setReturnUrl({
path: currentRoute.path,
params: currentRoute.params
});
}
// Database connection failed, navigate to reinitialize flow
router.replace('/reinitialize');
}
} catch {
// Database query failed, navigate to reinitialize flow
router.replace('/reinitialize');
}
}
}
appState.current = nextAppState;
});
return (): void => {
subscription.remove();
};
}, [isVaultUnlocked, pathname]);
/**
* Load autofill state from storage
*/
@@ -500,7 +435,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
isInitialized,
username,
shouldShowAutofillReminder,
returnUrl,
isOffline,
getEnabledAuthMethods,
isBiometricsEnabled,
@@ -517,7 +451,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setClipboardClearTimeout,
getBiometricDisplayName,
markAutofillConfigured,
setReturnUrl,
verifyPassword,
getEncryptionKeyDerivationParams,
setOfflineMode,
@@ -526,7 +459,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
isInitialized,
username,
shouldShowAutofillReminder,
returnUrl,
isOffline,
getEnabledAuthMethods,
isBiometricsEnabled,
@@ -543,7 +475,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setClipboardClearTimeout,
getBiometricDisplayName,
markAutofillConfigured,
setReturnUrl,
verifyPassword,
getEncryptionKeyDerivationParams,
setOfflineMode,

View File

@@ -0,0 +1,229 @@
import { Href, useRouter, usePathname, useGlobalSearchParams } from 'expo-router';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AppState } from 'react-native';
import NativeVaultManager from '@/specs/NativeVaultManager';
type NavigationContextType = {
/**
* Return URL to navigate to after successful vault unlock.
* This is set when the app is backgrounded and vault is locked.
*/
returnUrl: { path: string; params?: Record<string, string> } | null;
/**
* Set the return URL for post-unlock navigation.
*/
setReturnUrl: (url: { path: string; params?: Record<string, string> } | null) => void;
/**
* Navigate to the appropriate destination after successful vault unlock.
* Handles return URLs and default navigation to credentials tab.
*/
navigateAfterUnlock: () => void;
}
const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
/**
* NavigationProvider to provide centralized navigation logic, particularly for post-unlock flows.
*/
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const router = useRouter();
const pathname = usePathname();
const params = useGlobalSearchParams();
const [returnUrl, setReturnUrl] = useState<{ path: string; params?: Record<string, string> } | null>(null);
const appState = useRef(AppState.currentState);
const lastRouteRef = useRef<{ path: string, params?: object }>({ path: pathname, params });
// Track current route for vault lock recovery
useEffect(() => {
lastRouteRef.current = { path: pathname, params };
}, [pathname, params]);
/**
* Navigate to the appropriate destination after successful vault unlock.
* Priority order:
* 1. Return URL (from reinitialize flow or _layout.tsx)
* 2. Default credentials tab
*/
const navigateAfterUnlock = useCallback((): void => {
// Priority 1: Handle return URL (from reinitialize flow)
if (returnUrl?.path) {
const url = returnUrl;
setReturnUrl(null);
handleReturnUrl(url, router);
return;
}
// Priority 2: Default navigation to credentials
router.replace('/(tabs)/credentials');
}, [returnUrl, router]);
/**
* Handle return URL navigation (from reinitialize flow).
*/
const handleReturnUrl = (
returnUrl: { path: string; params?: Record<string, string> },
router: ReturnType<typeof useRouter>
): void => {
// Normalize the path using centralized function
const normalizedPath = normalizeDeepLinkPath(returnUrl.path);
const params = returnUrl.params || {};
// Check if this is a detail route (has a sub-page after the tab)
const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/');
const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') &&
!normalizedPath.endsWith('/(tabs)/settings');
if (isCredentialRoute) {
// Navigate to credentials tab first, then push detail page
router.replace('/(tabs)/credentials');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
router.push(targetUrl as Href);
}, 0);
} else if (isSettingsRoute) {
// Navigate to settings tab first, then push detail page
router.replace('/(tabs)/settings');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
router.push(targetUrl as Href);
}, 0);
} else {
// Direct navigation for root tab routes
// If there are query params, append them as query string
if (Object.keys(params).length > 0) {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = `${normalizedPath}?${queryParams}`;
router.replace(targetUrl as Href);
} else {
router.replace(normalizedPath as Href);
}
}
};
/**
* Normalize a deep link or path to ensure it has the correct /(tabs)/ prefix.
*
* Supports:
* - Action-based URLs: aliasvault://open/mobile-unlock/[id]
* - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page]
*/
const normalizeDeepLinkPath = (urlOrPath: string): string => {
// Remove all URL schemes first
let path = urlOrPath
.replace('net.aliasvault.app://', '')
.replace('aliasvault://', '')
.replace('exp+aliasvault://', '');
// If it already has /(tabs)/ prefix, return as is
if (path.startsWith('/(tabs)/')) {
return path;
}
// Handle credential paths
if (path.startsWith('credentials/') || path.includes('/credentials/')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return `/(tabs)${path}`;
}
// Handle settings paths
if (path.startsWith('settings/') || path.startsWith('/settings')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return `/(tabs)${path}`;
}
return path;
};
/**
* Check if the vault is unlocked.
*/
const isVaultUnlocked = useCallback(async (): Promise<boolean> => {
try {
return await NativeVaultManager.isVaultUnlocked();
} catch (error) {
console.error('Failed to check vault status:', error);
return false;
}
}, []);
/**
* Handle app state changes - detect when vault is locked and save return URL.
*/
useEffect(() => {
const appstateSubscription = AppState.addEventListener('change', async (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
/**
* App coming to foreground
* Skip vault re-initialization checks during unlock, login, initialize, and reinitialize flows to prevent race conditions
* where the AppState listener fires during app initialization, especially on iOS release builds.
* Also skip during mobile-unlock flow as it has its own authentication.
*/
if (!pathname?.startsWith('/unlock') &&
!pathname?.startsWith('/login') &&
!pathname?.startsWith('/initialize') &&
!pathname?.startsWith('/reinitialize') &&
!pathname?.includes('/mobile-unlock/')) {
try {
// Check if vault is unlocked.
const isUnlocked = await isVaultUnlocked();
if (!isUnlocked) {
// Get current full URL including query params
const currentRoute = lastRouteRef.current;
if (currentRoute?.path) {
setReturnUrl({
path: currentRoute.path,
params: currentRoute.params as Record<string, string>
});
}
// Database connection failed, navigate to reinitialize flow
console.log('database connection failed, navigating to reinitialize');
router.replace('/reinitialize');
}
} catch {
// Database query failed, navigate to reinitialize flow
console.log('database query failed, navigating to reinitialize');
router.replace('/reinitialize');
}
}
}
appState.current = nextAppState;
});
return (): void => {
appstateSubscription.remove();
};
}, [isVaultUnlocked, pathname, router]);
const contextValue = useMemo(() => ({
returnUrl,
setReturnUrl,
navigateAfterUnlock,
}), [returnUrl, navigateAfterUnlock]);
return (
<NavigationContext.Provider value={contextValue}>
{children}
</NavigationContext.Provider>
);
};
/**
* Hook to use the NavigationContext.
*/
export const useNavigation = (): NavigationContextType => {
const context = useContext(NavigationContext);
if (context === undefined) {
throw new Error('useNavigation must be used within a NavigationProvider');
}
return context;
};

View File

@@ -8,7 +8,7 @@
"no": "No",
"ok": "OK",
"continue": "Continue",
"loading": "Loading...",
"loading": "Loading",
"error": "Error",
"success": "Success",
"never": "Never",
@@ -22,7 +22,8 @@
"disabled": "Disabled",
"errors": {
"unknownError": "An unknown error occurred. Please try again.",
"unknownErrorTryAgain": "An unknown error occurred. Please try again."
"unknownErrorTryAgain": "An unknown error occurred. Please try again.",
"serverVersionTooOld": "The AliasVault server needs to be updated to a newer version in order to use this feature. Please contact the server admin if you need help."
}
},
"auth": {
@@ -48,6 +49,7 @@
"enableBiometric": "Enable {{biometric}}?",
"biometricPrompt": "Would you like to use {{biometric}} to unlock your vault?",
"tryBiometricAgain": "Try {{biometric}} Again",
"tryPinAgain": "Try PIN Again",
"authCodeNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"errors": {
"credentialsRequired": "Username and password are required",
@@ -395,6 +397,23 @@
"failedToDelete": "Failed to delete account. Please try again.",
"usernameNotFound": "Username not found. Please login again."
}
},
"qrScanner": {
"title": "QR Code Scanner",
"scanningMessage": "Scan AliasVault QR code",
"invalidQrCode": "Invalid QR Code",
"notAliasVaultQr": "This is not a valid AliasVault QR code. Please scan a QR code generated by AliasVault.",
"cameraPermissionTitle": "Camera Permission Required",
"cameraPermissionMessage": "Please allow camera access to scan QR codes.",
"mobileLogin": {
"confirmTitle": "Confirm Login Request",
"confirmSubtitle": "Re-authenticate to approve login on another device.",
"confirmMessage": "You are about to log in on a remote device with your account. This other device will have full access to your vault. Only proceed if you trust this device.",
"successDescription": "The remote device has been successfully logged in.",
"requestExpired": "This login request has expired. Please generate a new QR code.",
"authenticationFailed": "Authentication failed. Please try again.",
"noAuthMethodEnabled": "Biometric or PIN unlock needs to be enabled to unlock with mobile"
}
}
},
"navigation": {

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
@@ -212,7 +212,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -222,13 +222,84 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
CE59C7602E4F47FD0024A246 /* VaultUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUITests; sourceTree = "<group>"; };
CE77825E2EA1822400A75E6F /* VaultUtils */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUtils; sourceTree = "<group>"; };
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = "<group>"; };
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = "<group>"; };
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = "<group>"; };
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = "<group>"; };
CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = "<group>"; };
CE59C7602E4F47FD0024A246 /* VaultUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUITests;
sourceTree = "<group>";
};
CE77825E2EA1822400A75E6F /* VaultUtils */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUtils;
sourceTree = "<group>";
};
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKit;
sourceTree = "<group>";
};
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKitTests;
sourceTree = "<group>";
};
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUI;
sourceTree = "<group>";
};
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultModels;
sourceTree = "<group>";
};
CEE909812DA548C7008D568F /* Autofill */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = Autofill;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -1347,7 +1418,10 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -1401,7 +1475,10 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;

View File

@@ -46,7 +46,14 @@
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>net.aliasvault.app</string>
<string>aliasvault</string>
</array>
<key>CFBundleURLName</key>
<string>net.aliasvault.app</string>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>net.aliasvault.app</string>
</array>
</dict>
@@ -72,6 +79,8 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>AliasVault supports scanning QR codes for logging into your vault on web or desktop without re-entering your master password.</string>
<key>NSFaceIDUsageDescription</key>
<string>AliasVault uses Face ID to securely store your encryption key, allowing you to access your encrypted data with facial authentication.</string>
<key>NSUserActivityTypes</key>

View File

@@ -231,6 +231,12 @@
[vaultManager clearUsername:resolve rejecter:reject];
}
// MARK: - Server Version Management
- (void)isServerVersionGreaterThanOrEqualTo:(NSString *)targetVersion resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager isServerVersionGreaterThanOrEqualTo:targetVersion resolver:resolve rejecter:reject];
}
// MARK: - Offline Mode Management
- (void)setOfflineMode:(BOOL)isOffline resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
@@ -273,4 +279,16 @@
[vaultManager showPinSetup:resolve rejecter:reject];
}
// MARK: - Mobile Login
- (void)encryptDecryptionKeyForMobileLogin:(NSString *)publicKeyJWK resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager encryptDecryptionKeyForMobileLogin:publicKeyJWK resolver:resolve rejecter:reject];
}
// MARK: - Re-authentication
- (void)authenticateUser:(NSString *)title subtitle:(NSString *)subtitle resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager authenticateUser:title subtitle:subtitle resolver:resolve rejecter:reject];
}
@end

View File

@@ -649,6 +649,16 @@ public class VaultManager: NSObject {
resolve(nil)
}
// MARK: - Server Version Management
@objc
func isServerVersionGreaterThanOrEqualTo(_ targetVersion: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
let isGreaterOrEqual = vaultStore.isServerVersionGreaterThanOrEqualTo(targetVersion)
resolve(isGreaterOrEqual)
}
// MARK: - Offline Mode Management
@objc
@@ -887,6 +897,89 @@ public class VaultManager: NSObject {
}
}
@objc
func encryptDecryptionKeyForMobileLogin(_ publicKeyJWK: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
do {
// Get the encryption key and encrypt it with the provided public key
let encryptedData = try vaultStore.encryptDecryptionKeyForMobileLogin(publicKeyJWK: publicKeyJWK)
// Return the encrypted data as base64 string
let base64Encrypted = encryptedData.base64EncodedString()
resolve(base64Encrypted)
} catch {
reject("ENCRYPTION_ERROR", "Failed to encrypt decryption key: \(error.localizedDescription)", error)
}
}
@objc
func authenticateUser(_ title: String?,
subtitle: String?,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
// Check if PIN is enabled first
let pinEnabled = vaultStore.isPinEnabled()
if pinEnabled {
// PIN is enabled, show PIN unlock UI
DispatchQueue.main.async { [weak self] in
guard let self = self else {
reject("INTERNAL_ERROR", "VaultManager instance deallocated", nil)
return
}
// Get the root view controller from React Native
guard let rootVC = RCTPresentedViewController() else {
reject("NO_VIEW_CONTROLLER", "No view controller available", nil)
return
}
// Create PIN unlock view with ViewModel
// Use custom title/subtitle if provided, otherwise use defaults
let customTitle = (title?.isEmpty == false) ? title : nil
let customSubtitle = (subtitle?.isEmpty == false) ? subtitle : nil
let viewModel = PinUnlockViewModel(
pinLength: self.vaultStore.getPinLength(),
customTitle: customTitle,
customSubtitle: customSubtitle,
unlockHandler: { [weak self] pin in
guard let self = self else {
throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "VaultManager instance deallocated"])
}
// Unlock vault with PIN (just validates, doesn't store in memory)
_ = try self.vaultStore.unlockWithPin(pin)
// Success - dismiss and resolve
await MainActor.run {
rootVC.dismiss(animated: true) {
resolve(true)
}
}
},
cancelHandler: {
// User cancelled - dismiss and resolve with false
rootVC.dismiss(animated: true) {
resolve(false)
}
}
)
let pinView = PinUnlockView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: pinView)
// Present modally as full screen
hostingController.modalPresentationStyle = .fullScreen
rootVC.present(hostingController, animated: true)
}
} else {
// Use biometric authentication
let authenticated = vaultStore.issueBiometricAuthentication(title: title)
resolve(authenticated)
}
}
@objc
func requiresMainQueueSetup() -> Bool {
return false

View File

@@ -272,6 +272,10 @@ PODS:
- ExpoModulesCore
- ExpoBlur (14.1.5):
- ExpoModulesCore
- ExpoCamera (16.1.11):
- ExpoModulesCore
- ZXingObjC/OneD
- ZXingObjC/PDF417
- ExpoClipboard (7.1.5):
- ExpoModulesCore
- ExpoDocumentPicker (13.1.6):
@@ -2445,6 +2449,11 @@ PODS:
- SwiftLint (0.59.1)
- SWXMLHash (7.0.2)
- Yoga (0.0.0)
- ZXingObjC/Core (3.6.9)
- ZXingObjC/OneD (3.6.9):
- ZXingObjC/Core
- ZXingObjC/PDF417 (3.6.9):
- ZXingObjC/Core
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
@@ -2459,6 +2468,7 @@ DEPENDENCIES:
- expo-dev-menu-interface (from `../node_modules/expo-dev-menu-interface/ios`)
- ExpoAsset (from `../node_modules/expo-asset/ios`)
- ExpoBlur (from `../node_modules/expo-blur/ios`)
- ExpoCamera (from `../node_modules/expo-camera/ios`)
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
@@ -2572,6 +2582,7 @@ SPEC REPOS:
- SQLite.swift
- SwiftLint
- SWXMLHash
- ZXingObjC
EXTERNAL SOURCES:
boost:
@@ -2598,6 +2609,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-asset/ios"
ExpoBlur:
:path: "../node_modules/expo-blur/ios"
ExpoCamera:
:path: "../node_modules/expo-camera/ios"
ExpoClipboard:
:path: "../node_modules/expo-clipboard/ios"
ExpoDocumentPicker:
@@ -2807,6 +2820,7 @@ SPEC CHECKSUMS:
expo-dev-menu-interface: 609c35ae8b97479cdd4c9e23c8cf6adc44beea0e
ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6
ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9
ExpoCamera: e1879906d41184e84b57d7643119f8509414e318
ExpoClipboard: 436f6de6971f14eb75ae160e076d9cb3b19eb795
ExpoDocumentPicker: b263a279685b6640b8c8bc70d71c83067aeaae55
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
@@ -2911,6 +2925,7 @@ SPEC CHECKSUMS:
SwiftLint: 3d48e2fb2a3468fdaccf049e5e755df22fb40c2c
SWXMLHash: dd733a457e9c4fe93b1538654057aefae4acb382
Yoga: dc7c21200195acacb62fa920c588e7c2106de45e
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: ac288e273086bafdd610cafff08ccca0d164f7c3

View File

@@ -4,7 +4,7 @@ public struct VaultMetadata: Codable {
public var publicEmailDomains: [String]?
public var privateEmailDomains: [String]?
public var vaultRevisionNumber: Int
public init(publicEmailDomains: [String]? = nil, privateEmailDomains: [String]? = nil, vaultRevisionNumber: Int) {
self.publicEmailDomains = publicEmailDomains
self.privateEmailDomains = privateEmailDomains

View File

@@ -16,6 +16,7 @@ public struct VaultConstants {
static let usernameKey = "aliasvault_username"
static let offlineModeKey = "aliasvault_offline_mode"
static let pinEnabledKey = "aliasvault_pin_enabled"
static let serverVersionKey = "aliasvault_server_version"
static let defaultAutoLockTimeout: Int = 3600 // 1 hour in seconds
}

View File

@@ -2,9 +2,10 @@ import Foundation
/// Utility for comparing semantic version strings
public enum VersionComparison {
/// Checks if version1 is greater than or equal to version2, following SemVer rules.
/// Checks if version1 is greater than or equal to version2, ignoring pre-release suffixes.
///
/// Pre-release versions (e.g., -alpha, -beta, -dev) are considered lower than release versions.
/// Pre-release suffixes (e.g., -alpha, -beta, -dev) are stripped from version1 before comparison.
/// This allows server versions like "0.25.0-alpha-dev" to be treated as "0.25.0".
///
/// - Parameters:
/// - version1: First version string (e.g., "1.2.3" or "1.2.3-beta")
@@ -14,25 +15,22 @@ public enum VersionComparison {
/// - Example:
/// ```swift
/// VersionComparison.isGreaterThanOrEqualTo("1.2.3", "1.2.0") // true
/// VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // false
/// VersionComparison.isGreaterThanOrEqualTo("1.2.0", "1.2.0-alpha") // true
/// VersionComparison.isGreaterThanOrEqualTo("1.2.0-alpha", "1.2.0") // true (ignores -alpha)
/// VersionComparison.isGreaterThanOrEqualTo("1.2.0-dev", "1.2.1") // false (0.25.0 < 0.25.1)
/// ```
public static func isGreaterThanOrEqualTo(_ version1: String, _ version2: String) -> Bool {
// Split versions into core and pre-release parts
// Strip pre-release suffix from version1 (server version)
let components1 = version1.split(separator: "-", maxSplits: 1)
let components2 = version2.split(separator: "-", maxSplits: 1)
let core1 = String(components1[0])
let core2 = String(components2[0])
let preRelease1 = components1.count > 1 ? String(components1[1]) : nil
let preRelease2 = components2.count > 1 ? String(components2[1]) : nil
// Parse core version numbers
let parts1 = core1.split(separator: ".").compactMap { Int($0) }
let parts2 = core2.split(separator: ".").compactMap { Int($0) }
// Compare core versions first
// Compare core versions only
let maxLength = max(parts1.count, parts2.count)
for iVal in 0..<maxLength {
let part1 = iVal < parts1.count ? parts1[iVal] : 0
@@ -46,20 +44,8 @@ public enum VersionComparison {
}
}
// If core versions are equal, check pre-release versions
// No pre-release (release version) is greater than pre-release version
if preRelease1 == nil && preRelease2 != nil {
return true
}
if preRelease1 != nil && preRelease2 == nil {
return false
}
if preRelease1 == nil && preRelease2 == nil {
return true
}
// Both have pre-release versions, compare them lexically
return preRelease1! >= preRelease2!
// Core versions are equal
return true
}
/// Checks if a given server version meets the minimum requirement

View File

@@ -41,4 +41,46 @@ extension VaultStore {
public func getAuthMethods() -> AuthMethods {
return self.enabledAuthMethods
}
/// Authenticate the user using biometric authentication only
/// Note: This method only handles biometric authentication.
/// Returns true if authentication succeeded, false otherwise
/// - Parameter title: The title for authentication. Optional, defaults to "Unlock Vault" context.
public func issueBiometricAuthentication(title: String?) -> Bool {
// Use title if provided, otherwise default
let authReason = (title?.isEmpty == false) ? title! : "Unlock Vault"
// Check if biometric authentication is enabled
guard self.enabledAuthMethods.contains(.faceID) else {
print("No authentication method enabled")
return false
}
let context = LAContext()
var error: NSError?
// Check if biometric authentication is available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
print("Biometric authentication not available: \(error?.localizedDescription ?? "unknown error")")
return false
}
// Perform biometric authentication synchronously
var authenticated = false
let semaphore = DispatchSemaphore(value: 0)
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: authReason
) { success, authError in
authenticated = success
if let authError = authError {
print("Biometric authentication failed: \(authError.localizedDescription)")
}
semaphore.signal()
}
semaphore.wait()
return authenticated
}
}

View File

@@ -85,4 +85,33 @@ extension VaultStore {
public func getOfflineMode() -> Bool {
return userDefaults.bool(forKey: VaultConstants.offlineModeKey)
}
// MARK: - Server Version Storage
/// Set the server API version
public func setServerVersion(_ version: String) {
userDefaults.set(version, forKey: VaultConstants.serverVersionKey)
userDefaults.synchronize()
}
/// Get the server API version
public func getServerVersion() -> String? {
return userDefaults.string(forKey: VaultConstants.serverVersionKey)
}
/// Clear the server version
public func clearServerVersion() {
userDefaults.removeObject(forKey: VaultConstants.serverVersionKey)
userDefaults.synchronize()
}
/// Check if the stored server version is greater than or equal to the specified version
/// - Parameter targetVersion: The version to compare against (e.g., "0.25.0")
/// - Returns: true if stored server version >= targetVersion, false if server version not available or less than target
public func isServerVersionGreaterThanOrEqualTo(_ targetVersion: String) -> Bool {
guard let serverVersion = getServerVersion() else {
return false // No server version stored yet
}
return VersionComparison.isGreaterThanOrEqualTo(serverVersion, targetVersion)
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
import Security
/// Extension for the VaultStore class to handle RSA public key encryption
extension VaultStore {
/// Encrypts the vault's encryption key using an RSA public key for mobile login
/// This method gets the internal encryption key and encrypts it with the provided public key
/// - Parameter publicKeyJWK: The RSA public key in JWK format (JSON string)
/// - Returns: The encrypted encryption key
public func encryptDecryptionKeyForMobileLogin(publicKeyJWK: String) throws -> Data {
// Get the current encryption key from the vault store
// This will only work if the vault is unlocked (encryption key is in memory)
let encryptionKey = try getEncryptionKey()
// Encrypt the encryption key with the provided public key
return try encryptWithPublicKey(data: encryptionKey, publicKeyJWK: publicKeyJWK)
}
/// Encrypts data using an RSA public key
/// - Parameters:
/// - data: The data to encrypt
/// - publicKeyBase64: The RSA public key in JWK format (JSON string, base64 encoded after conversion)
/// - Returns: The encrypted data
internal func encryptWithPublicKey(data: Data, publicKeyJWK: String) throws -> Data {
// Parse the JWK JSON
guard let jwkData = publicKeyJWK.data(using: .utf8),
let jwk = try? JSONSerialization.jsonObject(with: jwkData) as? [String: Any],
let modulusB64 = jwk["n"] as? String,
let exponentB64 = jwk["e"] as? String else {
throw NSError(domain: "VaultStore", code: 100, userInfo: [NSLocalizedDescriptionKey: "Invalid JWK format"])
}
// Decode modulus and exponent from base64url
guard let modulusData = base64UrlDecode(modulusB64),
let exponentData = base64UrlDecode(exponentB64) else {
throw NSError(domain: "VaultStore", code: 101, userInfo: [NSLocalizedDescriptionKey: "Failed to decode JWK components"])
}
// Create RSA public key
let publicKey = try createPublicKey(modulus: modulusData, exponent: exponentData)
// Encrypt the data using RSA-OAEP with SHA-256
var error: Unmanaged<CFError>?
guard let encryptedData = SecKeyCreateEncryptedData(
publicKey,
.rsaEncryptionOAEPSHA256,
data as CFData,
&error
) as Data? else {
let errorDescription = error?.takeRetainedValue().localizedDescription ?? "Unknown error"
throw NSError(domain: "VaultStore", code: 102, userInfo: [NSLocalizedDescriptionKey: "RSA encryption failed: \(errorDescription)"])
}
return encryptedData
}
/// Creates an RSA public key from modulus and exponent
private func createPublicKey(modulus: Data, exponent: Data) throws -> SecKey {
// Create the key attributes
let keyDict: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits as String: modulus.count * 8
]
// Create the public key data in ASN.1 format
let publicKeyData = createPublicKeyASN1(modulus: modulus, exponent: exponent)
var error: Unmanaged<CFError>?
guard let publicKey = SecKeyCreateWithData(
publicKeyData as CFData,
keyDict as CFDictionary,
&error
) else {
let errorDescription = error?.takeRetainedValue().localizedDescription ?? "Unknown error"
throw NSError(domain: "VaultStore", code: 103, userInfo: [NSLocalizedDescriptionKey: "Failed to create public key: \(errorDescription)"])
}
return publicKey
}
/// Creates ASN.1 DER encoded public key data from modulus and exponent
private func createPublicKeyASN1(modulus: Data, exponent: Data) -> Data {
// RSA Public Key ASN.1 structure:
// SEQUENCE {
// SEQUENCE {
// OBJECT IDENTIFIER rsaEncryption
// NULL
// }
// BIT STRING {
// SEQUENCE {
// INTEGER modulus
// INTEGER exponent
// }
// }
// }
var result = Data()
// Inner sequence: modulus and exponent
let modulusEncoded = encodeASN1Integer(modulus)
let exponentEncoded = encodeASN1Integer(exponent)
let innerSequence = encodeASN1Sequence(modulusEncoded + exponentEncoded)
// Bit string containing the inner sequence
let bitString = encodeASN1BitString(innerSequence)
// Algorithm identifier sequence
let algorithmOID = Data([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]) // rsaEncryption OID
let algorithmNull = Data([0x05, 0x00])
let algorithmSequence = encodeASN1Sequence(algorithmOID + algorithmNull)
// Outer sequence
result = encodeASN1Sequence(algorithmSequence + bitString)
return result
}
/// Encodes data as ASN.1 SEQUENCE
private func encodeASN1Sequence(_ data: Data) -> Data {
return encodeASN1(tag: 0x30, data: data)
}
/// Encodes data as ASN.1 INTEGER
private func encodeASN1Integer(_ data: Data) -> Data {
var integerData = data
// Remove leading zeros
while integerData.count > 1 && integerData[0] == 0 {
integerData = integerData.dropFirst()
}
// Add padding byte if the high bit is set (to keep it positive)
if let firstByte = integerData.first, firstByte >= 0x80 {
integerData.insert(0x00, at: 0)
}
return encodeASN1(tag: 0x02, data: integerData)
}
/// Encodes data as ASN.1 BIT STRING
private func encodeASN1BitString(_ data: Data) -> Data {
var bitStringData = Data([0x00]) // No unused bits
bitStringData.append(data)
return encodeASN1(tag: 0x03, data: bitStringData)
}
/// Encodes data with ASN.1 tag and length
private func encodeASN1(tag: UInt8, data: Data) -> Data {
var result = Data([tag])
result.append(encodeASN1Length(data.count))
result.append(data)
return result
}
/// Encodes length in ASN.1 format
private func encodeASN1Length(_ length: Int) -> Data {
if length < 128 {
return Data([UInt8(length)])
}
var lengthBytes = Data()
var len = length
while len > 0 {
lengthBytes.insert(UInt8(len & 0xFF), at: 0)
len >>= 8
}
var result = Data([UInt8(0x80 | lengthBytes.count)])
result.append(lengthBytes)
return result
}
/// Decodes base64url string to Data
private func base64UrlDecode(_ base64url: String) -> Data? {
var base64 = base64url
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Add padding if needed
let remainder = base64.count % 4
if remainder > 0 {
base64 += String(repeating: "=", count: 4 - remainder)
}
return Data(base64Encoded: base64)
}
}

View File

@@ -130,6 +130,9 @@ extension VaultStore {
throw VaultSyncError.serverVersionNotSupported
}
// Store server version in metadata
setServerVersion(status.serverVersion)
try validateSrpSalt(status.srpSalt)
return status
}

View File

@@ -46,13 +46,13 @@ public struct PinUnlockView: View {
.padding(.bottom, 12)
// Title
Text(String(localized: "unlock_vault", bundle: locBundle))
Text(viewModel.customTitle ?? String(localized: "unlock_vault", bundle: locBundle))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(colors.text)
.padding(.bottom, 6)
// Subtitle
Text(String(format: String(localized: "enter_pin_to_unlock_vault", bundle: locBundle)))
Text(viewModel.customSubtitle ?? String(format: String(localized: "enter_pin_to_unlock_vault", bundle: locBundle)))
.font(.system(size: 15))
.foregroundColor(colors.text.opacity(0.7))
.multilineTextAlignment(.center)
@@ -219,15 +219,21 @@ public class PinUnlockViewModel: ObservableObject {
@Published public var isUnlocking: Bool = false
public let pinLength: Int?
public let customTitle: String?
public let customSubtitle: String?
private let unlockHandler: (String) async throws -> Void
private let cancelHandler: () -> Void
public init(
pinLength: Int?,
customTitle: String? = nil,
customSubtitle: String? = nil,
unlockHandler: @escaping (String) async throws -> Void,
cancelHandler: @escaping () -> Void
) {
self.pinLength = pinLength
self.customTitle = customTitle
self.customSubtitle = customSubtitle
self.unlockHandler = unlockHandler
self.cancelHandler = cancelHandler
}

View File

@@ -103,7 +103,7 @@ public struct CredentialCard: View {
}
Button(action: {
if let url = URL(string: "net.aliasvault.app://credentials/\(credential.id.uuidString)") {
if let url = URL(string: "aliasvault://credentials/\(credential.id.uuidString)") {
UIApplication.shared.open(url)
}
}, label: {
@@ -111,7 +111,7 @@ public struct CredentialCard: View {
})
Button(action: {
if let url = URL(string: "net.aliasvault.app://credentials/add-edit-page?id=\(credential.id.uuidString)") {
if let url = URL(string: "aliasvault://credentials/add-edit-page?id=\(credential.id.uuidString)") {
UIApplication.shared.open(url)
}
}, label: {

View File

@@ -60,7 +60,7 @@ public struct CredentialProviderView: View {
if !viewModel.isChoosingTextToInsert {
VStack(spacing: 12) {
Button(action: {
var urlString = "net.aliasvault.app://credentials/add-edit-page"
var urlString = "aliasvault://credentials/add-edit-page"
if let serviceUrl = viewModel.serviceUrl {
let encodedUrl = serviceUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
urlString += "?serviceUrl=\(encodedUrl)"
@@ -121,7 +121,7 @@ public struct CredentialProviderView: View {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
var urlString = "net.aliasvault.app://credentials/add-edit-page"
var urlString = "aliasvault://credentials/add-edit-page"
if let serviceUrl = viewModel.serviceUrl {
let encodedUrl = serviceUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
urlString += "?serviceUrl=\(encodedUrl)"

View File

@@ -17,6 +17,7 @@
"@types/jsrsasign": "^10.5.15",
"expo": "^53.0.22",
"expo-blur": "~14.1.5",
"expo-camera": "^16.1.11",
"expo-clipboard": "~7.1.5",
"expo-constants": "~17.1.7",
"expo-dev-client": "~5.1.8",
@@ -8771,6 +8772,26 @@
"react-native": "*"
}
},
"node_modules/expo-camera": {
"version": "16.1.11",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-16.1.11.tgz",
"integrity": "sha512-etA5ZKoC6nPBnWWqiTmlX//zoFZ6cWQCCIdmpUHTGHAKd4qZNCkhPvBWbi8o32pDe57lix1V4+TPFgEcvPwsaA==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-clipboard": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.1.5.tgz",

View File

@@ -38,6 +38,7 @@
"@types/jsrsasign": "^10.5.15",
"expo": "^53.0.22",
"expo-blur": "~14.1.5",
"expo-camera": "^16.1.11",
"expo-clipboard": "~7.1.5",
"expo-constants": "~17.1.7",
"expo-dev-client": "~5.1.8",

View File

@@ -85,6 +85,9 @@ export interface Spec extends TurboModule {
setOfflineMode(isOffline: boolean): Promise<void>;
getOfflineMode(): Promise<boolean>;
// Server version management
isServerVersionGreaterThanOrEqualTo(targetVersion: string): Promise<boolean>;
// Vault sync and mutate
isNewVaultVersionAvailable(): Promise<{ isNewVersionAvailable: boolean; newRevision: number | null }>;
downloadVault(newRevision: number): Promise<boolean>;
@@ -95,6 +98,13 @@ export interface Spec extends TurboModule {
removeAndDisablePin(): Promise<void>;
showPinUnlock(): Promise<void>;
showPinSetup(): Promise<void>;
// Mobile login methods
encryptDecryptionKeyForMobileLogin(publicKeyJWK: string): Promise<string>;
// Re-authentication methods
// Authenticate user with biometric or PIN. If title/subtitle are null/empty, defaults to "Unlock Vault" context.
authenticateUser(title: string | null, subtitle: string | null): Promise<boolean>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeVaultManager');

View File

@@ -0,0 +1,137 @@
import { Href } from 'expo-router';
import { Linking } from 'react-native';
/**
* Post-unlock navigation options.
*/
export type PostUnlockNavigationOptions = {
/**
* Return URL from app context (for reinitialize flow).
*/
returnUrl?: { path: string; params?: Record<string, string> } | null;
/**
* Router instance for navigation.
*/
router: {
replace: (href: Href) => void;
push: (href: Href) => void;
};
/**
* Clear the return URL from app context.
*/
clearReturnUrl?: () => void;
}
/**
* Centralized post-unlock navigation logic.
* Handles pending deep links, return URLs, and default navigation.
* This ensures consistent navigation behavior across all unlock flows:
* - initialize.tsx (cold boot with biometric unlock)
* - unlock.tsx (manual password/PIN unlock)
* - reinitialize.tsx (vault lock due to timeout, requiring unlock again)
*/
export class PostUnlockNavigation {
/**
* Navigate to the appropriate destination after successful vault unlock.
* Priority order:
* 1. Return URL (from reinitialize flow or _layout.tsx)
* 2. Default credentials tab
*/
static navigate(options: PostUnlockNavigationOptions): void {
const { returnUrl, router, clearReturnUrl } = options;
// Priority 1: Handle return URL (from reinitialize flow)
if (returnUrl?.path) {
this.handleReturnUrl(returnUrl, router);
if (clearReturnUrl) {
clearReturnUrl();
}
return;
}
// Priority 2: Default navigation to credentials
router.replace('/(tabs)/credentials');
}
/**
* Handle return URL navigation (from reinitialize flow).
*/
private static handleReturnUrl(
returnUrl: { path: string; params?: Record<string, string> | undefined },
router: PostUnlockNavigationOptions['router']
): void {
// Normalize the path using centralized function
const normalizedPath = this.normalizeDeepLinkPath(returnUrl.path);
const params = returnUrl.params || {};
// Check if this is a detail route (has a sub-page after the tab)
const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/');
const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') &&
!normalizedPath.endsWith('/(tabs)/settings');
if (isCredentialRoute) {
// Navigate to credentials tab first, then push detail page
router.replace('/(tabs)/credentials');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
router.push(targetUrl as Href);
}, 0);
} else if (isSettingsRoute) {
// Navigate to settings tab first, then push detail page
router.replace('/(tabs)/settings');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
router.push(targetUrl as Href);
}, 0);
} else {
// Direct navigation for root tab routes
router.replace({
pathname: normalizedPath as '/',
params: params as Record<string, string>
});
}
}
/**
* Normalize a deep link or path to ensure it has the correct /(tabs)/ prefix.
* Exported for use in _layout.tsx and other navigation logic.
*
* Supports:
* - Action-based URLs: aliasvault://open/mobile-unlock/[id]
* - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page]
*/
private static normalizeDeepLinkPath(urlOrPath: string): string {
// Remove all URL schemes first
let path = urlOrPath
.replace('net.aliasvault.app://', '')
.replace('aliasvault://', '')
.replace('exp+aliasvault://', '');
// If it already has /(tabs)/ prefix, return as is
if (path.startsWith('/(tabs)/')) {
return path;
}
// Handle credential paths
if (path.startsWith('credentials/') || path.includes('/credentials/')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return `/(tabs)${path}`;
}
// Handle settings paths
if (path.startsWith('settings/') || path.startsWith('/settings')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return `/(tabs)${path}`;
}
return path;
}
}

View File

@@ -0,0 +1,126 @@
import NativeVaultManager from '@/specs/NativeVaultManager';
export type AuthMethod = 'faceid' | 'password';
export type UnlockResult = {
success: boolean;
error?: string;
redirectToUnlock?: boolean;
};
/**
* Centralized vault unlock logic that handles both biometric and PIN unlock.
* This utility is used by initialize.tsx, reinitialize.tsx, and unlock.tsx to avoid code duplication.
*/
export class VaultUnlockHelper {
/**
* Attempt to unlock the vault using available authentication methods.
* Tries biometric first (if available), then PIN (if enabled), otherwise indicates manual unlock needed.
*
* @param params Configuration for unlock attempt
* @returns Promise<UnlockResult> indicating success/failure and any actions needed
*/
static async attemptAutomaticUnlock(params: {
enabledAuthMethods: AuthMethod[];
unlockVault: () => Promise<boolean>; // dbContext.unlockVault for biometric
}): Promise<UnlockResult> {
const { enabledAuthMethods, unlockVault } = params;
// Check which authentication methods are available
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
const isPinEnabled = await NativeVaultManager.isPinEnabled();
// Try biometric unlock first (Face ID / Touch ID)
if (isFaceIDEnabled) {
try {
const isUnlocked = await unlockVault();
if (!isUnlocked) {
return {
success: false,
error: 'Biometric unlock failed',
redirectToUnlock: true,
};
}
return { success: true };
} catch (error) {
console.error('Biometric unlock error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Biometric unlock failed',
redirectToUnlock: true,
};
}
}
// Try PIN unlock if biometric is not available
if (isPinEnabled) {
try {
await NativeVaultManager.showPinUnlock();
// Verify vault is now unlocked
const isNowUnlocked = await NativeVaultManager.isVaultUnlocked();
if (!isNowUnlocked) {
return {
success: false,
error: 'PIN unlock failed',
redirectToUnlock: true,
};
}
return { success: true };
} catch (error) {
// User cancelled or PIN unlock failed
// Only log non-cancellation errors to reduce noise
const errorMessage = error instanceof Error ? error.message : 'PIN unlock failed or cancelled';
if (!errorMessage.includes('cancelled')) {
console.error('PIN unlock error:', error);
}
return {
success: false,
error: errorMessage,
redirectToUnlock: true,
};
}
}
// No automatic unlock method available - manual unlock required
return {
success: false,
error: 'No automatic unlock method available',
redirectToUnlock: true,
};
}
/**
* Authenticate user for a specific action (e.g., mobile unlock confirmation).
* Uses the native authenticateUser which automatically detects and uses the appropriate method.
*
* @param title Authentication prompt title
* @param subtitle Authentication prompt subtitle
* @returns Promise<boolean> indicating if authentication succeeded
*/
static async authenticateForAction(
title: string,
subtitle: string
): Promise<boolean> {
try {
const authenticated = await NativeVaultManager.authenticateUser(title, subtitle);
return authenticated;
} catch (error) {
console.error('Authentication error:', error);
return false;
}
}
/**
* Check if any automatic unlock method is available.
* @param enabledAuthMethods The enabled authentication methods
* @returns Promise<boolean> indicating if automatic unlock is possible
*/
static async hasAutomaticUnlockMethod(
enabledAuthMethods: AuthMethod[]
): Promise<boolean> {
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
const isPinEnabled = await NativeVaultManager.isPinEnabled();
return isFaceIDEnabled || isPinEnabled;
}
}

View File

@@ -103,15 +103,19 @@ type ValidateLoginRequest2Fa = {
clientPublicEphemeral: string;
clientSessionProof: string;
};
/**
* Token model type.
*/
type TokenModel = {
token: string;
refreshToken: string;
};
/**
* Validate login response type.
*/
type ValidateLoginResponse = {
requiresTwoFactor: boolean;
token?: {
token: string;
refreshToken: string;
};
token?: TokenModel;
serverSessionProof: string;
};
@@ -350,6 +354,10 @@ declare enum AuthEventType {
* Represents a user logout event.
*/
Logout = 3,
/**
* Represents a mobile login attempt (login via QR code from mobile app).
*/
MobileLogin = 4,
/**
* Represents JWT access token refresh event issued by client to API.
*/
@@ -380,4 +388,35 @@ declare enum AuthEventType {
AccountDeletion = 99
}
export { type ApiErrorResponse, AuthEventType, type AuthLogModel, type BadRequestResponse, type DeleteAccountInitiateRequest, type DeleteAccountInitiateResponse, type DeleteAccountRequest, type Email, type EmailAttachment, type FaviconExtractModel, type LoginRequest, type LoginResponse, type MailboxBulkRequest, type MailboxBulkResponse, type MailboxEmail, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse };
/**
* Mobile login initiate request type.
*/
type MobileLoginInitiateRequest = {
clientPublicKey: string;
};
/**
* Mobile login initiate response type.
*/
type MobileLoginInitiateResponse = {
requestId: string;
};
/**
* Mobile login submit request type.
*/
type MobileLoginSubmitRequest = {
requestId: string;
encryptedDecryptionKey: string;
};
/**
* Mobile login poll response type.
*/
type MobileLoginPollResponse = {
fulfilled: boolean;
encryptedSymmetricKey: string | null;
encryptedToken: string | null;
encryptedRefreshToken: string | null;
encryptedDecryptionKey: string | null;
encryptedUsername: string | null;
};
export { type ApiErrorResponse, AuthEventType, type AuthLogModel, type BadRequestResponse, type DeleteAccountInitiateRequest, type DeleteAccountInitiateResponse, type DeleteAccountRequest, type Email, type EmailAttachment, type FaviconExtractModel, type LoginRequest, type LoginResponse, type MailboxBulkRequest, type MailboxBulkResponse, type MailboxEmail, type MobileLoginInitiateRequest, type MobileLoginInitiateResponse, type MobileLoginPollResponse, type MobileLoginSubmitRequest, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type TokenModel, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse };

View File

@@ -7,6 +7,7 @@ var AuthEventType = /* @__PURE__ */ ((AuthEventType2) => {
AuthEventType2[AuthEventType2["Login"] = 1] = "Login";
AuthEventType2[AuthEventType2["TwoFactorAuthentication"] = 2] = "TwoFactorAuthentication";
AuthEventType2[AuthEventType2["Logout"] = 3] = "Logout";
AuthEventType2[AuthEventType2["MobileLogin"] = 4] = "MobileLogin";
AuthEventType2[AuthEventType2["TokenRefresh"] = 10] = "TokenRefresh";
AuthEventType2[AuthEventType2["PasswordReset"] = 20] = "PasswordReset";
AuthEventType2[AuthEventType2["PasswordChange"] = 21] = "PasswordChange";

View File

@@ -0,0 +1,26 @@
//-----------------------------------------------------------------------
// <copyright file="MobileLoginRequestWithUsername.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Models;
using AliasServerDb;
/// <summary>
/// View model for MobileLoginRequest joined with User to get username.
/// </summary>
public class MobileLoginRequestWithUsername
{
/// <summary>
/// Gets or sets the mobile login request.
/// </summary>
public required MobileLoginRequest Request { get; set; }
/// <summary>
/// Gets or sets the username from the User table via UserId FK.
/// </summary>
public string? Username { get; set; }
}

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="RecentUsageMobileLogins.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Models;
/// <summary>
/// Model representing IP addresses with mobile login request counts.
/// </summary>
public class RecentUsageMobileLogins
{
/// <summary>
/// Gets or sets the anonymized IP address (last octet masked).
/// </summary>
public string IpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the original IP address for linking purposes.
/// </summary>
public string OriginalIpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the count of mobile login requests from this IP in the last 72 hours.
/// </summary>
public int MobileLoginCount72h { get; set; }
}

View File

@@ -26,4 +26,9 @@ public class RecentUsageStatistics
/// Gets or sets the list of IP addresses with most registrations in the last 72 hours.
/// </summary>
public List<RecentUsageRegistrations> TopIpsByRegistrations72h { get; set; } = new();
/// <summary>
/// Gets or sets the list of IP addresses with most mobile login requests in the last 72 hours.
/// </summary>
public List<RecentUsageMobileLogins> TopIpsByMobileLogins72h { get; set; } = new();
}

View File

@@ -0,0 +1,72 @@
@using AliasVault.Admin.Main.Models
@using AliasVault.RazorComponents.Tables
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top IP Addresses by Mobile Login Requests (Last 72h)</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">IP addresses with the most mobile login requests in the last 72 hours (last octet anonymized)</p>
</div>
</div>
@if (Data != null && Data.Any())
{
<div class="mb-3">
<Paginator CurrentPage="@CurrentPage" PageSize="@PageSize" TotalRecords="@Data.Count" OnPageChanged="@HandlePageChanged" />
</div>
<div class="overflow-x-auto">
<SortableTable Columns="@_tableColumns">
@foreach (var ip in PagedData)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">
<a href="mobile-login-history?search=@Uri.EscapeDataString(ip.OriginalIpAddress)" class="font-mono text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@ip.IpAddress
</a>
</SortableTableColumn>
<SortableTableColumn>@ip.MobileLoginCount72h.ToString("N0")</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
}
else if (Data != null)
{
<div class="text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No Recent Mobile Logins</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No mobile login requests occurred in the last 72 hours.</p>
</div>
}
else
{
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
}
</div>
@code {
[Parameter]
public List<RecentUsageMobileLogins>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 20;
private IEnumerable<RecentUsageMobileLogins> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageMobileLogins>();
private readonly List<TableColumn> _tableColumns = new()
{
new() { Title = "Client IP Address", PropertyName = "IpAddress", Sortable = false },
new() { Title = "Mobile Logins (72h)", PropertyName = "MobileLoginCount72h", Sortable = false }
};
private void HandlePageChanged(int page)
{
CurrentPage = page;
StateHasChanged();
}
}

View File

@@ -27,6 +27,9 @@
<!-- Top IP Addresses by Registrations ---->
<RecentUsageRegistrationsTable Data="@_recentUsageStats?.TopIpsByRegistrations72h" />
<!-- Top IP Addresses by Mobile Login Requests ---->
<RecentUsageMobileLoginsTable Data="@_recentUsageStats?.TopIpsByMobileLogins72h" />
</div>
@if (_loadingError)

View File

@@ -0,0 +1,382 @@
@page "/mobile-login-history"
@using AliasVault.Admin.Main.Models
@using AliasVault.RazorComponents.Tables
@using Microsoft.AspNetCore.WebUtilities
@inject NavigationManager NavigationManager
@inherits MainBase
<LayoutPageTitle>Mobile Login History</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"Mobile Login History ({TotalRecords:N0})" : "Mobile Login History")"
Description="View all 'login with mobile app' login requests, including IP addresses, timestamps, and whether they were fulfilled. Each record represents a single attempt to log in using the mobile app.">
<CustomActions>
<RefreshButton OnClick="() => RefreshData(CancellationToken.None)" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@if (IsInitialized)
{
<div class="px-4">
<ResponsivePaginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-3 flex space-x-4">
<div class="w-3/4">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search by username or IP address..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
<div class="w-1/4">
<select @bind="SelectedStatusFilter" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
<option value="">All Requests</option>
<option value="retrieved">Retrieved</option>
<option value="fulfilled">Fulfilled</option>
<option value="pending">Pending</option>
</select>
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="px-4">
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var request in RequestList)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@request.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</SortableTableColumn>
<SortableTableColumn>
<span class="font-mono text-sm">@(request.ClientIpAddress ?? "N/A")</span>
</SortableTableColumn>
<SortableTableColumn>
<span class="font-mono text-sm">@(request.MobileIpAddress ?? "N/A")</span>
</SortableTableColumn>
<SortableTableColumn>
@if (request.FulfilledAt.HasValue)
{
<span class="text-sm">@request.FulfilledAt.Value.ToString("yyyy-MM-dd HH:mm:ss")</span>
}
else
{
<span class="text-gray-500 dark:text-gray-400 text-sm italic">-</span>
}
</SortableTableColumn>
<SortableTableColumn>
@if (request.RetrievedAt.HasValue)
{
<span class="text-sm">@request.RetrievedAt.Value.ToString("yyyy-MM-dd HH:mm:ss")</span>
}
else
{
<span class="text-gray-500 dark:text-gray-400 text-sm italic">-</span>
}
</SortableTableColumn>
<SortableTableColumn>
@if (!string.IsNullOrEmpty(request.Username))
{
<a href="users/@request.UserId" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
@request.Username
</a>
}
else
{
<span class="text-gray-500 dark:text-gray-400 italic">-</span>
}
</SortableTableColumn>
<SortableTableColumn>
@if (request.RetrievedAt.HasValue)
{
<StatusPill Color="green" Enabled="true" TextTrue="Retrieved" />
}
else if (request.FulfilledAt.HasValue)
{
<StatusPill Color="yellow" Enabled="true" TextTrue="Fulfilled" />
}
else
{
<StatusPill Color="red" Enabled="false" TextFalse="Pending" />
}
</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
}
@code {
private readonly List<TableColumn> _tableColumns = [
new TableColumn { Title = "Created At", PropertyName = "CreatedAt" },
new TableColumn { Title = "Client IP", PropertyName = "ClientIpAddress" },
new TableColumn { Title = "Mobile IP", PropertyName = "MobileIpAddress" },
new TableColumn { Title = "Fulfilled At", PropertyName = "FulfilledAt" },
new TableColumn { Title = "Retrieved At", PropertyName = "RetrievedAt" },
new TableColumn { Title = "Username", PropertyName = "Username" },
new TableColumn { Title = "Status", Sortable = false },
];
private List<MobileLoginRequestModel> RequestList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
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 CancellationTokenSource? _searchCancellationTokenSource;
private string _lastSearchTerm = string.Empty;
private string SearchTerm
{
get => _searchTerm;
set
{
if (_searchTerm != value)
{
_searchTerm = value;
_searchCancellationTokenSource?.Cancel();
_searchCancellationTokenSource = new CancellationTokenSource();
_ = RefreshData(_searchCancellationTokenSource.Token);
}
}
}
private string _selectedStatusFilter = string.Empty;
private string _lastSelectedStatusFilter = string.Empty;
private string SelectedStatusFilter
{
get => _selectedStatusFilter;
set
{
if (_selectedStatusFilter != value)
{
_selectedStatusFilter = value;
_searchCancellationTokenSource?.Cancel();
_searchCancellationTokenSource = new CancellationTokenSource();
_ = RefreshData(_searchCancellationTokenSource.Token);
}
}
}
private string SortColumn { get; set; } = "CreatedAt";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
private async Task HandleSortChanged((string column, SortDirection direction) sort)
{
SortColumn = sort.column;
SortDirection = sort.direction;
await RefreshData(CancellationToken.None);
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "users" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Mobile Login History" });
// Check for search query parameter
var uri = new Uri(NavigationManager.Uri);
var queryParams = QueryHelpers.ParseQuery(uri.Query);
if (queryParams.TryGetValue("search", out var search))
{
_searchTerm = search.ToString();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await RefreshData(CancellationToken.None);
}
}
private void HandlePageChanged(int newPage)
{
CurrentPage = newPage;
_ = RefreshData(CancellationToken.None);
}
private async Task RefreshData(CancellationToken cancellationToken = default)
{
try
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken);
// Join with AliasVaultUsers table to get username via UserId FK
var query = from request in dbContext.MobileLoginRequests
join user in dbContext.AliasVaultUsers on request.UserId equals user.Id into userJoin
from user in userJoin.DefaultIfEmpty()
select new MobileLoginRequestWithUsername
{
Request = request,
Username = user != null ? user.UserName : null
};
query = ApplySearchFilter(query);
query = ApplyStatusFilter(query);
query = ApplySort(query);
TotalRecords = await query.CountAsync(cancellationToken);
var requests = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.Select(r => new
{
r.Request.CreatedAt,
r.Request.ClientIpAddress,
r.Request.MobileIpAddress,
r.Request.FulfilledAt,
r.Request.RetrievedAt,
r.Username,
r.Request.UserId
})
.ToListAsync(cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
RequestList = requests.Select(r => new MobileLoginRequestModel
{
CreatedAt = r.CreatedAt,
ClientIpAddress = r.ClientIpAddress,
MobileIpAddress = r.MobileIpAddress,
FulfilledAt = r.FulfilledAt,
RetrievedAt = r.RetrievedAt,
Username = r.Username,
UserId = r.UserId
}).ToList();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
catch (OperationCanceledException)
{
// Expected when cancellation is requested, do nothing
}
}
private IQueryable<MobileLoginRequestWithUsername> ApplySearchFilter(IQueryable<MobileLoginRequestWithUsername> query)
{
if (SearchTerm.Length > 0)
{
// Reset page number back to 1 if the search term has changed
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(r =>
(r.Username != null && EF.Functions.Like(r.Username.ToLower(), "%" + searchTerm + "%")) ||
(r.Request.ClientIpAddress != null && EF.Functions.Like(r.Request.ClientIpAddress.ToLower(), "%" + searchTerm + "%")) ||
(r.Request.MobileIpAddress != null && EF.Functions.Like(r.Request.MobileIpAddress.ToLower(), "%" + searchTerm + "%"))
);
}
return query;
}
private IQueryable<MobileLoginRequestWithUsername> ApplyStatusFilter(IQueryable<MobileLoginRequestWithUsername> query)
{
if (!string.IsNullOrEmpty(SelectedStatusFilter))
{
// Reset page number back to 1 if the filter has changed
if (SelectedStatusFilter != _lastSelectedStatusFilter && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSelectedStatusFilter = SelectedStatusFilter;
switch (SelectedStatusFilter)
{
case "retrieved":
query = query.Where(r => r.Request.RetrievedAt != null);
break;
case "fulfilled":
query = query.Where(r => r.Request.FulfilledAt != null && r.Request.RetrievedAt == null);
break;
case "pending":
query = query.Where(r => r.Request.FulfilledAt == null);
break;
}
}
return query;
}
private IQueryable<MobileLoginRequestWithUsername> ApplySort(IQueryable<MobileLoginRequestWithUsername> query)
{
switch (SortColumn)
{
case "CreatedAt":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Request.CreatedAt)
: query.OrderByDescending(x => x.Request.CreatedAt);
break;
case "ClientIpAddress":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Request.ClientIpAddress)
: query.OrderByDescending(x => x.Request.ClientIpAddress);
break;
case "MobileIpAddress":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Request.MobileIpAddress)
: query.OrderByDescending(x => x.Request.MobileIpAddress);
break;
case "FulfilledAt":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Request.FulfilledAt)
: query.OrderByDescending(x => x.Request.FulfilledAt);
break;
case "RetrievedAt":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Request.RetrievedAt)
: query.OrderByDescending(x => x.Request.RetrievedAt);
break;
case "Username":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Username)
: query.OrderByDescending(x => x.Username);
break;
default:
query = query.OrderByDescending(x => x.Request.CreatedAt);
break;
}
return query;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_searchCancellationTokenSource?.Cancel();
_searchCancellationTokenSource?.Dispose();
}
base.Dispose(disposing);
}
public class MobileLoginRequestModel
{
public DateTime CreatedAt { get; set; }
public string? ClientIpAddress { get; set; }
public string? MobileIpAddress { get; set; }
public DateTime? FulfilledAt { get; set; }
public DateTime? RetrievedAt { get; set; }
public string? Username { get; set; }
public string? UserId { get; set; }
}
}

View File

@@ -48,6 +48,12 @@
<input type="number" @bind="Settings.AuthLogRetentionDays" id="authLogRetention" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Number of days to keep auth logs before deletion. Set to 0 to disable automatic cleanup.</p>
</div>
<div>
<label for="mobileLoginLogRetention" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Mobile Login Log Retention (days)</label>
<input type="number" @bind="Settings.MobileLoginLogRetentionDays" id="mobileLoginLogRetention" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Number of days to keep mobile login request logs before deletion. Set to 0 to disable automatic cleanup. </p>
</div>
</div>
</div>

View File

@@ -11,6 +11,12 @@
Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")"
Description="This page shows an overview of all registered users and the associated vaults.">
<CustomActions>
<a href="mobile-login-history" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-200 mr-3">
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
Mobile Login History
</a>
<RefreshButton OnClick="() => RefreshData(CancellationToken.None)" ButtonText="Refresh" />
</CustomActions>
</PageHeader>

View File

@@ -115,6 +115,7 @@ public class StatisticsService
GetTopUsersByAliases72hAsync().ContinueWith(t => stats.TopUsersByAliases72h = t.Result),
GetTopUsersByEmails72hAsync().ContinueWith(t => stats.TopUsersByEmails72h = t.Result),
GetTopIpsByRegistrations72hAsync().ContinueWith(t => stats.TopIpsByRegistrations72h = t.Result),
GetTopIpsByMobileLogins72hAsync().ContinueWith(t => stats.TopIpsByMobileLogins72h = t.Result),
};
await Task.WhenAll(tasks);
@@ -570,4 +571,36 @@ public class StatisticsService
RegistrationCount72h = ip.RegistrationCount72h,
}).ToList();
}
/// <summary>
/// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours.
/// </summary>
/// <returns>List of top IP addresses by mobile login requests.</returns>
private async Task<List<RecentUsageMobileLogins>> GetTopIpsByMobileLogins72hAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
var cutoffDate = DateTime.UtcNow.AddHours(-72);
// Get mobile login requests by client IP
var topIps = await context.MobileLoginRequests
.Where(mlr => mlr.CreatedAt >= cutoffDate &&
mlr.ClientIpAddress != null &&
mlr.ClientIpAddress != "xxx.xxx.xxx.xxx")
.GroupBy(mlr => mlr.ClientIpAddress)
.Select(g => new
{
IpAddress = g.Key,
MobileLoginCount72h = g.Count(),
})
.OrderByDescending(ip => ip.MobileLoginCount72h)
.Take(20)
.ToListAsync();
return topIps.Select(ip => new RecentUsageMobileLogins
{
OriginalIpAddress = ip.IpAddress!,
IpAddress = AnonymizeIpAddress(ip.IpAddress!),
MobileLoginCount72h = ip.MobileLoginCount72h,
}).ToList();
}
}

View File

@@ -15,6 +15,7 @@ using AliasServerDb;
using AliasVault.Api.Helpers;
using AliasVault.Auth;
using AliasVault.Cryptography.Client;
using AliasVault.Cryptography.Server;
using AliasVault.Shared.Core;
using AliasVault.Shared.Models.Enums;
using AliasVault.Shared.Models.WebApi;
@@ -49,6 +50,23 @@ using SecureRemotePassword;
[ApiVersion("1")]
public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config, ServerSettingsService settingsService) : ControllerBase
{
/// <summary>
/// Timeout in minutes for mobile login requests. Clients use 2 minutes for countdown, we use 3 here to give a bit of extra buffer time.
/// Requests older than this will be automatically expired and removed.
/// </summary>
private const int MobileLoginTimeoutMinutes = 10;
/// <summary>
/// Access token validity in minutes.
/// </summary>
/// <remarks>
/// This is the time period for which the access token is valid.
/// It is used to authenticate the user for a limited time
/// and is short-lived by design. With the separate refresh token, the user can request a new access token
/// when this access token expires.
/// </remarks>
private const int AccessTokenValiditySeconds = 600;
/// <summary>
/// Semaphore to prevent concurrent access to the database when generating new tokens for a user.
/// </summary>
@@ -533,6 +551,204 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
latestVaultEncryptionSettings.EncryptionSettings));
}
/// <summary>
/// Initiates a mobile login request by creating a QR code challenge.
/// </summary>
/// <param name="model">The mobile login initiate request model.</param>
/// <returns>IActionResult.</returns>
[HttpPost("mobile-login/initiate")]
[AllowAnonymous]
public async Task<IActionResult> InitiateMobileLogin([FromBody] MobileLoginInitiateRequest model)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
// Generate a unique request ID
var requestId = Guid.NewGuid().ToString("N");
// Create the login request
var loginRequest = new MobileLoginRequest
{
Id = requestId,
ClientPublicKey = model.ClientPublicKey,
CreatedAt = timeProvider.UtcNow,
ClientIpAddress = IpAddressUtility.GetIpFromContext(HttpContext),
};
context.MobileLoginRequests.Add(loginRequest);
await context.SaveChangesAsync();
return Ok(new MobileLoginInitiateResponse
{
RequestId = requestId,
});
}
/// <summary>
/// Polls the status of a mobile login request.
/// </summary>
/// <param name="requestId">The unique identifier for the login request.</param>
/// <returns>IActionResult.</returns>
[HttpGet("mobile-login/poll/{requestId}")]
[AllowAnonymous]
public async Task<IActionResult> PollMobileLogin(string requestId)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == requestId);
// Check if request exists and hasn't expired
if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow)
{
return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
}
// If not fulfilled, return pending status
if (loginRequest.FulfilledAt == null)
{
return Ok(new MobileLoginPollResponse
{
Fulfilled = false,
EncryptedSymmetricKey = null,
EncryptedToken = null,
EncryptedRefreshToken = null,
EncryptedDecryptionKey = null,
EncryptedUsername = null,
});
}
// Check if already retrieved (one-time use protection)
if (loginRequest.RetrievedAt != null)
{
return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
}
// Sanity check: check if user exists using UserId FK
var user = await userManager.FindByIdAsync(loginRequest.UserId!);
if (user == null)
{
await authLoggingService.LogAuthEventFailAsync("n/a", AuthEventType.MobileLogin, AuthFailureReason.InvalidUsername);
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400));
}
// Sanity check: check if the account is blocked.
if (user.Blocked)
{
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.MobileLogin, AuthFailureReason.AccountBlocked);
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_BLOCKED, 400));
}
// Sanity check: check if the account is locked out.
if (await userManager.IsLockedOutAsync(user))
{
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.MobileLogin, AuthFailureReason.AccountLocked);
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_LOCKED, 400));
}
// Generate token for the user
var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: true);
// Get encrypted decryption key from the login request and put it in memory
var encryptedDecryptionKey = loginRequest.EncryptedDecryptionKey!;
// Generate a single symmetric key for encrypting all fields
var symmetricKey = Cryptography.Server.Encryption.GenerateRandomSymmetricKey();
// Encrypt each field with the symmetric key (returns base64)
var encryptedToken = Cryptography.Server.Encryption.SymmetricEncrypt(tokenModel.Token, symmetricKey);
var encryptedRefreshToken = Cryptography.Server.Encryption.SymmetricEncrypt(tokenModel.RefreshToken, symmetricKey);
var encryptedUsername = Cryptography.Server.Encryption.SymmetricEncrypt(user.UserName!, symmetricKey);
// Encrypt the symmetric key with the client's RSA public key (returns base64)
var encryptedSymmetricKey = Cryptography.Server.Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, loginRequest.ClientPublicKey);
// Log successful mobile login authentication
await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.MobileLogin);
// Mark as retrieved and clear sensitive data from database
loginRequest.ClientPublicKey = string.Empty;
loginRequest.EncryptedDecryptionKey = null;
loginRequest.RetrievedAt = timeProvider.UtcNow;
await context.SaveChangesAsync();
// Return response with encrypted symmetric key and encrypted fields
// Client will decrypt username to call /login endpoint for salt and encryption settings
return Ok(new MobileLoginPollResponse
{
Fulfilled = true,
EncryptedSymmetricKey = encryptedSymmetricKey,
EncryptedToken = encryptedToken,
EncryptedRefreshToken = encryptedRefreshToken,
EncryptedDecryptionKey = encryptedDecryptionKey,
EncryptedUsername = encryptedUsername,
});
}
/// <summary>
/// Gets the public key for a mobile login request (for mobile app to encrypt).
/// </summary>
/// <param name="requestId">The unique identifier for the login request.</param>
/// <returns>IActionResult.</returns>
[HttpGet("mobile-login/request/{requestId}")]
[Authorize]
public async Task<IActionResult> GetMobileLoginRequest(string requestId)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == requestId);
// Check if request exists and hasn't expired
if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow)
{
return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
}
// Return only the public key
return Ok(new { clientPublicKey = loginRequest.ClientPublicKey });
}
/// <summary>
/// Submits a mobile login response from the mobile app.
/// </summary>
/// <param name="model">The mobile login submit request model.</param>
/// <returns>IActionResult.</returns>
[HttpPost("mobile-login/submit")]
[Authorize]
public async Task<IActionResult> SubmitMobileLogin([FromBody] MobileLoginSubmitRequest model)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
// Get the authenticated user
var user = await userManager.GetUserAsync(User);
if (user == null)
{
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 401));
}
var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == model.RequestId);
// Check if request exists and hasn't expired
if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow)
{
return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
}
// Check if already fulfilled
if (loginRequest.FulfilledAt != null)
{
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_ALREADY_FULFILLED, 400));
}
// Update the login request with the encrypted key and user ID
loginRequest.EncryptedDecryptionKey = model.EncryptedDecryptionKey;
loginRequest.UserId = user.Id;
loginRequest.FulfilledAt = timeProvider.UtcNow;
loginRequest.MobileIpAddress = IpAddressUtility.GetIpFromContext(HttpContext);
await context.SaveChangesAsync();
return Ok();
}
/// <summary>
/// Confirms the account deletion process.
/// </summary>
@@ -764,7 +980,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
issuer: configuration["Jwt:Issuer"] ?? string.Empty,
audience: configuration["Jwt:Issuer"] ?? string.Empty,
claims: claims,
expires: timeProvider.UtcNow.AddMinutes(10),
expires: timeProvider.UtcNow.AddSeconds(AccessTokenValiditySeconds),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);

View File

@@ -77,9 +77,13 @@ public static class AuthHelper
/// Generate a device identifier based on request headers. This is used to associate refresh tokens
/// with a specific device for a specific user.
///
/// The identifier includes the client type (web app, browser extension, mobile app) to prevent
/// conflicts when a user is logged in on multiple clients from the same browser/device.
/// For example, logging out from the browser extension won't affect the web app session.
///
/// NOTE: current implementation means that only one refresh token can be valid for a
/// specific user/device combo at a time. The identifier generation could be made more unique in the future
/// to prevent any unwanted conflicts.
/// to prevent any potential unwanted conflicts.
/// </summary>
/// <param name="request">The HttpRequest instance for the request that the client used.</param>
/// <returns>Unique device identifier as string.</returns>
@@ -88,7 +92,11 @@ public static class AuthHelper
var userAgent = request.Headers.UserAgent.ToString();
var acceptLanguage = request.Headers.AcceptLanguage.ToString();
var rawIdentifier = $"{userAgent}|{acceptLanguage}";
// Client header is usually formatted like "[client name]-[version]" e.g. "chrome-0.25.0", take only "chrome"
var clientHeader = request.Headers["X-AliasVault-Client"].ToString();
var clientName = clientHeader?.Split('-')[0] ?? "unknown";
var rawIdentifier = $"{clientName}|{userAgent}|{acceptLanguage}";
return rawIdentifier;
}
}

View File

@@ -0,0 +1,319 @@
@using AliasVault.Client.Auth.Models
@using AliasVault.Client.Auth.Services
@using AliasVault.Client.Main.Components.Layout
@using AliasVault.Client.Utilities
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.DependencyInjection
@implements IDisposable
@if (IsOpen)
{
<ModalWrapper>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-80 transition-opacity" @onclick="HandleClose"></div>
<!-- Modal -->
<div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-5 text-left shadow-xl transition-all w-full max-w-md mx-4">
<!-- Close button -->
<button type="button" class="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none" @onclick="HandleClose">
<span class="sr-only">@SharedLocalizer["Close"]</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Content -->
<div class="mt-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
@Title
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
@Description
</p>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-400 text-sm">
@_errorMessage
</div>
}
@if (!string.IsNullOrEmpty(_qrCodeUrl))
{
<div class="flex flex-col items-center mb-4">
<div id="@_qrElementId" data-url="@_qrCodeUrl" class="@(_isLoading ? "hidden" : "") mb-3 p-4 bg-white rounded-lg border-4 border-gray-200 dark:border-gray-600">
<!-- QR code will be rendered here -->
</div>
@if (!_isLoading)
{
<div class="text-gray-700 dark:text-gray-300 text-sm font-medium">
@FormatTime(_timeRemaining)
</div>
}
</div>
}
@if (_isLoading && string.IsNullOrEmpty(_errorMessage))
{
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
}
<button type="button" @onclick="HandleClose" class="mt-4 w-full inline-flex justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500">
@SharedLocalizer["Cancel"]
</button>
</div>
</div>
</ModalWrapper>
}
@code {
private string? _qrCodeUrl;
private MobileLoginErrorCode? _errorCode;
private string? _errorMessage;
private int _timeRemaining = 120; // 2 minutes in seconds
private MobileLoginUtility? _mobileLoginUtility;
private bool _isLoading = true;
private System.Threading.Timer? _countdownTimer;
private string _qrElementId = $"mobile-unlock-qr-{Guid.NewGuid():N}";
[Inject]
private HttpClient Http { get; set; } = default!;
[Inject]
private JsInteropService JsInteropService { get; set; } = default!;
[Inject]
private ILogger<MobileUnlockModal> Logger { get; set; } = default!;
[Inject]
private IStringLocalizerFactory LocalizerFactory { get; set; } = default!;
[Inject]
private IServiceProvider ScopedServices { get; set; } = default!;
private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
private IStringLocalizer MobileLoginLocalizer => LocalizerFactory.Create("MobileLogin", "AliasVault.Client");
/// <summary>
/// Whether the modal is open.
/// </summary>
[Parameter]
public bool IsOpen { get; set; }
/// <summary>
/// Callback when the modal is closed.
/// </summary>
[Parameter]
public EventCallback OnClose { get; set; }
/// <summary>
/// Callback when mobile login/unlock succeeds with the result.
/// </summary>
[Parameter]
public EventCallback<MobileLoginResult> OnSuccess { get; set; }
/// <summary>
/// Mode - 'login' or 'unlock'.
/// </summary>
[Parameter]
public string Mode { get; set; } = "login";
/// <summary>
/// Modal title based on mode.
/// </summary>
private string Title => Mode == "unlock"
? LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["UnlockTitle"]
: LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["PageTitle"];
/// <summary>
/// Modal description based on mode.
/// </summary>
private string Description => LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["ScanQrCodeDescription"];
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (IsOpen && _mobileLoginUtility == null)
{
await InitiateMobileLoginAsync();
}
else if (!IsOpen)
{
Cleanup();
}
}
/// <summary>
/// Initialize mobile login when modal opens.
/// </summary>
private async Task InitiateMobileLoginAsync()
{
try
{
_isLoading = true;
_errorCode = null;
_errorMessage = null;
_qrCodeUrl = null;
_timeRemaining = 120;
StateHasChanged();
// Initialize mobile login utility
var utilityLogger = ScopedServices.GetRequiredService<ILogger<MobileLoginUtility>>();
_mobileLoginUtility = new MobileLoginUtility(Http, JsInteropService, utilityLogger);
// Initiate mobile login and get QR code data
var requestId = await _mobileLoginUtility.InitiateAsync();
// Generate QR code with AliasVault prefix for mobile login
_qrCodeUrl = $"aliasvault://open/mobile-unlock/{requestId}";
// Render QR code while showing loading
StateHasChanged();
await Task.Delay(100); // Give DOM time to render
await JsInteropService.GenerateQrCode(_qrElementId);
// Wait for QR code to be fully rendered before hiding loading
await Task.Delay(300);
_isLoading = false;
StateHasChanged();
// Start countdown timer
StartCountdownTimer();
// Start polling for response
await _mobileLoginUtility.StartPollingAsync(
HandleSuccessAsync,
HandleErrorAsync);
}
catch (MobileLoginException ex)
{
Logger.LogError(ex, "Error initiating mobile login");
_isLoading = false;
_errorCode = ex.ErrorCode;
_errorMessage = GetErrorMessage(ex.ErrorCode);
StateHasChanged();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error initiating mobile login");
_isLoading = false;
_errorCode = MobileLoginErrorCode.Generic;
_errorMessage = GetErrorMessage(MobileLoginErrorCode.Generic);
StateHasChanged();
}
}
/// <summary>
/// Handle successful mobile login/unlock.
/// </summary>
private async Task HandleSuccessAsync(MobileLoginResult result)
{
try
{
// Call parent success callback
await OnSuccess.InvokeAsync(result);
// Close modal after successful processing
await HandleClose();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error handling mobile login success");
_errorMessage = SharedLocalizer["ErrorUnknown"];
StateHasChanged();
}
}
/// <summary>
/// Handle error.
/// </summary>
private void HandleErrorAsync(MobileLoginErrorCode errorCode)
{
_isLoading = false;
_qrCodeUrl = null; // Hide QR code when error occurs
_errorCode = errorCode;
_errorMessage = GetErrorMessage(errorCode);
StateHasChanged();
}
/// <summary>
/// Get translated error message for error code.
/// </summary>
private string GetErrorMessage(MobileLoginErrorCode errorCode)
{
return errorCode switch
{
MobileLoginErrorCode.Timeout => MobileLoginLocalizer["ErrorTimeout"],
MobileLoginErrorCode.Generic => SharedLocalizer["ErrorUnknown"],
_ => SharedLocalizer["ErrorUnknown"]
};
}
/// <summary>
/// Handle modal close.
/// </summary>
private async Task HandleClose()
{
Cleanup();
await OnClose.InvokeAsync();
}
/// <summary>
/// Cleanup resources.
/// </summary>
private void Cleanup()
{
_mobileLoginUtility?.Cleanup();
_mobileLoginUtility?.Dispose();
_mobileLoginUtility = null;
_countdownTimer?.Dispose();
_countdownTimer = null;
_qrCodeUrl = null;
_errorCode = null;
_errorMessage = null;
_timeRemaining = 120;
_isLoading = true;
}
/// <summary>
/// Format time remaining as MM:SS.
/// </summary>
private string FormatTime(int seconds)
{
var mins = seconds / 60;
var secs = seconds % 60;
return $"{mins}:{secs:D2}";
}
/// <summary>
/// Start countdown timer.
/// </summary>
private void StartCountdownTimer()
{
_countdownTimer = new System.Threading.Timer(_ =>
{
if (_timeRemaining > 0)
{
_timeRemaining--;
InvokeAsync(StateHasChanged);
}
else
{
_countdownTimer?.Dispose();
_mobileLoginUtility?.StopPolling();
}
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
/// <inheritdoc />
public void Dispose()
{
Cleanup();
}
}

View File

@@ -0,0 +1,34 @@
//-----------------------------------------------------------------------
// <copyright file="MobileLoginResult.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Auth.Models;
/// <summary>
/// Result of a successful mobile login containing decrypted authentication data.
/// </summary>
public sealed class MobileLoginResult
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public required string Username { get; set; }
/// <summary>
/// Gets or sets the JWT access token.
/// </summary>
public required string Token { get; set; }
/// <summary>
/// Gets or sets the refresh token.
/// </summary>
public required string RefreshToken { get; set; }
/// <summary>
/// Gets or sets the vault decryption key (base64 encoded).
/// </summary>
public required string DecryptionKey { get; set; }
}

View File

@@ -112,10 +112,22 @@ else
<a href="/user/forgot-password" class="ml-auto text-sm text-primary-700 hover:underline dark:text-primary-500">@Localizer["LostPasswordLink"]</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">@Localizer["LoginToAccountButton"]</button>
<div class="flex flex-col gap-4">
<button type="submit" id="login-button" class="w-full px-5 py-2 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 flex items-center justify-center gap-2">
@Localizer["LoginButton"]
</button>
<button type="button" id="mobile-login-button" @onclick="() => _showMobileLoginModal = true" class="hidden md:flex w-full px-5 py-2 text-base font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600 dark:focus:ring-gray-700 items-center justify-center gap-2">
<svg class="w-5 h-5" 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 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
@Localizer["MobileDeviceLink"]
</button>
</div>
@if (Config.PublicRegistrationEnabled)
{
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
@Localizer["NoAccountYetText"] <a href="/user/setup" class="text-primary-700 hover:underline dark:text-primary-500">@Localizer["CreateNewVaultLink"]</a>
</div>
}
@@ -124,6 +136,11 @@ else
<FooterLogin />
<MobileUnlockModal IsOpen="@_showMobileLoginModal"
OnClose="() => _showMobileLoginModal = false"
OnSuccess="HandleMobileLoginSuccess"
Mode="login" />
@code {
private readonly LoginFormModel _loginModel = new();
private readonly LoginModel2Fa _loginModel2Fa = new();
@@ -132,9 +149,11 @@ else
private ServerValidationErrors _serverValidationErrors = new();
private bool _showTwoFactorAuthStep;
private bool _showLoginWithRecoveryCodeStep;
private bool _showMobileLoginModal;
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Auth.Login", "AliasVault.Client");
private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client");
private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
private SrpEphemeral _clientEphemeral = new();
private SrpSession _clientSession = new();
@@ -455,4 +474,78 @@ else
}
}
/// <summary>
/// Handle successful mobile login.
/// </summary>
private async Task HandleMobileLoginSuccess(MobileLoginResult result)
{
_loadingIndicator.Show(Localizer["LoggingInMessage"]);
_serverValidationErrors.Clear();
try
{
// Clear global messages
GlobalNotificationService.ClearMessages();
// Call /login endpoint to retrieve salt and encryption settings
var loginInitiateRequest = new { username = result.Username };
var loginResponse = await Http.PostAsJsonAsync("v1/Auth/login", loginInitiateRequest);
if (!loginResponse.IsSuccessStatusCode)
{
_serverValidationErrors.AddError(Localizer["LoginErrorMessage"]);
return;
}
var loginData = await loginResponse.Content.ReadFromJsonAsync<LoginInitiateResponse>();
if (loginData == null)
{
_serverValidationErrors.AddError(Localizer["LoginErrorMessage"]);
return;
}
// Store the tokens in local storage
await AuthService.StoreAccessTokenAsync(result.Token);
await AuthService.StoreRefreshTokenAsync(result.RefreshToken);
// Convert decryption key from base64 string to byte array
var decryptionKeyBytes = Convert.FromBase64String(result.DecryptionKey);
// Store the encryption key in memory
await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes);
await AuthStateProvider.GetAuthenticationStateAsync();
GlobalNotificationService.ClearMessages();
// Redirect to the page the user was trying to access before if set
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
if (!string.IsNullOrEmpty(localStorageReturnUrl))
{
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
NavigationManager.NavigateTo(localStorageReturnUrl);
}
else
{
NavigationManager.NavigateTo("/");
}
}
#if DEBUG
catch (Exception ex)
{
// If in debug mode show the actual exception.
_serverValidationErrors.AddError(ex.ToString());
}
#else
catch
{
// If in release mode show a generic error.
_serverValidationErrors.AddError(Localizer["LoginErrorMessage"]);
}
#endif
finally
{
_loadingIndicator.Hide();
}
}
}

View File

@@ -46,11 +46,11 @@ else
<ServerValidationErrors @ref="_serverValidationErrors" />
<div class="flex space-x-4">
<button type="button" @onclick="UnlockWithWebAuthn" class="flex-grow inline-flex items-center justify-center px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 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">
<button type="button" @onclick="UnlockWithWebAuthn" class="flex-grow inline-flex items-center justify-center px-5 py-2 text-base font-medium text-center text-white rounded-lg bg-primary-700 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">
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd"></path></svg>
@Localizer["UnlockWithWebAuthn"]
</button>
<button type="button" @onclick="async () => await ShowPasswordLogin()" class="inline-flex items-center justify-center px-5 py-3 text-base font-medium text-center text-gray-900 rounded-lg border border-gray-300 hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 dark:text-white dark:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
<button type="button" @onclick="async () => await ShowPasswordLogin()" class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-center text-gray-900 rounded-lg border border-gray-300 hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 dark:text-white dark:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
@Localizer["UnlockWithPassword"]
</button>
</div>
@@ -72,20 +72,31 @@ else
<ValidationMessage For="() => _unlockModel.Password"/>
</div>
<button type="submit" class="w-full inline-flex items-center justify-center px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 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">
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"></path></svg>
<button type="submit" id="unlock-button" class="w-full px-5 py-2 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 flex items-center justify-center gap-2">
@Localizer["UnlockButton"]
</button>
</EditForm>
<button type="button" id="mobile-unlock-button" @onclick="() => _showMobileUnlockModal = true" class="hidden md:flex w-full px-5 py-2 text-base font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600 dark:focus:ring-gray-700 items-center justify-center gap-2 mt-4">
<svg class="w-5 h-5" 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 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
@Localizer["UnlockWithMobileButton"]
</button>
}
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mt-6">
<div class="text-sm text-center font-medium text-gray-500 dark:text-gray-400 mt-6">
@Localizer["SwitchAccountsText"] <a href="/user/logout" class="text-primary-700 hover:underline dark:text-primary-500">@Localizer["LogOutLink"]</a>
</div>
}
<FooterLogin />
<MobileUnlockModal IsOpen="@_showMobileUnlockModal"
OnClose="() => _showMobileUnlockModal = false"
OnSuccess="HandleMobileUnlockSuccess"
Mode="unlock" />
@code {
/// <summary>
/// Skip automatic WebAuthn unlock during page load if set to true.
@@ -98,12 +109,14 @@ else
private bool IsWebAuthnLoading { get; set; }
private bool ShowWebAuthnButton { get; set; }
private bool IsPasswordFocused { get; set; }
private bool _showMobileUnlockModal;
private readonly UnlockModel _unlockModel = new();
private FullScreenLoadingIndicator _loadingIndicator = new();
private ServerValidationErrors _serverValidationErrors = new();
private PasswordInputField? passwordField;
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Auth.Unlock", "AliasVault.Client");
private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client");
private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -353,4 +366,62 @@ else
// Do nothing
}
}
/// <summary>
/// Handle successful mobile unlock.
/// </summary>
private async Task HandleMobileUnlockSuccess(MobileLoginResult result)
{
_loadingIndicator.Show(Localizer["UnlockingVaultMessage"]);
_serverValidationErrors.Clear();
try
{
await StatusCheck();
// Revoke existing tokens
await AuthService.RemoveTokensAsync();
// Store the new tokens
await AuthService.StoreAccessTokenAsync(result.Token);
await AuthService.StoreRefreshTokenAsync(result.RefreshToken);
// Convert decryption key from base64 string to byte array
var decryptionKeyBytes = Convert.FromBase64String(result.DecryptionKey);
// Store the encryption key in memory
await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes);
await AuthStateProvider.GetAuthenticationStateAsync();
// Redirect to the page the user was trying to access before if set
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
if (!string.IsNullOrEmpty(localStorageReturnUrl))
{
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
NavigationManager.NavigateTo(localStorageReturnUrl);
}
else
{
NavigationManager.NavigateTo("/");
}
}
#if DEBUG
catch (Exception ex)
{
// If in debug mode show the actual exception.
_serverValidationErrors.AddError(ex.ToString());
}
#else
catch
{
// If in release mode show a generic error.
_serverValidationErrors.AddError(Localizer["GenericUnlockError"]);
}
#endif
finally
{
_loadingIndicator.Hide();
}
}
}

View File

@@ -0,0 +1,220 @@
//-----------------------------------------------------------------------
// <copyright file="MobileLoginUtility.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Auth.Services;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AliasVault.Client.Auth.Models;
using AliasVault.Client.Services.JsInterop;
using AliasVault.Client.Utilities;
using AliasVault.Shared.Models.WebApi.Auth;
using Microsoft.Extensions.Logging;
/// <summary>
/// Utility class for logging in with mobile app functionality.
/// </summary>
public sealed class MobileLoginUtility : IDisposable
{
private readonly HttpClient _httpClient;
private readonly JsInteropService _jsInteropService;
private readonly ILogger<MobileLoginUtility> _logger;
private Timer? _pollingTimer;
private string? _requestId;
private string? _privateKey;
private CancellationTokenSource? _cancellationTokenSource;
/// <summary>
/// Initializes a new instance of the <see cref="MobileLoginUtility"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client.</param>
/// <param name="jsInteropService">The JS interop service.</param>
/// <param name="logger">The logger.</param>
public MobileLoginUtility(HttpClient httpClient, JsInteropService jsInteropService, ILogger<MobileLoginUtility> logger)
{
_httpClient = httpClient;
_jsInteropService = jsInteropService;
_logger = logger;
}
/// <summary>
/// Initiates a mobile login request and returns the request ID for QR code generation.
/// </summary>
/// <returns>The request ID.</returns>
/// <exception cref="MobileLoginException">Thrown when the request fails.</exception>
public async Task<string> InitiateAsync()
{
try
{
// Generate RSA key pair
var keyPair = await _jsInteropService.GenerateRsaKeyPair();
_privateKey = keyPair.PrivateKey;
// Send public key to server
var request = new MobileLoginInitiateRequest
{
ClientPublicKey = keyPair.PublicKey,
};
var response = await _httpClient.PostAsJsonAsync("v1/Auth/mobile-login/initiate", request);
if (!response.IsSuccessStatusCode)
{
throw new MobileLoginException(MobileLoginErrorCode.Generic);
}
var result = await response.Content.ReadFromJsonAsync<MobileLoginInitiateResponse>();
if (result == null)
{
throw new MobileLoginException(MobileLoginErrorCode.Generic);
}
_requestId = result.RequestId;
return _requestId;
}
catch (MobileLoginException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initiate mobile login");
throw new MobileLoginException(MobileLoginErrorCode.Generic);
}
}
/// <summary>
/// Starts polling the server for mobile login response.
/// </summary>
/// <param name="onSuccess">Callback for successful authentication with decrypted login result.</param>
/// <param name="onError">Callback for errors with error code.</param>
/// <returns>Task.</returns>
public Task StartPollingAsync(Func<MobileLoginResult, Task> onSuccess, Action<MobileLoginErrorCode> onError)
{
if (string.IsNullOrEmpty(_requestId) || string.IsNullOrEmpty(_privateKey))
{
throw new InvalidOperationException("Must call InitiateAsync() before starting polling");
}
_cancellationTokenSource = new CancellationTokenSource();
// Start polling timer (every 3 seconds)
_pollingTimer = new Timer(async _ => await PollServerAsync(onSuccess, onError), null, TimeSpan.Zero, TimeSpan.FromSeconds(3));
// Auto-stop after 3.5 minutes (adds 1 minute buffer to default 2 minute timer for edge cases)
Task.Delay(TimeSpan.FromSeconds(210), _cancellationTokenSource.Token)
.ContinueWith(
_ =>
{
if (!_cancellationTokenSource.IsCancellationRequested)
{
StopPolling();
onError(MobileLoginErrorCode.Timeout);
}
},
TaskScheduler.Default);
return Task.CompletedTask;
}
/// <summary>
/// Stops polling the server.
/// </summary>
public void StopPolling()
{
_pollingTimer?.Dispose();
_pollingTimer = null;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
/// <summary>
/// Cleans up resources.
/// </summary>
public void Cleanup()
{
StopPolling();
_privateKey = null;
_requestId = null;
}
/// <inheritdoc/>
public void Dispose()
{
Cleanup();
}
private async Task PollServerAsync(Func<MobileLoginResult, Task> onSuccess, Action<MobileLoginErrorCode> onError)
{
if (string.IsNullOrEmpty(_requestId) || _cancellationTokenSource?.IsCancellationRequested == true)
{
return;
}
try
{
var response = await _httpClient.GetAsync($"v1/Auth/mobile-login/poll/{_requestId}");
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
StopPolling();
_privateKey = null;
_requestId = null;
onError(MobileLoginErrorCode.Timeout);
return;
}
throw new InvalidOperationException($"Polling failed: {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<MobileLoginPollResponse>();
if (result?.Fulfilled == true && !string.IsNullOrEmpty(result.EncryptedSymmetricKey))
{
// Stop polling
StopPolling();
// Decrypt the vault decryption key directly with RSA private key
var decryptionKey = await _jsInteropService.DecryptWithPrivateKey(result.EncryptedDecryptionKey!, _privateKey!);
// Decrypt the symmetric key with RSA private key
var symmetricKeyBase64 = await _jsInteropService.DecryptWithPrivateKey(result.EncryptedSymmetricKey, _privateKey!);
// Decrypt all remaining fields using the symmetric key
var token = await _jsInteropService.SymmetricDecrypt(result.EncryptedToken!, symmetricKeyBase64);
var refreshToken = await _jsInteropService.SymmetricDecrypt(result.EncryptedRefreshToken!, symmetricKeyBase64);
var username = await _jsInteropService.SymmetricDecrypt(result.EncryptedUsername!, symmetricKeyBase64);
// Clear sensitive data
_privateKey = null;
_requestId = null;
// Call success callback with decrypted data
await onSuccess(new MobileLoginResult
{
Username = username,
Token = token,
RefreshToken = refreshToken,
DecryptionKey = decryptionKey,
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during mobile login polling");
StopPolling();
_privateKey = null;
_requestId = null;
onError(MobileLoginErrorCode.Generic);
}
}
}

View File

@@ -104,10 +104,6 @@
<value>Log in</value>
<comment>Login button text</comment>
</data>
<data name="LoginToAccountButton" xml:space="preserve">
<value>Login to your account</value>
<comment>Extended login button text</comment>
</data>
<!-- Links -->
<data name="LostPasswordLink" xml:space="preserve">
@@ -126,7 +122,10 @@
<value>Log in with an authenticator code instead.</value>
<comment>Link text for logging in with authenticator</comment>
</data>
<data name="MobileDeviceLink" xml:space="preserve">
<value>Log in using Mobile App</value>
<comment>Link text for mobile device login</comment>
</data>
<!-- Descriptions and help text -->
<data name="TwoFactorAuthenticationDescription" xml:space="preserve">
<value>Your login is protected with an authenticator app. Enter your authenticator code below.</value>

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ErrorTimeout" xml:space="preserve">
<value>Mobile login request timed out. Please reload the page and try again.</value>
</data>
</root>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PageTitle" xml:space="preserve">
<value>Log in using Mobile App</value>
<comment>Page title for mobile unlock feature</comment>
</data>
<data name="UnlockTitle" xml:space="preserve">
<value>Unlock using Mobile App</value>
<comment>Modal title for mobile unlock feature</comment>
</data>
<data name="ScanQrCodeDescription" xml:space="preserve">
<value>Scan this QR code with your AliasVault mobile app to login.</value>
<comment>Description instructing user to scan QR code</comment>
</data>
<data name="ScanQrCodeToUnlock" xml:space="preserve">
<value>Scan this QR code with your AliasVault mobile app to unlock your vault.</value>
<comment>Description instructing user to scan QR code to unlock</comment>
</data>
</root>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PageTitle" xml:space="preserve">
<value>Log in using Mobile App</value>
<comment>Page title for mobile unlock feature</comment>
</data>
<data name="UnlockTitle" xml:space="preserve">
<value>Unlock using Mobile App</value>
<comment>Modal title for mobile unlock feature</comment>
</data>
<data name="ScanQrCodeDescription" xml:space="preserve">
<value>Scan this QR code with your AliasVault mobile app to login and unlock your vault.</value>
<comment>Description instructing user to scan QR code</comment>
</data>
</root>

View File

@@ -122,4 +122,8 @@
<value>An error occurred while processing the login request. Try again (later).</value>
<comment>Generic error message for unlock failures</comment>
</data>
<data name="UnlockWithMobileButton" xml:space="preserve">
<value>Unlock with Mobile App</value>
<comment>Button text for unlocking with mobile app</comment>
</data>
</root>

View File

@@ -215,6 +215,10 @@
<value>An error occurred. Please try again.</value>
<comment>Generic error message</comment>
</data>
<data name="ErrorUnknown" xml:space="preserve">
<value>An unknown error occurred. Please try again.</value>
<comment>Generic unknown error message</comment>
</data>
<data name="ErrorValidation" xml:space="preserve">
<value>Please correct the errors below.</value>
<comment>Validation error message</comment>
@@ -291,6 +295,10 @@
</data>
<!-- General UI text -->
<data name="Or" xml:space="preserve">
<value>or</value>
<comment>Divider text between options</comment>
</data>
<data name="LockVault" xml:space="preserve">
<value>Lock vault</value>
<comment>Tooltip text for lock vault button</comment>

View File

@@ -235,7 +235,7 @@ public sealed class JsInteropService(IJSRuntime jsRuntime)
try
{
// Invoke the JavaScript function and get the result as a byte array
await jsRuntime.InvokeVoidAsync("generateQrCode", "authenticator-uri");
await jsRuntime.InvokeVoidAsync("generateQrCode", elementId);
}
catch (JSException ex)
{

View File

@@ -0,0 +1,25 @@
//-----------------------------------------------------------------------
// <copyright file="MobileLoginErrorCode.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Utilities;
/// <summary>
/// Error codes for mobile login operations.
/// These codes are used to provide translatable error messages to users.
/// </summary>
public enum MobileLoginErrorCode
{
/// <summary>
/// The mobile login request has timed out after 2 minutes.
/// </summary>
Timeout,
/// <summary>
/// A generic error occurred during mobile login.
/// </summary>
Generic,
}

View File

@@ -0,0 +1,32 @@
//-----------------------------------------------------------------------
// <copyright file="MobileLoginException.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Utilities;
using System;
/// <summary>
/// Exception thrown during mobile login operations.
/// Contains a <see cref="MobileLoginErrorCode"/> for translation.
/// </summary>
public class MobileLoginException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MobileLoginException"/> class.
/// </summary>
/// <param name="errorCode">The error code.</param>
public MobileLoginException(MobileLoginErrorCode errorCode)
: base($"Mobile login failed with error code: {errorCode}")
{
ErrorCode = errorCode;
}
/// <summary>
/// Gets the error code.
/// </summary>
public MobileLoginErrorCode ErrorCode { get; }
}

View File

@@ -1515,6 +1515,10 @@ video {
border-width: 2px;
}
.border-4 {
border-width: 4px;
}
.border-b {
border-bottom-width: 1px;
}
@@ -1627,6 +1631,11 @@ video {
border-color: rgb(254 240 138 / var(--tw-border-opacity));
}
.border-red-400 {
--tw-border-opacity: 1;
border-color: rgb(248 113 113 / var(--tw-border-opacity));
}
.bg-amber-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 243 199 / var(--tw-bg-opacity));
@@ -1811,6 +1820,11 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-yellow-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
}
.bg-yellow-50 {
--tw-bg-opacity: 1;
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
@@ -1821,6 +1835,11 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-black {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1829,6 +1848,10 @@ video {
--tw-bg-opacity: 0.75;
}
.bg-opacity-80 {
--tw-bg-opacity: 0.8;
}
.bg-gradient-to-r {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
}
@@ -1958,6 +1981,11 @@ video {
padding-bottom: 1.5rem;
}
.py-8 {
padding-top: 2rem;
padding-bottom: 2rem;
}
.pb-28 {
padding-bottom: 7rem;
}
@@ -2018,6 +2046,10 @@ video {
padding-top: 2rem;
}
.pt-5 {
padding-top: 1.25rem;
}
.text-left {
text-align: left;
}
@@ -2368,11 +2400,20 @@ video {
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.ring-inset {
--tw-ring-inset: inset;
}
.ring-black {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(0 0 0 / var(--tw-ring-opacity));
}
.ring-gray-300 {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
}
.ring-opacity-5 {
--tw-ring-opacity: 0.05;
}
@@ -2715,6 +2756,11 @@ video {
--tw-ring-color: rgb(243 244 246 / var(--tw-ring-opacity));
}
.focus\:ring-gray-200:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity));
}
.focus\:ring-gray-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
@@ -2876,6 +2922,11 @@ video {
border-color: rgb(133 77 14 / var(--tw-border-opacity));
}
.dark\:border-red-700:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(185 28 28 / var(--tw-border-opacity));
}
.dark\:bg-amber-800\/30:is(.dark *) {
background-color: rgb(146 64 14 / 0.3);
}
@@ -3018,6 +3069,10 @@ video {
background-color: rgb(113 63 18 / 0.2);
}
.dark\:bg-red-900\/30:is(.dark *) {
background-color: rgb(127 29 29 / 0.3);
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -3177,6 +3232,11 @@ video {
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
}
.dark\:ring-gray-600:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
}
.dark\:ring-offset-gray-800:is(.dark *) {
--tw-ring-offset-color: #1f2937;
}
@@ -3190,6 +3250,11 @@ video {
color: rgb(248 185 99 / var(--tw-text-opacity));
}
.dark\:hover\:border-gray-600:hover:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
}
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
@@ -3514,6 +3579,10 @@ video {
display: inline;
}
.md\:flex {
display: flex;
}
.md\:hidden {
display: none;
}

View File

@@ -137,6 +137,11 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
/// </summary>
public DbSet<TaskRunnerJob> TaskRunnerJobs { get; set; }
/// <summary>
/// Gets or sets the MobileLoginRequests DbSet.
/// </summary>
public DbSet<MobileLoginRequest> MobileLoginRequests { get; set; }
/// <summary>
/// Sets up the connection string if it is not already configured.
/// </summary>

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