mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-23 22:28:22 -05:00
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:
3
.vscode/AliasVault.code-workspace
vendored
3
.vscode/AliasVault.code-workspace
vendored
@@ -24,6 +24,7 @@
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"java.configuration.updateBuildConfiguration": "disabled"
|
||||
"java.configuration.updateBuildConfiguration": "disabled",
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
255
apps/browser-extension/package-lock.json
generated
255
apps/browser-extension/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ class VaultSync(
|
||||
throw VaultSyncError.ServerVersionNotSupported()
|
||||
}
|
||||
|
||||
// Store server version in metadata
|
||||
metadata.setServerVersion(status.serverVersion)
|
||||
|
||||
validateSrpSalt(status.srpSalt)
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
290
apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx
Normal file
290
apps/mobile-app/app/(tabs)/settings/mobile-unlock/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx
Normal file
110
apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx
Normal file
214
apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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') }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
68
apps/mobile-app/app/open/[...path].tsx
Normal file
68
apps/mobile-app/app/open/[...path].tsx
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
white: '#ffffff',
|
||||
text: '#11181C',
|
||||
textMuted: '#4b5563',
|
||||
background: '#f3f2f7',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
229
apps/mobile-app/context/NavigationContext.tsx
Normal file
229
apps/mobile-app/context/NavigationContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,9 @@ extension VaultStore {
|
||||
throw VaultSyncError.serverVersionNotSupported
|
||||
}
|
||||
|
||||
// Store server version in metadata
|
||||
setServerVersion(status.serverVersion)
|
||||
|
||||
try validateSrpSalt(status.srpSalt)
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
21
apps/mobile-app/package-lock.json
generated
21
apps/mobile-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
137
apps/mobile-app/utils/PostUnlockNavigation.ts
Normal file
137
apps/mobile-app/utils/PostUnlockNavigation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
apps/mobile-app/utils/VaultUnlockHelper.ts
Normal file
126
apps/mobile-app/utils/VaultUnlockHelper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
382
apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor
Normal file
382
apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
64
apps/server/AliasVault.Client/Resources/MobileLogin.en.resx
Normal file
64
apps/server/AliasVault.Client/Resources/MobileLogin.en.resx
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user