diff --git a/adamant-wallets b/adamant-wallets index 8ccedce17..423300ea7 160000 --- a/adamant-wallets +++ b/adamant-wallets @@ -1 +1 @@ -Subproject commit 8ccedce173e4d27adf263f101b0c1c32c6b638e7 +Subproject commit 423300ea73a7855bb456e16bd1f1b0afb35fb95e diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index dae654977..58063972b 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -11,6 +11,8 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-community-file-opener') implementation project(':capacitor-filesystem') + implementation project(':capgo-capacitor-native-biometric') + implementation project(':capacitor-secure-storage-plugin') } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 05e63b05e..dbfd2ae35 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,4 +35,5 @@ + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 77d7d777f..37a8df31c 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -7,3 +7,9 @@ project(':capacitor-community-file-opener').projectDir = new File('../node_modul include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') + +include ':capgo-capacitor-native-biometric' +project(':capgo-capacitor-native-biometric').projectDir = new File('../node_modules/@capgo/capacitor-native-biometric/android') + +include ':capacitor-secure-storage-plugin' +project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android') diff --git a/package-lock.json b/package-lock.json index 87664d5bf..493ea3d35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@capacitor/android": "^7.3.0", "@capacitor/core": "^7.3.0", "@capacitor/filesystem": "^7.1.1", + "@capgo/capacitor-native-biometric": "^7.1.13", "@emoji-mart/data": "^1.2.1", "@klayr/codec": "^0.5.1", "@klayr/cryptography": "^4.1.1", @@ -33,6 +34,7 @@ "bitcoinjs-lib": "^6.1.7", "buffer": "^6.0.3", "bytebuffer": "^5.0.1", + "capacitor-secure-storage-plugin": "^0.12.0", "coininfo": "^5.2.1", "copy-to-clipboard": "^3.3.3", "core-js": "^3.40.0", @@ -2148,6 +2150,15 @@ "integrity": "sha512-7gGvuQ1NlSCwnjdIMkry+/meyUxHTnsVodRxOTOerLAoAyvtSnCp1rKyLjt9kCz9Lf7Y/wUbCe+AWbAvfxL5bA==", "license": "ISC" }, + "node_modules/@capgo/capacitor-native-biometric": { + "version": "7.1.13", + "resolved": "https://registry.npmjs.org/@capgo/capacitor-native-biometric/-/capacitor-native-biometric-7.1.13.tgz", + "integrity": "sha512-1Bh4QhyXczNX1vGrau8EDHEu2sFn5JGHIIwOodI/XO3JYjkqO1kVs6GVVmsJ7ICrqZIR2Z1YO0BWK9f0vR/LRg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@commitlint/cli": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", @@ -9175,6 +9186,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/capacitor-secure-storage-plugin": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.12.0.tgz", + "integrity": "sha512-98rljshpX5uXdxUNc78mhq+nJGsf/hiaE1MfAkDH4s+Gqn6a2/VkxT7Iet5TskAZkc38b85adLljO/eVcrrzdg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", diff --git a/package.json b/package.json index 30ed5f4ba..6ea77dcc5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@capacitor/android": "^7.3.0", "@capacitor/core": "^7.3.0", "@capacitor/filesystem": "^7.1.1", + "@capgo/capacitor-native-biometric": "^7.1.13", "@emoji-mart/data": "^1.2.1", "@klayr/codec": "^0.5.1", "@klayr/cryptography": "^4.1.1", @@ -62,6 +63,7 @@ "bitcoinjs-lib": "^6.1.7", "buffer": "^6.0.3", "bytebuffer": "^5.0.1", + "capacitor-secure-storage-plugin": "^0.12.0", "coininfo": "^5.2.1", "copy-to-clipboard": "^3.3.3", "core-js": "^3.40.0", diff --git a/src/assets/styles/components/_login-form.scss b/src/assets/styles/components/_login-form.scss index 52f4ed86f..dbe89e7c5 100644 --- a/src/assets/styles/components/_login-form.scss +++ b/src/assets/styles/components/_login-form.scss @@ -1,5 +1,6 @@ @use 'sass:map'; @use '../settings/_colors.scss'; +@use '@/assets/styles/generic/_variables.scss'; /** * Centering input text and label. diff --git a/src/components/Container.vue b/src/components/Container.vue index 644ab1022..7da4d3861 100644 --- a/src/components/Container.vue +++ b/src/components/Container.vue @@ -52,6 +52,7 @@ export default defineComponent({ } @media #{map.get(settings.$display-breakpoints, 'sm-and-down')} { + margin-bottom: 40px; &--padding { padding: 0 16px 0 24px; } diff --git a/src/components/SignInOptionsDialog.vue b/src/components/SignInOptionsDialog.vue new file mode 100644 index 000000000..c7196ad3a --- /dev/null +++ b/src/components/SignInOptionsDialog.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/src/components/auth/BiometricLoginForm.vue b/src/components/auth/BiometricLoginForm.vue new file mode 100644 index 000000000..bb2197bea --- /dev/null +++ b/src/components/auth/BiometricLoginForm.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/components/auth/PasskeyLoginForm.vue b/src/components/auth/PasskeyLoginForm.vue new file mode 100644 index 000000000..125bc6e05 --- /dev/null +++ b/src/components/auth/PasskeyLoginForm.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/components/LoginForm.vue b/src/components/auth/PassphraseLoginForm.vue similarity index 89% rename from src/components/LoginForm.vue rename to src/components/auth/PassphraseLoginForm.vue index 7113ac233..266461eb4 100644 --- a/src/components/LoginForm.vue +++ b/src/components/auth/PassphraseLoginForm.vue @@ -2,7 +2,6 @@ - diff --git a/src/components/LoginPasswordForm.vue b/src/components/auth/PasswordLoginForm.vue similarity index 94% rename from src/components/LoginPasswordForm.vue rename to src/components/auth/PasswordLoginForm.vue index a34c0bd02..ddc1310a6 100644 --- a/src/components/LoginPasswordForm.vue +++ b/src/components/auth/PasswordLoginForm.vue @@ -37,10 +37,10 @@

