195 lines
6.6 KiB
JavaScript

/**
* LockScreen — master password setup + unlock UI.
*/
import { Component } from './component.js'
import { app } from '../lib/stores/app.js'
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js'
import { startAutoLock } from '../lib/stores/security.js'
import { settings } from '../lib/stores/settings.js'
import { autofocus } from '../lib/autofocus.js'
export class LockScreen extends Component {
masterPassword = ''
confirmPassword = ''
error = ''
loading = false
isSetup = false
mount() {
super.mount()
this.#checkVault()
return this
}
render() {
const notLocal = typeof window !== 'undefined' && window.location.protocol !== 'file:'
this.el = this.ce('div', { className: 'lock-screen' },
this.ce('div', { className: 'lock-card' },
this.ce('div', { className: 'lock-icon', textContent: '🔐' }),
this.ce('h1', { textContent: 'Password Vault' }),
this.ce('p', { className: 'subtitle', textContent: 'Unlock your vault' }),
notLocal ? this.ce('div', { className: 'warning-banner', role: 'alert', textContent: 'This HTML file is intended for offline use.' }) : null,
null, // error-banner placeholder
this.#buildForm(),
this.ce('p', { className: 'hint', textContent: 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.' }),
),
)
// Store references to dynamic elements
this._subtitle = this.q('.subtitle')
this._confirmGroup = this.q('.confirm-group')
this._submitBtn = this.q('.submit-btn')
this._passwordInput = this.q('#master-password')
this._confirmInput = this.q('#confirm-password')
this._hint = this.q('.hint')
autofocus(this._passwordInput, true)
// Wire input listeners so this.masterPassword / this.confirmPassword stay in sync
if (this._passwordInput) {
this.on(this._passwordInput, 'input', (e) => { this.masterPassword = e.target.value })
}
if (this._confirmInput) {
this.on(this._confirmInput, 'input', (e) => { this.confirmPassword = e.target.value })
}
return this.el
}
#buildForm() {
return this.ce('form', { className: 'lock-form', id: 'lock-form' },
this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'master-password', textContent: 'Master Password' }),
this.ce('input', { id: 'master-password', type: 'password', placeholder: 'Enter master password', autocomplete: 'current-password' }),
),
this.ce('div', { className: 'form-group confirm-group' },
this.ce('label', { htmlFor: 'confirm-password', textContent: 'Confirm Password' }),
this.ce('input', { id: 'confirm-password', type: 'password', placeholder: 'Confirm master password', autocomplete: 'new-password' }),
),
this.ce('button', { type: 'submit', className: 'btn btn-primary w-full submit-btn', textContent: 'Unlock' }),
)
}
#updateUI() {
if (this._subtitle) {
this._subtitle.textContent = this.isSetup ? 'Create your vault' : 'Unlock your vault'
}
if (this._confirmGroup) {
this._confirmGroup.style.display = this.isSetup ? '' : 'none'
}
if (this._submitBtn) {
this._submitBtn.textContent = this.loading ? 'Processing...' : (this.isSetup ? 'Create Vault' : 'Unlock')
this._submitBtn.disabled = this.loading
}
if (this._passwordInput) {
this._passwordInput.disabled = this.loading
}
if (this._confirmInput) {
this._confirmInput.disabled = this.loading
}
// Error banner
if (this.error) {
if (!this._errorBanner) {
const banner = this.ce('div', { className: 'error-banner', role: 'alert', textContent: this.error })
const form = this.q('#lock-form')
form?.parentNode?.insertBefore(banner, form)
this._errorBanner = banner
} else {
this._errorBanner.textContent = this.error
}
} else if (this._errorBanner) {
this._errorBanner.remove()
this._errorBanner = null
}
if (this._hint) {
this._hint.textContent = this.isSetup
? 'Your master password encrypts all data locally. It cannot be recovered if lost.'
: 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.'
}
}
#checkVault() {
isVaultInitialized().then(init => {
this.isSetup = !init
this.#updateUI()
})
}
#handleSubmit = async (e) => {
e.preventDefault()
this.error = ''
this.loading = true
this.#updateUI()
try {
if (this.isSetup) {
if (!this.masterPassword || this.masterPassword.length < 4) {
this.error = 'Password must be at least 4 characters'
this.loading = false
this.#updateUI()
return
}
if (this.masterPassword !== this.confirmPassword) {
this.error = 'Passwords do not match'
this.loading = false
this.#updateUI()
return
}
const { salt, testEncrypted, testPlaintext } = await createTestPayload(this.masterPassword)
app.salt = salt
const key = await deriveKey(this.masterPassword, salt)
app.encryptionKey = key
await saveVaultMeta(salt, testEncrypted, testPlaintext)
await ensureTrashGroup()
await settings.load()
app.isUnlocked = true
startAutoLock()
} else {
const meta = await loadVaultMeta()
if (!meta.salt || !meta.testEncrypted || !meta.testPlaintext) {
this.error = 'Vault data corrupted'
this.loading = false
this.#updateUI()
return
}
const key = await deriveKey(this.masterPassword, meta.salt)
const isValid = await verifyPassword(this.masterPassword, meta.salt, meta.testEncrypted, meta.testPlaintext)
if (!isValid) {
this.error = 'Incorrect password'
this.loading = false
this.#updateUI()
return
}
app.salt = meta.salt
app.encryptionKey = key
await settings.load()
app.isUnlocked = true
startAutoLock()
}
} catch (err) {
console.error(err)
this.error = 'An error occurred: ' + err.message
}
this.loading = false
this.masterPassword = ''
this.confirmPassword = ''
if (this._passwordInput) this._passwordInput.value = ''
if (this._confirmInput) this._confirmInput.value = ''
this.#updateUI()
}
afterMount() {
const form = this.q('#lock-form')
if (form) this.on(form, 'submit', this.#handleSubmit)
}
}