276 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* EntryForm — create/edit credential form.
*/
import { Component } from './component.js'
import { addEntry, updateEntry, getEntryById, getGroups } from '../lib/storage/db.js'
import { encrypt, decrypt } from '../lib/crypto/crypto.js'
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.js'
import { search } from '../lib/stores/search.js'
import { autofocus } from '../lib/autofocus.js'
export class EntryForm extends Component {
/** @param {{ entryId: string|null, onSave: Function, onCancel: Function }} props */
constructor(container, props = {}) {
super(container)
this.entryId = props.entryId || null
this.onSave = props.onSave || (() => {})
this.onCancel = props.onCancel || (() => {})
this.title = ''
this.username = ''
this.password = ''
this.url = ''
this.notes = ''
this.groupId = ''
this.passwordVisible = false
this.groups = []
this.loading = true
this.error = ''
this.saving = false
this.isEdit = false
this.formErrors = []
}
mount() {
super.mount()
this.#loadForm()
return this
}
render() {
this.el = this.ce('div', { className: 'entry-form' })
this.#renderContent()
return this.el
}
#renderContent() {
this.el.innerHTML = ''
if (this.loading) {
this.el.appendChild(this.ce('div', { className: 'loading', textContent: 'Loading...' }))
return
}
if (this.error && !this.isEdit) {
this.el.appendChild(this.ce('div', { className: 'error-banner', textContent: this.error }))
return
}
const form = this.ce('form', { className: 'ef-form-card', id: 'entry-form' })
// Validation errors
if (this.formErrors.length > 0) {
const errDiv = this.ce('div', { className: 'validation-errors' })
for (const err of this.formErrors) {
errDiv.appendChild(this.ce('div', { className: 'validation-error', textContent: `${err}` }))
}
form.appendChild(errDiv)
}
// Title
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'title', textContent: 'Title *' }),
this.ce('input', { id: 'title', type: 'text', placeholder: 'e.g. GitHub, Gmail', value: this.title }),
))
// Username
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'username', textContent: 'Username / Email' }),
this.ce('input', { id: 'username', type: 'text', placeholder: 'username or email', value: this.username }),
))
// Password
const pwdGroup = this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'password', textContent: 'Password *' }),
this.ce('div', { className: 'password-input-group' },
this.ce('input', {
id: 'password',
type: this.passwordVisible ? 'text' : 'password',
placeholder: 'Password',
value: this.password,
}),
this.ce('button', { type: 'button', className: 'btn btn-ghost btn-sm', id: 'toggle-pwd', textContent: this.passwordVisible ? '🙈' : '👁', title: 'Toggle visibility' }),
this.ce('button', { type: 'button', className: 'btn btn-ghost btn-sm', id: 'generate-pwd', textContent: '🎲', title: 'Generate password' }),
),
)
form.appendChild(pwdGroup)
// URL
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'url', textContent: 'URL' }),
this.ce('input', { id: 'url', type: 'url', placeholder: 'https://example.com', value: this.url }),
))
// Group
const select = this.ce('select', { id: 'group' })
const defaultOpt = this.ce('option', { value: '' }, this.text('No group'))
select.appendChild(defaultOpt)
for (const group of this.groups) {
if (isTrashGroup(group.id)) continue
const opt = this.ce('option', { value: group.id }, this.text(group.name))
if (group.id === this.groupId) opt.selected = true
select.appendChild(opt)
}
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'group', textContent: 'Group' }),
select,
))
// Notes
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'notes', textContent: 'Notes' }),
this.ce('textarea', { id: 'notes', placeholder: 'Any additional notes...' }),
))
// Actions
form.appendChild(this.ce('div', { className: 'ef-form-actions' },
this.ce('button', {
type: 'submit',
className: 'btn btn-primary',
disabled: this.saving,
textContent: this.saving ? 'Saving...' : (this.isEdit ? '💾 Update' : ' Create'),
}),
this.ce('button', { type: 'button', className: 'btn btn-ghost', id: 'cancel-btn', textContent: 'Cancel' }),
))
this.el.appendChild(form)
// Wire up form events
const formEl = this.q('#entry-form')
if (formEl) this.on(formEl, 'submit', this.#handleSubmit)
const cancelBtn = this.q('#cancel-btn')
if (cancelBtn) this.on(cancelBtn, 'click', () => this.onCancel())
const togglePwd = this.q('#toggle-pwd')
if (togglePwd) {
this.on(togglePwd, 'click', () => {
this.passwordVisible = !this.passwordVisible
const pwdInput = this.q('#password')
if (pwdInput) pwdInput.type = this.passwordVisible ? 'text' : 'password'
togglePwd.textContent = this.passwordVisible ? '🙈' : '👁'
})
}
const generatePwd = this.q('#generate-pwd')
if (generatePwd) {
this.on(generatePwd, 'click', () => {
this.password = generatePassword({ length: 16 })
const pwdInput = this.q('#password')
if (pwdInput) pwdInput.value = this.password
})
}
// Wire input changes
const titleInput = this.q('#title')
if (titleInput) this.on(titleInput, 'input', (e) => { this.title = e.target.value })
const usernameInput = this.q('#username')
if (usernameInput) this.on(usernameInput, 'input', (e) => { this.username = e.target.value })
const pwdInput = this.q('#password')
if (pwdInput) this.on(pwdInput, 'input', (e) => { this.password = e.target.value })
const urlInput = this.q('#url')
if (urlInput) this.on(urlInput, 'input', (e) => { this.url = e.target.value })
const notesInput = this.q('#notes')
if (notesInput) this.on(notesInput, 'input', (e) => { this.notes = e.target.value })
const groupSelect = this.q('#group')
if (groupSelect) this.on(groupSelect, 'change', (e) => { this.groupId = e.target.value })
// Autofocus title on new entries
if (!this.isEdit && titleInput) autofocus(titleInput, true)
}
async #loadForm() {
this.loading = true
try {
this.groups = await getGroups()
if (this.entryId) {
this.isEdit = true
const entry = await getEntryById(this.entryId)
if (entry) {
this.title = entry.title
this.username = entry.username
this.password = await decrypt(entry.encryptedPassword, app.encryptionKey)
this.url = entry.url || ''
this.notes = entry.notes || ''
this.groupId = entry.groupId || ''
} else {
this.error = 'Entry not found'
}
} else {
const active = search.activeGroupId
this.groupId = (active !== 'all' && active !== 'trash') ? active : ''
}
} catch (e) {
this.error = 'Failed to load form: ' + e.message
}
this.loading = false
this.#renderContent()
}
#handleSubmit = async (e) => {
e.preventDefault()
this.formErrors = []
this.error = ''
this.saving = true
// Read current values from inputs
const titleInput = this.q('#title')
if (titleInput) this.title = titleInput.value
const usernameInput = this.q('#username')
if (usernameInput) this.username = usernameInput.value
const pwdInput = this.q('#password')
if (pwdInput) this.password = pwdInput.value
const urlInput = this.q('#url')
if (urlInput) this.url = urlInput.value
const notesInput = this.q('#notes')
if (notesInput) this.notes = notesInput.value
const groupSelect = this.q('#group')
if (groupSelect) this.groupId = groupSelect.value
try {
const validation = validateEntry({ title: this.title, username: this.username, encryptedPassword: this.password })
if (!validation.valid) {
this.formErrors = validation.errors
this.saving = false
this.#renderContent()
return
}
const encryptedPassword = await encrypt(this.password, app.encryptionKey)
if (this.isEdit) {
const existing = await getEntryById(this.entryId)
const updated = updateEntryModel(existing, {
title: this.title,
username: this.username,
encryptedPassword,
url: this.url,
notes: this.notes,
groupId: this.groupId,
})
await updateEntry(updated)
} else {
const entry = createEntry({
title: this.title,
username: this.username,
encryptedPassword,
url: this.url,
notes: this.notes,
groupId: this.groupId,
})
await addEntry(entry)
}
this.onSave()
} catch (e) {
this.error = 'Failed to save: ' + e.message
}
this.saving = false
this.#renderContent()
}
}