- {{ t('login_via_password.remove_password_hint') }} + {{ t('login.use_passphrase_hint') }}

- {{ t('login_via_password.remove_password') }} + {{ t('login.use_passphrase') }}
@@ -60,6 +60,7 @@ import { mdiEye, mdiEyeOff } from '@mdi/js' import { useSaveCursor } from '@/hooks/useSaveCursor' import { useConsiderOffline } from '@/hooks/useConsiderOffline' import { NodeStatusResult } from '@/lib/nodes/abstract.node' +import { passwordAuth } from '@/lib/auth' const className = 'login-form' const classes = { @@ -106,8 +107,8 @@ const admNodesDisabled = computed(() => admNodes.value.some((node) => node.statu const submit = () => { showSpinner.value = true - return store - .dispatch('loginViaPassword', password.value) + return passwordAuth + .authorizeUser(password.value) .then(() => { emit('login') }) @@ -163,10 +164,6 @@ const removePassword = () => { padding-right: 32px; padding-left: 32px; } - - :deep(input) { - font-size: 16px !important; - } } } diff --git a/src/electron/main.js b/src/electron/main.js index ac119c402..5297c5113 100644 --- a/src/electron/main.js +++ b/src/electron/main.js @@ -66,7 +66,12 @@ function createWindow() { height: 800, minWidth: 380, minHeight: 624, - icon: path.join(__dirname, '/icon.png') + icon: path.join(__dirname, '/icon.png'), + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + webSecurity: true + } }) // You can use `process.env.VITE_DEV_SERVER_URL` when the vite command is called `serve` diff --git a/src/lib/adamant-api/index.d.ts b/src/lib/adamant-api/index.d.ts index d123ffe94..6090fd044 100644 --- a/src/lib/adamant-api/index.d.ts +++ b/src/lib/adamant-api/index.d.ts @@ -200,3 +200,13 @@ export function getChatRoomMessages( params: GetChatRoomMessagesParams, recursive: boolean = false ): Promise>> + +export function saveSecureData( + passphrase: string, + encryptionKey: Uint8Array +): Promise + +export function getSecureData(): Promise<{ + passphrase: string + encryptionKey: Uint8Array +}> diff --git a/src/lib/adamant-api/index.js b/src/lib/adamant-api/index.js index 1f66ec292..30953d1aa 100644 --- a/src/lib/adamant-api/index.js +++ b/src/lib/adamant-api/index.js @@ -1,5 +1,7 @@ import Queue from 'promise-queue' import { Base64 } from 'js-base64' +import { Capacitor } from '@capacitor/core' +import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin' import constants, { Transactions, Delegates, MessageType } from '@/lib/constants' import utils from '@/lib/adamant' @@ -11,12 +13,95 @@ import store from '@/store' import { isStringEqualCI } from '@/lib/textHelpers' import { parseCryptoAddressesKVStxs } from '@/lib/store-crypto-address' import { DEFAULT_TIME_DELTA } from '@/lib/nodes/constants.js' +import { hexToBytes, bytesToHex } from '@/lib/hex' Queue.configure(Promise) /** Promises queue to execute them sequentially */ const queue = new Queue(1, Infinity) +/** + * Encrypts text using Web Crypto API for secure storage + * @param {string} text Text to encrypt + * @returns {Promise} Base64 encoded encrypted data with salt and IV + */ +async function encryptForStorage(text) { + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(`adamant_${window.location.hostname}_v1`), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const salt = crypto.getRandomValues(new Uint8Array(16)) + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 10000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ) + + const iv = crypto.getRandomValues(new Uint8Array(12)) + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + derivedKey, + encoder.encode(text) + ) + + // Combine salt + iv + encrypted data + const combined = new Uint8Array(16 + 12 + encrypted.byteLength) + combined.set(salt, 0) + combined.set(iv, 16) + combined.set(new Uint8Array(encrypted), 28) + + return Base64.fromUint8Array(combined) +} + +/** + * Decrypts text from storage using Web Crypto API + * @param {string} encryptedText Base64 encoded encrypted data + * @returns {Promise} Decrypted text + */ +async function decryptFromStorage(encryptedText) { + const data = Base64.toUint8Array(encryptedText) + const salt = data.slice(0, 16) + const iv = data.slice(16, 28) + const encrypted = data.slice(28) + + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(`adamant_${window.location.hostname}_v1`), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 10000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ) + + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, derivedKey, encrypted) + return new TextDecoder().decode(decrypted) +} + /** @type {{privateKey: Buffer, publicKey: Buffer}} */ let myKeypair = {} let myAddress = null @@ -796,3 +881,39 @@ export async function getChatRoomMessages(address1, address2, paramsArg, recursi return loadMessages(lastOffset) } + +/** + * Saves passphrase to secure storage + * @param {string} passphrase User passphrase to save + * @param {Uint8Array|null|null} customKey + * @returns {Promise} + */ +export async function saveSecureData(passphrase, encryptionKey) { + const data = { passphrase, encryptionKey: bytesToHex(encryptionKey) } + if (Capacitor.isNativePlatform()) { + await SecureStoragePlugin.set({ key: 'adamant_auth_data', value: JSON.stringify(data) }) + } else { + const encrypted = await encryptForStorage(JSON.stringify(data)) + localStorage.setItem('adamant_auth_data', encrypted) + } +} + +/** + * Retrieves passphrase from secure storage + * @returns {Promise} User passphrase + */ +export async function getSecureData() { + let data + if (Capacitor.isNativePlatform()) { + const result = await SecureStoragePlugin.get({ key: 'adamant_auth_data' }) + data = JSON.parse(result.value) + } else { + const encrypted = localStorage.getItem('adamant_auth_data') + const decrypted = await decryptFromStorage(encrypted) + data = JSON.parse(decrypted) + } + return { + passphrase: data.passphrase, + encryptionKey: hexToBytes(data.encryptionKey) + } +} diff --git a/src/lib/auth/BiometricAuthService.ts b/src/lib/auth/BiometricAuthService.ts new file mode 100644 index 000000000..858daea02 --- /dev/null +++ b/src/lib/auth/BiometricAuthService.ts @@ -0,0 +1,48 @@ +import { Capacitor } from '@capacitor/core' +import { NativeBiometric } from '@capgo/capacitor-native-biometric' +import { AuthenticationResult, AuthenticationService, SetupResult } from './types' + +export class BiometricAuthService implements AuthenticationService { + async authorizeUser(): Promise { + if (!Capacitor.isNativePlatform()) { + return AuthenticationResult.Failed + } + + try { + await NativeBiometric.verifyIdentity({ + reason: 'Login to ADAMANT Messenger', + title: 'Biometric Authentication' + }) + return AuthenticationResult.Success + } catch (error: unknown) { + console.error(error) + + const errorMessage = (error as { code: number; message: string }).message + + if (errorMessage.toLowerCase().includes('cancel')) { + return AuthenticationResult.Cancel + } + + return AuthenticationResult.Failed + } + } + + async setupBiometric(): Promise { + if (!Capacitor.isNativePlatform()) { + return SetupResult.Failed + } + + try { + const result = await NativeBiometric.isAvailable() + return result.isAvailable ? SetupResult.Success : SetupResult.Failed + } catch (error: unknown) { + console.error(error) + + if (error instanceof Error && error.message.includes('cancel')) { + return SetupResult.Cancel + } + + return SetupResult.Failed + } + } +} diff --git a/src/lib/auth/PasskeyAuthService.ts b/src/lib/auth/PasskeyAuthService.ts new file mode 100644 index 000000000..6e859a127 --- /dev/null +++ b/src/lib/auth/PasskeyAuthService.ts @@ -0,0 +1,85 @@ +import { Capacitor } from '@capacitor/core' +import { AuthenticationResult, AuthenticationService, SetupResult } from './types' +import store from '@/store' + +export class PasskeyAuthService implements AuthenticationService { + async authorizeUser(): Promise { + if (Capacitor.isNativePlatform()) { + return AuthenticationResult.Failed + } + + if (!('credentials' in navigator && !!window.PublicKeyCredential)) { + return AuthenticationResult.Failed + } + + try { + const rpId = window.location.hostname || 'localhost' + await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rpId, + userVerification: 'preferred', + timeout: 30000 + } + }) + return AuthenticationResult.Success + } catch (error: unknown) { + console.error(error) + + if ( + error instanceof Error && + (error.name === 'NotAllowedError' || error.name === 'AbortError') + ) { + return AuthenticationResult.Cancel + } + + return AuthenticationResult.Failed + } + } + + async setupPasskey(): Promise { + if (Capacitor.isNativePlatform()) { + return SetupResult.Failed + } + + if (!('credentials' in navigator && !!window.PublicKeyCredential)) { + return SetupResult.Failed + } + + try { + const rpId = window.location.hostname || 'localhost' + await navigator.credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { + name: 'ADAMANT Messenger', + id: rpId + }, + user: { + id: crypto.getRandomValues(new Uint8Array(32)), + name: `ADM ${store.state.address || 'User'}`, + displayName: `ADM ${store.state.address || 'User'}` + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + authenticatorSelection: { + userVerification: 'preferred', + residentKey: 'required' + }, + timeout: 30000 + } + }) + return SetupResult.Success + } catch (error: unknown) { + console.error(error) + + if ( + error instanceof Error && + (error.name === 'NotAllowedError' || error.name === 'AbortError') + ) { + return SetupResult.Cancel + } + + return SetupResult.Failed + } + } +} diff --git a/src/lib/auth/PasswordAuthService.ts b/src/lib/auth/PasswordAuthService.ts new file mode 100644 index 000000000..6a7edf12a --- /dev/null +++ b/src/lib/auth/PasswordAuthService.ts @@ -0,0 +1,13 @@ +import { AuthenticationResult, AuthenticationService } from './types' +import store from '@/store' + +/** + * Password-based authentication service + * Uses user-provided password to encrypt/decrypt stored passphrase + */ +export class PasswordAuthService implements AuthenticationService { + async authorizeUser(password: string): Promise { + await store.dispatch('loginViaPassword', password) + return AuthenticationResult.Success + } +} diff --git a/src/lib/auth/__tests__/BiometricAuthService.test.ts b/src/lib/auth/__tests__/BiometricAuthService.test.ts new file mode 100644 index 000000000..9fc50c141 --- /dev/null +++ b/src/lib/auth/__tests__/BiometricAuthService.test.ts @@ -0,0 +1,121 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Capacitor } from '@capacitor/core' +import { NativeBiometric } from '@capgo/capacitor-native-biometric' +import { BiometricAuthService } from '../BiometricAuthService' +import { AuthenticationResult, SetupResult } from '../types' + +vi.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: vi.fn() + } +})) + +vi.mock('@capgo/capacitor-native-biometric', () => ({ + NativeBiometric: { + verifyIdentity: vi.fn(), + isAvailable: vi.fn() + } +})) + +describe('BiometricAuthService', () => { + let service: BiometricAuthService + const mockCapacitor = vi.mocked(Capacitor) + const mockNativeBiometric = vi.mocked(NativeBiometric) + + beforeEach(() => { + service = new BiometricAuthService() + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('authorizeUser', () => { + it('should return Failed when not on native platform', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Failed) + }) + + it('should return Success when biometric verification succeeds', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + mockNativeBiometric.verifyIdentity.mockResolvedValue(undefined) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Success) + expect(mockNativeBiometric.verifyIdentity).toHaveBeenCalledWith({ + reason: 'Login to ADAMANT Messenger', + title: 'Biometric Authentication' + }) + }) + + it('should return Cancel when user cancels authentication', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + const cancelError = { code: 10, message: 'User canceled authentication' } + mockNativeBiometric.verifyIdentity.mockRejectedValue(cancelError) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Cancel) + }) + + it('should return Failed when biometric verification fails', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + const authError = { code: 11, message: 'Authentication failed' } + mockNativeBiometric.verifyIdentity.mockRejectedValue(authError) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Failed) + }) + }) + + describe('setupBiometric', () => { + it('should return Failed when not on native platform', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + + const result = await service.setupBiometric() + + expect(result).toBe(SetupResult.Failed) + }) + + it('should return Success when biometric is available', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + mockNativeBiometric.isAvailable.mockResolvedValue({ + isAvailable: true, + biometryType: 'touchId' as any + }) + + const result = await service.setupBiometric() + + expect(result).toBe(SetupResult.Success) + }) + + it('should return Failed when biometric is not available', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + mockNativeBiometric.isAvailable.mockResolvedValue({ + isAvailable: false, + biometryType: 'none' as any + }) + + const result = await service.setupBiometric() + + expect(result).toBe(SetupResult.Failed) + }) + + it('should return Cancel when setup is cancelled', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + const cancelError = new Error('User cancelled setup') + mockNativeBiometric.isAvailable.mockRejectedValue(cancelError) + + const result = await service.setupBiometric() + + expect(result).toBe(SetupResult.Cancel) + }) + }) +}) \ No newline at end of file diff --git a/src/lib/auth/__tests__/PasskeyAuthService.test.ts b/src/lib/auth/__tests__/PasskeyAuthService.test.ts new file mode 100644 index 000000000..f9fb20c60 --- /dev/null +++ b/src/lib/auth/__tests__/PasskeyAuthService.test.ts @@ -0,0 +1,206 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Capacitor } from '@capacitor/core' +import { PasskeyAuthService } from '../PasskeyAuthService' +import { AuthenticationResult, SetupResult } from '../types' + +vi.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + default: { + state: { + address: 'U12345678901234567890' + } + } +})) + +const mockCrypto = { + getRandomValues: vi.fn((array) => { + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256) + } + return array + }) +} + +const mockCredentials = { + create: vi.fn(), + get: vi.fn() +} + +describe('PasskeyAuthService', () => { + let service: PasskeyAuthService + const mockCapacitor = vi.mocked(Capacitor) + + beforeEach(() => { + service = new PasskeyAuthService() + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + + Object.defineProperty(global, 'crypto', { + value: mockCrypto, + writable: true + }) + + Object.defineProperty(global, 'window', { + value: { + location: { hostname: 'localhost' }, + PublicKeyCredential: class {}, + }, + writable: true + }) + + Object.defineProperty(global, 'navigator', { + value: { + credentials: mockCredentials + }, + writable: true + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('authorizeUser', () => { + it('should return Failed when on native platform', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Failed) + }) + + it('should return Failed when WebAuthn is not supported', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + delete (global.navigator as any).credentials + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Failed) + }) + + it('should return Success when passkey authentication succeeds', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + mockCredentials.get.mockResolvedValue({ id: 'credential-id' }) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Success) + expect(mockCredentials.get).toHaveBeenCalledWith({ + publicKey: { + challenge: expect.any(Uint8Array), + rpId: 'localhost', + userVerification: 'preferred', + timeout: 30000 + } + }) + }) + + it('should return Cancel when user cancels authentication', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + const cancelError = new Error('User cancelled') + cancelError.name = 'NotAllowedError' + mockCredentials.get.mockRejectedValue(cancelError) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Cancel) + }) + + it('should return Cancel when authentication is aborted', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + const abortError = new Error('Operation aborted') + abortError.name = 'AbortError' + mockCredentials.get.mockRejectedValue(abortError) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Cancel) + }) + + it('should return Failed when authentication fails', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + const authError = new Error('Authentication failed') + authError.name = 'SecurityError' + mockCredentials.get.mockRejectedValue(authError) + + const result = await service.authorizeUser() + + expect(result).toBe(AuthenticationResult.Failed) + }) + }) + + describe('setupPasskey', () => { + it('should return Failed when on native platform', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true) + + const result = await service.setupPasskey() + + expect(result).toBe(SetupResult.Failed) + }) + + it('should return Failed when WebAuthn is not supported', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + delete (global.window as any).PublicKeyCredential + + const result = await service.setupPasskey() + + expect(result).toBe(SetupResult.Failed) + }) + + it('should return Success when passkey setup succeeds', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + mockCredentials.create.mockResolvedValue({ id: 'new-credential-id' }) + + const result = await service.setupPasskey() + + expect(result).toBe(SetupResult.Success) + expect(mockCredentials.create).toHaveBeenCalledWith({ + publicKey: { + challenge: expect.any(Uint8Array), + rp: { + name: 'ADAMANT Messenger', + id: 'localhost' + }, + user: { + id: expect.any(Uint8Array), + name: 'ADM U12345678901234567890', + displayName: 'ADM U12345678901234567890' + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + authenticatorSelection: { + userVerification: 'preferred', + residentKey: 'required' + }, + timeout: 30000 + } + }) + }) + + it('should return Cancel when user cancels setup', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + const cancelError = new Error('User cancelled setup') + cancelError.name = 'NotAllowedError' + mockCredentials.create.mockRejectedValue(cancelError) + + const result = await service.setupPasskey() + + expect(result).toBe(SetupResult.Cancel) + }) + + it('should return Failed when setup fails', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false) + const setupError = new Error('Setup failed') + setupError.name = 'InvalidStateError' + mockCredentials.create.mockRejectedValue(setupError) + + const result = await service.setupPasskey() + + expect(result).toBe(SetupResult.Failed) + }) + }) +}) \ No newline at end of file diff --git a/src/lib/auth/__tests__/PasswordAuthService.test.ts b/src/lib/auth/__tests__/PasswordAuthService.test.ts new file mode 100644 index 000000000..62496a5b5 --- /dev/null +++ b/src/lib/auth/__tests__/PasswordAuthService.test.ts @@ -0,0 +1,52 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import { PasswordAuthService } from '../PasswordAuthService' +import { AuthenticationResult } from '../types' + +vi.mock('@/store', () => ({ + default: { + dispatch: vi.fn() + } +})) + +describe('PasswordAuthService', () => { + let service: PasswordAuthService + let mockStore: any + + beforeEach(async () => { + const store = await import('@/store') + mockStore = store.default + service = new PasswordAuthService() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('authorizeUser', () => { + it('should return Success when login via password succeeds', async () => { + mockStore.dispatch.mockResolvedValue({ passphrase: 'test-passphrase' }) + + const result = await service.authorizeUser('correct-password') + + expect(result).toBe(AuthenticationResult.Success) + expect(mockStore.dispatch).toHaveBeenCalledWith('loginViaPassword', 'correct-password') + }) + + it('should throw error when login via password fails', async () => { + const loginError = new Error('Incorrect password') + mockStore.dispatch.mockRejectedValue(loginError) + + await expect(service.authorizeUser('wrong-password')).rejects.toThrow('Incorrect password') + expect(mockStore.dispatch).toHaveBeenCalledWith('loginViaPassword', 'wrong-password') + }) + + it('should throw error when empty password provided', async () => { + const emptyPasswordError = new Error('Password cannot be empty') + mockStore.dispatch.mockRejectedValue(emptyPasswordError) + + await expect(service.authorizeUser('')).rejects.toThrow('Password cannot be empty') + expect(mockStore.dispatch).toHaveBeenCalledWith('loginViaPassword', '') + }) + }) +}) \ No newline at end of file diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 000000000..91e2e0771 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,18 @@ +/** + * Authentication Module + * + * Provides multiple authentication methods for web applications: + * - Password: User-defined password authentication + * - Biometric: Local biometric authentication (Touch ID/Face ID) + * - Passkey: WebAuthn-based passkey authentication + */ + +export { AuthenticationResult, AuthenticationMethod } from './types' + +import { PasskeyAuthService } from './PasskeyAuthService' +import { BiometricAuthService } from './BiometricAuthService' +import { PasswordAuthService } from './PasswordAuthService' + +export const passkeyAuth = new PasskeyAuthService() +export const biometricAuth = new BiometricAuthService() +export const passwordAuth = new PasswordAuthService() diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts new file mode 100644 index 000000000..5b8cf1726 --- /dev/null +++ b/src/lib/auth/types.ts @@ -0,0 +1,38 @@ +/** + * Biometric authentication types for Web platform + */ + +/** + * Result of authentication attempt + */ +export enum AuthenticationResult { + Success = 'success', + Failed = 'failed', + Cancel = 'cancel' +} + +/** + * Result of biometric setup attempt + */ +export enum SetupResult { + Success = 'success', + Failed = 'failed', + Cancel = 'cancel' +} + +/** + * Authentication methods supported by the application + */ +export enum AuthenticationMethod { + Passphrase = 'passphrase', + Password = 'password', + Biometric = 'biometric', + Passkey = 'passkey' +} + +/** + * Common interface for all authentication services + */ +export interface AuthenticationService { + authorizeUser(password?: string): Promise +} diff --git a/src/lib/idb/crypto.js b/src/lib/idb/crypto.js index aaeb0d780..580e40040 100644 --- a/src/lib/idb/crypto.js +++ b/src/lib/idb/crypto.js @@ -10,14 +10,22 @@ import { Buffer } from 'buffer' const NONCE = Buffer.allocUnsafe(24) +// Global encryption key for biometric/passkey authentication +let globalEncryptionKey = null + +export function setGlobalEncryptionKey(encryptionKey) { + globalEncryptionKey = encryptionKey +} + /** * @param {string|number|Object} data * @returns {Buffer} */ export function encrypt(data) { const stringified = JSON.stringify(data) - const secretKey = ed2curve.convertSecretKey(store.state.password) - + const secretKey = globalEncryptionKey + ? ed2curve.convertSecretKey(globalEncryptionKey) + : ed2curve.convertSecretKey(store.state.password) return nacl.secretbox(Buffer.from(stringified), NONCE, secretKey) } @@ -26,9 +34,10 @@ export function encrypt(data) { * @returns {string|number|Object} */ export function decrypt(encryptedData) { - const secretKey = ed2curve.convertSecretKey(store.state.password) + const secretKey = globalEncryptionKey + ? ed2curve.convertSecretKey(globalEncryptionKey) + : ed2curve.convertSecretKey(store.state.password) const decoded = decode(nacl.secretbox.open(encryptedData, NONCE, secretKey)) - return JSON.parse(decoded) } diff --git a/src/locales/en.json b/src/locales/en.json index 7f14889b5..1b6ddd4ce 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -192,10 +192,8 @@ "brand_title": "ADAMANT", "copy_button_tooltip": "Copy", "create_address_label": "Or create new anonymous address:", - "device_unsupported": "Your device is not supported", "invalid_passphrase": "Invalid passphrase", "invalid_qr_code": "QR code does not contain passphrase or is unrecognizable", - "language_label": "Language", "login_button": "Login", "login_by_qr_code_tooltip": "Login with passphrase using QR code", "new_button": "Create new", @@ -207,20 +205,47 @@ "scan_qr_code_button_tooltip": "Scan QR code with passphrase using your camera", "subheader": "Decentralized messenger", "show_passphrase_tooltip": "Show passphrase", - "hide_passphrase_tooltip": "Hide passphrase" + "hide_passphrase_tooltip": "Hide passphrase", + "authentication_failed": "Authentication failed", + "use_passphrase_hint": "Having trouble?", + "use_passphrase": "Sign in with passphrase", + "signin_options": { + "title": "Sign-in options", + "not_available": "Not available on this device", + "biometric": { + "title": "Biometry", + "device_touchid_faceid": "Device Touch ID or Face ID", + "only_native": "Only available in native app" + }, + "passkey": { + "title": "Passkeys", + "secure_login": "Secure passwordless login with device or cloud key", + "only_web": "Only available in web app" + }, + "password": { + "title": "Password", + "secure_login": "Strong password keeps your data securely encrypted" + }, + "notifications": { + "biometric_enabled": "Biometric sign-in enabled", + "passkey_enabled": "Passkeys sign-in enabled", + "password_enabled": "Password sign-in enabled", + "biometric_disabled": "Biometric sign-in disabled", + "passkey_disabled": "Passkeys sign-in disabled", + "password_disabled": "Password sign-in disabled", + "biometric_failed": "Biometric sign-in setup failed", + "passkey_failed": "Passkeys sign-in setup failed", + "password_failed": "Password sign-in setup failed" + } + } }, "login_via_password": { "article": "ADAMANT Blog", "article_hint": "Learn about passwords in ", - "encoding_data_title": "Encrypting data…", "enter_password": "Password", "incorrect_password": "Invalid password", - "login_via_password": "Login via password", "popup_confirm_text": "Set", - "popup_hint": "8+ symbols recommended…", "popup_title": "Set password", - "remove_password": "Remove password", - "remove_password_hint": "Or remove your password to log in with a passphrase", "user_password_title": "Password", "user_password_unlock": "Unlock" }, @@ -289,9 +314,11 @@ "send_on_enter": "Send with Enter", "send_on_enter_tooltip": "Send messages with Enter key, for new line Ctrl+Enter", "stay_logged_in": "Stay logged in", - "stay_logged_in_tooltip": "Set an app password for quick access. A strong password keeps your messages and passphrase securely encrypted.", + "stay_logged_in_tooltip": "Use biometrics, passkeys, or a password for quick access. Your messages and passphrase remain safely encrypted.", "use_full_date": "Always show full dates", "use_full_date_tooltip": "Displays dates as 2021-01-17 instead of relative terms like 'Yesterday' or 'Today'", + "authentication_method_label": "Authentication method", + "authentication_method_placeholder": "Choose authentication method", "version": "App Version:", "vote_for_delegates_button": "Vote for delegates", "wallets_list": "Wallet list", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4c2c3f688..f89e9454d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -192,10 +192,8 @@ "brand_title": "АДАМАНТ", "copy_button_tooltip": "Скопировать", "create_address_label": "Или создайте новый анонимный адрес:", - "device_unsupported": "Ваше устройство не поддерживается", "invalid_passphrase": "Неверная пассфраза", "invalid_qr_code": "QR-код не содержит парольной фразы или не удается ее распознать", - "language_label": "Язык", "login_button": "Войти", "login_by_qr_code_tooltip": "Войти по пассфразе из файла с QR-кодом", "new_button": "Создать новый", @@ -207,20 +205,47 @@ "scan_qr_code_button_tooltip": "Сканировать камерой QR-код с парольной фразой", "subheader": "Децентрализованный мессенджер", "show_passphrase_tooltip": "Показать пассфразу", - "hide_passphrase_tooltip": "Скрыть пассфразу" + "hide_passphrase_tooltip": "Скрыть пассфразу", + "authentication_failed": "Ошибка аутентификации", + "use_passphrase_hint": "Возникли проблемы?", + "use_passphrase": "Войти по пассфразе", + "signin_options": { + "title": "Способы входа", + "not_available": "Недоступно на этом устройстве", + "biometric": { + "title": "Биометрия", + "device_touchid_faceid": "Встроенный Touch ID или Face ID", + "only_native": "Только в нативном приложении" + }, + "passkey": { + "title": "Ключи Passkeys", + "secure_login": "Безопасный вход с помощью ключа устройства", + "only_web": "Только в веб-приложении" + }, + "password": { + "title": "Пароль", + "secure_login": "Ваши данные надежно шифруются при сильном пароле" + }, + "notifications": { + "biometric_enabled": "Вход по биометрии включен", + "passkey_enabled": "Вход с помощью Passkeys включен", + "password_enabled": "Вход по паролю включен", + "biometric_disabled": "Вход по биометрии отключен", + "passkey_disabled": "Вход с помощью Passkeys отключен", + "password_disabled": "Вход по паролю отключен", + "biometric_failed": "Не удалось включить вход по биометрии", + "passkey_failed": "Не удалось включить вход через Passkeys", + "password_failed": "Не удалось включить вход по паролю" + } + } }, "login_via_password": { "article": "блоге АДАМАНТа", "article_hint": "О пользовательском пароле читайте в ", - "encrypting_data_title": "Шифрование данных…", "enter_password": "Пароль", "incorrect_password": "Неверный пароль", - "login_via_password": "Войти с помощью пароля", "popup_confirm_text": "Установить", - "popup_hint": "Рекомендуется 8+ символов…", "popup_title": "Задайте пароль", - "remove_password": "Удалить пароль", - "remove_password_hint": "Или удалите пароль для входа по пассфразе", "user_password_title": "Пароль", "user_password_unlock": "Разблокировать" }, @@ -289,9 +314,11 @@ "send_on_enter": "Отправлять клавишей Enter", "send_on_enter_tooltip": "Отправлять сообщения по клавише Enter, новая строка Ctrl+Enter", "stay_logged_in": "Оставаться в приложении", - "stay_logged_in_tooltip": "Задайте пароль приложения для быстрого доступа. Ваши переписка и пассфраза надежно шифруются при сильном пароле.", + "stay_logged_in_tooltip": "Используйте биометрию, passkeys или пароль для быстрого входа. Ваши сообщения и пассфраза надежно зашифрованы.", "use_full_date": "Показывать даты полностью", "use_full_date_tooltip": "Всегда показывать даты в полном формате, например, 2021-01-17 вместо 'Вчера' и 'Сегодня'", + "authentication_method_label": "Способ аутентификации", + "authentication_method_placeholder": "Выберите способ аутентификации", "version": "Версия приложения:", "vote_for_delegates_button": "Голосовать за делегатов", "wallets_list": "Список кошельков", diff --git a/src/store/index.js b/src/store/index.js index 730f573d9..5c68fcab4 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -6,10 +6,12 @@ import { loginOrRegister, loginViaPassword, sendSpecialMessage, - getCurrentAccount + getCurrentAccount, + getSecureData } from '@/lib/adamant-api' + import { CryptosInfo, Fees, FetchStatus } from '@/lib/constants' -import { encryptPassword } from '@/lib/idb/crypto' +import { encryptPassword, setGlobalEncryptionKey } from '@/lib/idb/crypto' import { flushCryptoAddresses, validateStoredCryptoAddresses } from '@/lib/store-crypto-address' import { registerCryptoModules } from './utils/registerCryptoModules' import { registerVuexPlugins } from './utils/registerVuexPlugins' @@ -45,6 +47,7 @@ import { cryptoTransferAsset, replyWithCryptoTransferAsset } from '@/lib/adamant import { PendingTxStore } from '@/lib/pending-transactions' import servicesModule from './modules/services' import servicesPlugin from './modules/services/services-plugin' +import { restoreState } from '@/lib/idb/state' export let interval @@ -152,6 +155,7 @@ const store = { }) }, loginViaPassword({ commit, dispatch }, password) { + setGlobalEncryptionKey(null) return loginViaPassword(password, this).then((account) => { commit('setIDBReady', true) @@ -159,10 +163,23 @@ const store = { dispatch('afterLogin', account.passphrase) }) }, + async loginViaBiometricOrPaskkeyAction({ commit, dispatch }) { + const { passphrase, encryptionKey } = await getSecureData() + + setGlobalEncryptionKey(encryptionKey) + await restoreState(this) + + return loginOrRegister(passphrase).then(() => { + commit('setIDBReady', true) + dispatch('afterLogin', passphrase) + }) + }, logout({ dispatch }) { dispatch('reset') dispatch('wallets/initWalletsSymbols') - dispatch('draftMessage/resetState', null, { root: true }) + dispatch('draftMessage/resetState', null, { + root: true + }) PendingTxStore.clear() }, unlock({ state, dispatch }) { @@ -171,8 +188,8 @@ const store = { unlock(passphrase) - // retrieve wallet data only if loginViaPassword, otherwise coin modules will be loaded twice - if (state.password) { + // retrieve wallet data only if stay logged in is enabled, otherwise coin modules will be loaded twice + if (state.options.stayLoggedIn) { dispatch('afterLogin', passphrase) } }, @@ -196,7 +213,14 @@ const store = { }) }, reset({ commit }) { - commit('reset', null, { root: true }) + setGlobalEncryptionKey(null) + commit('reset', null, { + root: true + }) + commit('options/updateOption', { + key: 'authenticationMethod', + value: null + }) }, setPassword({ commit }, password) { return encryptPassword(password).then((encryptedPassword) => { @@ -208,7 +232,14 @@ const store = { removePassword({ commit }) { commit('resetPassword') commit('setIDBReady', false) - commit('options/updateOption', { key: 'stayLoggedIn', value: false }) + commit('options/updateOption', { + key: 'stayLoggedIn', + value: false + }) + commit('options/updateOption', { + key: 'authenticationMethod', + value: null + }) }, updateBalance({ commit }, payload = {}) { if (payload.requestedByUser) { diff --git a/src/store/modules/options/index.js b/src/store/modules/options/index.js index ab589d42f..72247b2d1 100644 --- a/src/store/modules/options/index.js +++ b/src/store/modules/options/index.js @@ -2,6 +2,7 @@ import { Cryptos, Rates } from '@/lib/constants' const state = () => ({ stayLoggedIn: false, // if true, messages and passphrase will be stored encrypted. If false, localStorage will be cleared after logout + authenticationMethod: null, sendMessageOnEnter: true, allowSoundNotifications: true, allowTabNotifications: true, @@ -29,7 +30,8 @@ const state = () => ({ }) const getters = { - isLoginViaPassword: (state) => state.stayLoggedIn, + isUsingSecureLogin: (state) => + state.stayLoggedIn && state.authenticationMethod && state.authenticationMethod !== 'Passphrase', scrollTopPosition: (state) => state.scrollTopPosition, currentNodesTab: (state) => state.currentNodesTab, wasSendingFunds: (state) => state.sendFundsData.wasSendingFunds, diff --git a/src/store/plugins/indexedDb.js b/src/store/plugins/indexedDb.js index a323b762c..e1f4c8bc7 100644 --- a/src/store/plugins/indexedDb.js +++ b/src/store/plugins/indexedDb.js @@ -116,7 +116,7 @@ function chatThrottle(chatId) { } export default (store) => { - if (store.getters['options/isLoginViaPassword']) { + if (store.getters['options/isUsingSecureLogin']) { if (store.state.password) { restoreState(store) .then(() => { @@ -167,7 +167,7 @@ export default (store) => { store.subscribe((mutation, state) => { // start sync if state has been saved to IDB - if (state.IDBReady && store.getters['options/isLoginViaPassword']) { + if (state.IDBReady && store.getters['options/isUsingSecureLogin']) { if (isModuleMutation(mutation.type)) { const [moduleName] = mutation.type.split('/') diff --git a/src/store/plugins/localStorage.js b/src/store/plugins/localStorage.js index 1966e0126..dc241917e 100644 --- a/src/store/plugins/localStorage.js +++ b/src/store/plugins/localStorage.js @@ -19,7 +19,8 @@ const vuexPersistence = new VuexPersistence({ useSocketConnection: state.options.useSocketConnection, suppressWarningOnAddressesNotification: state.options.suppressWarningOnAddressesNotification, - currentRate: state.options.currentRate + currentRate: state.options.currentRate, + authenticationMethod: state.options.authenticationMethod } } } diff --git a/src/stores/modal-state.ts b/src/stores/modal-state.ts index 8704a0d6f..8b86d8642 100644 --- a/src/stores/modal-state.ts +++ b/src/stores/modal-state.ts @@ -6,6 +6,7 @@ export const useChatStateStore = defineStore('chatState', () => { const isShowPartnerInfoDialog = ref(false) const isShowFreeTokensDialog = ref(false) const isShowSetPasswordDialog = ref(false) + const isShowSignInOptionsDialog = ref(false) const isChatMenuOpen = ref(false) const isEmojiPickerOpen = ref(false) const actionsDropdownMessageId = ref(-1) @@ -26,6 +27,10 @@ export const useChatStateStore = defineStore('chatState', () => { isShowSetPasswordDialog.value = value } + const setShowSignInOptionsDialog = (value: boolean) => { + isShowSignInOptionsDialog.value = value + } + const setChatMenuOpen = (value: boolean) => { isChatMenuOpen.value = value } @@ -43,6 +48,7 @@ export const useChatStateStore = defineStore('chatState', () => { setShowPartnerInfoDialog(false) setShowFreeTokensDialog(false) setShowSetPasswordDialog(false) + setShowSignInOptionsDialog(false) setChatMenuOpen(false) setEmojiPickerOpen(false) setActionsDropdownMessageId(-1) @@ -54,6 +60,7 @@ export const useChatStateStore = defineStore('chatState', () => { isShowPartnerInfoDialog, isShowFreeTokensDialog, isShowSetPasswordDialog, + isShowSignInOptionsDialog, isChatMenuOpen, isEmojiPickerOpen, @@ -62,6 +69,7 @@ export const useChatStateStore = defineStore('chatState', () => { setShowPartnerInfoDialog, setShowFreeTokensDialog, setShowSetPasswordDialog, + setShowSignInOptionsDialog, setChatMenuOpen, setEmojiPickerOpen, $reset diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index 2d0cbc779..6db7a20e4 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -155,6 +155,7 @@ const { isShowPartnerInfoDialog, isShowFreeTokensDialog, isShowSetPasswordDialog, + isShowSignInOptionsDialog, isChatMenuOpen, isEmojiPickerOpen } = storeToRefs(chatStateStore) @@ -172,6 +173,7 @@ const canPressEscape = computed(() => { !isShowChatStartDialog.value && !isShowFreeTokensDialog.value && !isShowSetPasswordDialog.value && + !isShowSignInOptionsDialog.value && !isSnackbarShowing.value && !isShowPartnerInfoDialog.value && !isChatMenuOpen.value && diff --git a/src/views/Login.vue b/src/views/Login.vue index 1bdef2673..c281a72ec 100644 --- a/src/views/Login.vue +++ b/src/views/Login.vue @@ -32,7 +32,11 @@ - + - + - + - + + + + + + + + + + + + + + + + + @@ -110,14 +146,17 @@ import { useRoute } from 'vue-router' import QrcodeCapture from '@/components/QrcodeCapture.vue' import LanguageSwitcher from '@/components/LanguageSwitcher.vue' import PassphraseGenerator from '@/components/PassphraseGenerator.vue' -import LoginForm from '@/components/LoginForm.vue' +import LoginForm from '@/components/auth/PassphraseLoginForm.vue' import QrcodeScannerDialog from '@/components/QrcodeScannerDialog.vue' import Icon from '@/components/icons/BaseIcon.vue' import QrCodeScanIcon from '@/components/icons/common/QrCodeScan.vue' import FileIcon from '@/components/icons/common/File.vue' -import LoginPasswordForm from '@/components/LoginPasswordForm.vue' +import PasswordLoginForm from '@/components/auth/PasswordLoginForm.vue' +import BiometricLoginForm from '@/components/auth/BiometricLoginForm.vue' +import PasskeyLoginForm from '@/components/auth/PasskeyLoginForm.vue' import Logo from '@/components/icons/common/Logo.vue' import { navigateByURI } from '@/router/navigationGuard' +import { AuthenticationMethod } from '@/lib/auth' const store = useStore() const route = useRoute() @@ -129,7 +168,17 @@ const password = ref('') const showQrcodeScanner = ref(false) const loginForm = useTemplateRef | null>('loginForm') -const isLoginViaPassword = computed(() => store.getters['options/isLoginViaPassword']) +const stayLoggedIn = computed(() => store.state.options.stayLoggedIn) +const authenticationMethod = computed(() => store.state.options.authenticationMethod) + +const primaryMethod = computed(() => { + if (!stayLoggedIn.value || !authenticationMethod.value) { + return AuthenticationMethod.Passphrase + } + + return authenticationMethod.value +}) + const layout = computed(() => route.meta.layout || 'default') const onDetectQrcode = (passphrase: string) => { @@ -217,6 +266,39 @@ const onScanQrcode = (value: string) => { opacity: 0.06; } } + + // Auth form styles + &__form { + &-textfield { + &:deep(.v-field__append-inner) { + padding-left: 0; + margin-left: -28px; // compensate the append-inner icon + } + + &:deep(.v-field__input) { + width: 100%; + padding-right: 32px; + padding-left: 32px; + } + + :deep(input) { + font-size: 16px !important; + } + } + + &-icon { + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + } + + &:active { + opacity: 0.6; + } + } + } } /** Themes **/ @@ -235,6 +317,10 @@ const onScanQrcode = (value: string) => { opacity: 1; } } + + &__form-textfield { + color: map.get(colors.$adm-colors, 'regular'); + } } } .v-theme--dark { @@ -247,6 +333,10 @@ const onScanQrcode = (value: string) => { opacity: 1; } } + + &__form-textfield { + color: map.get(settings.$shades, 'white'); + } } } diff --git a/src/views/Options.vue b/src/views/Options.vue index 5476f3cfd..6f9390e40 100644 --- a/src/views/Options.vue +++ b/src/views/Options.vue @@ -49,11 +49,25 @@ @click.prevent="onCheckStayLoggedIn" /> -
- {{ t('options.stay_logged_in_tooltip') }} +
+ +
+ + @@ -201,7 +215,14 @@