276 lines
9.3 KiB
JavaScript
276 lines
9.3 KiB
JavaScript
/**
|
||
* 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()
|
||
}
|
||
}
|