515 lines
20 KiB
TypeScript
515 lines
20 KiB
TypeScript
import { Field, FieldType, FIELD_DEFS, AuditResult, AuditType } from "@padloc/core/src/item";
|
|
import { translate as $l } from "@padloc/locale/src/translate";
|
|
import { shared } from "../styles";
|
|
import "./icon";
|
|
import { Input } from "./input";
|
|
import { Textarea } from "./textarea";
|
|
import "./input";
|
|
import "./textarea";
|
|
import "./totp";
|
|
import "./button";
|
|
import { Drawer } from "./drawer";
|
|
import { customElement, property, query, state } from "lit/decorators.js";
|
|
import { css, html, LitElement } from "lit";
|
|
import { generatePassphrase } from "@padloc/core/src/diceware";
|
|
import { randomString, charSets } from "@padloc/core/src/util";
|
|
import { app } from "../globals";
|
|
import { descriptionForAudit, iconForAudit, titleTextForAudit } from "../lib/audit";
|
|
import "./popover";
|
|
|
|
@customElement("pl-field")
|
|
export class FieldElement extends LitElement {
|
|
@property({ type: Boolean })
|
|
editing: boolean = false;
|
|
|
|
@property({ attribute: false })
|
|
field: Field;
|
|
|
|
@property({ type: Boolean })
|
|
canMoveUp: boolean;
|
|
|
|
@property({ type: Boolean })
|
|
canMoveDown: boolean;
|
|
|
|
@property({ attribute: false })
|
|
auditResults: AuditResult[] = [];
|
|
|
|
@state()
|
|
private _masked: boolean = false;
|
|
|
|
@state()
|
|
private _suggestions: string[] | null = null;
|
|
|
|
@state()
|
|
private _existingUsernames: string[] = [];
|
|
|
|
@query(".name-input")
|
|
private _nameInput: Input;
|
|
|
|
@query(".value-input")
|
|
private _valueInput: Input | Textarea;
|
|
|
|
@query(".drawer")
|
|
private _drawer: Drawer;
|
|
|
|
private get _fieldDef() {
|
|
return FIELD_DEFS[this.field.type] || FIELD_DEFS.text;
|
|
}
|
|
|
|
private get _fieldActions() {
|
|
const actions = [
|
|
...(this._fieldDef.actions || []),
|
|
{ icon: "copy", label: $l("Copy"), action: () => this.dispatchEvent(new CustomEvent("copy-clipboard")) },
|
|
{
|
|
icon: "edit",
|
|
label: $l("Edit"),
|
|
action: () => {
|
|
this.dispatchEvent(new CustomEvent("edit"));
|
|
this._drawer.collapsed = true;
|
|
},
|
|
},
|
|
];
|
|
|
|
if (this._fieldDef.mask && !app.settings.unmaskFieldsOnHover) {
|
|
actions.unshift({
|
|
icon: this._masked ? "show" : "hide",
|
|
label: this._masked ? "show" : "hide",
|
|
action: () => (this._masked = !this._masked),
|
|
});
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
focus() {
|
|
if (this.editing) {
|
|
const inputToFocus = this._nameInput.value ? this._valueInput : this._nameInput;
|
|
inputToFocus.focus();
|
|
} else {
|
|
super.focus();
|
|
}
|
|
}
|
|
|
|
updated(changes: Map<string, any>) {
|
|
if (changes.has("field")) {
|
|
this._fieldChanged();
|
|
}
|
|
|
|
if (changes.has("editing")) {
|
|
this._editingChanged();
|
|
}
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.addEventListener("mouseenter", () => this._mouseenter());
|
|
this.addEventListener("mouseleave", () => this._mouseleave());
|
|
}
|
|
|
|
private _fieldChanged() {
|
|
this._masked = this._fieldDef.mask;
|
|
const usernames = new Map<string, number>();
|
|
usernames.set(app.account!.email, 1);
|
|
for (const vault of app.vaults) {
|
|
for (const item of vault.items) {
|
|
for (const field of item.fields) {
|
|
if (field.type === FieldType.Username && field.value) {
|
|
usernames.set(field.value, (usernames.get(field.value) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this._existingUsernames = [...usernames.entries()].sort(([, a], [, b]) => b - a).map(([key]) => key);
|
|
}
|
|
|
|
private _editingChanged() {
|
|
if (!this.editing) {
|
|
this.setAttribute("draggable", "true");
|
|
} else {
|
|
this.removeAttribute("draggable");
|
|
}
|
|
}
|
|
|
|
protected _mouseenter() {
|
|
if (app.settings.unmaskFieldsOnHover) {
|
|
this._masked = false;
|
|
}
|
|
this._drawer.collapsed = this.editing;
|
|
}
|
|
|
|
protected _mouseleave() {
|
|
if (app.settings.unmaskFieldsOnHover) {
|
|
this._masked = true;
|
|
}
|
|
this._drawer.collapsed = true;
|
|
}
|
|
|
|
private async _updateSuggestions() {
|
|
switch (this.field.type) {
|
|
case FieldType.Username:
|
|
const value = this._valueInput?.value || "";
|
|
this._suggestions = this._existingUsernames.filter((val) => val.startsWith(value) && val !== value);
|
|
break;
|
|
case FieldType.Password:
|
|
this._suggestions = this._valueInput?.value
|
|
? []
|
|
: [await randomString(16, charSets.alphanum), await generatePassphrase()];
|
|
break;
|
|
case FieldType.Url:
|
|
const url = app.state.context.browser?.url;
|
|
const origin = url && new URL(url).origin;
|
|
const host = url && new URL(url).host;
|
|
this._suggestions = !this._valueInput?.value && url && origin ? [origin, url, `*.${host}`] : [];
|
|
break;
|
|
default:
|
|
this._suggestions = null;
|
|
}
|
|
}
|
|
|
|
private async _expandSuggestions() {
|
|
await this._updateSuggestions();
|
|
const drawer = this._valueInput.querySelector("pl-drawer") as Drawer;
|
|
drawer && (drawer.collapsed = false);
|
|
}
|
|
|
|
private _collapseSuggestions() {
|
|
window.setTimeout(() => {
|
|
const drawer = this._valueInput?.querySelector("pl-drawer") as Drawer;
|
|
drawer && (drawer.collapsed = true);
|
|
}, 50);
|
|
}
|
|
|
|
static styles = [
|
|
shared,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
background: var(--color-background);
|
|
}
|
|
|
|
:host(.dragging) {
|
|
opacity: 0.999;
|
|
}
|
|
|
|
.field-header {
|
|
--input-padding: 0.3em;
|
|
margin: 0.2em 0;
|
|
color: var(--item-view-field-name-color, var(--color-highlight));
|
|
font-weight: var(--item-view-field-name-weight, 400);
|
|
}
|
|
|
|
.name-input {
|
|
text-transform: uppercase;
|
|
background: transparent;
|
|
}
|
|
|
|
.value-input,
|
|
.value-display {
|
|
line-height: 1.4em;
|
|
}
|
|
|
|
.value-input {
|
|
--input-padding: 0.3em 0.8em 0.3em 0.4em;
|
|
margin-bottom: 0.3em;
|
|
}
|
|
|
|
.value-display {
|
|
margin: 0 0.4em 0.4em 1.5em;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: break-word;
|
|
user-select: text;
|
|
cursor: text;
|
|
}
|
|
|
|
.move-button {
|
|
display: flex;
|
|
--button-padding: 0 0.5em;
|
|
}
|
|
|
|
:host([draggable]),
|
|
:host([draggable]) .name-input {
|
|
cursor: grab;
|
|
}
|
|
|
|
:host([draggable]):active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
:host(.dragging) pl-drawer {
|
|
display: none;
|
|
}
|
|
`,
|
|
];
|
|
|
|
private _renderDisplayValue() {
|
|
const format = this._fieldDef.format || ((value: string, _masked: boolean) => value);
|
|
if (!this.field.value) {
|
|
return html`<pre class="subtle value-display mono">[${$l("empty")}]</pre>`;
|
|
}
|
|
switch (this.field.type) {
|
|
case "password":
|
|
case "pin":
|
|
return html` <pre class="value-display mono">${format(this.field.value, this._masked)}</pre> `;
|
|
case "totp":
|
|
return html` <pl-totp class="mono value-display" .secret=${this.field.value}></pl-totp> `;
|
|
default:
|
|
return html` <pre class="value-display">${format(this.field.value, this._masked)}</pre> `;
|
|
}
|
|
}
|
|
|
|
private _renderEditValue() {
|
|
switch (this.field.type) {
|
|
case "note":
|
|
return html`
|
|
<pl-textarea
|
|
class="value-input"
|
|
.placeholder=${$l("Enter Notes Here")}
|
|
@input=${() => (this.field.value = this._valueInput.value)}
|
|
autosize
|
|
.value=${this.field.value}
|
|
>
|
|
</pl-textarea>
|
|
`;
|
|
|
|
case "totp":
|
|
return html`
|
|
<pl-input
|
|
class="value-input mono"
|
|
.placeholder=${$l("Enter Secret")}
|
|
type="text"
|
|
@input=${() => (this.field.value = this._valueInput.value)}
|
|
@focus=${this._expandSuggestions}
|
|
@blur=${this._collapseSuggestions}
|
|
.value=${this.field.value}
|
|
>
|
|
<pl-button
|
|
class="small transparent slim"
|
|
slot="after"
|
|
@click=${() => this.dispatchEvent(new CustomEvent("get-totp-qr"))}
|
|
>
|
|
<pl-icon icon="qrcode"></pl-icon>
|
|
</pl-button>
|
|
${!this.field.value
|
|
? html`
|
|
<pl-drawer slot="below" collapsed>
|
|
<div class="horizontal layout">
|
|
<pl-button
|
|
class="tiny skinny transparent"
|
|
@click=${() => this.dispatchEvent(new CustomEvent("get-totp-qr"))}
|
|
>
|
|
<pl-icon icon="qrcode" class="right-margined"></pl-icon>
|
|
${$l("Scan QR Code")}
|
|
</pl-button>
|
|
</div>
|
|
</pl-drawer>
|
|
`
|
|
: ""}
|
|
</pl-input>
|
|
`;
|
|
case "password":
|
|
return html`
|
|
<pl-input
|
|
class="value-input mono"
|
|
.placeholder=${$l("Enter Password")}
|
|
type="text"
|
|
@input=${() => {
|
|
this.field.value = this._valueInput.value;
|
|
this._updateSuggestions();
|
|
}}
|
|
.value=${this.field.value}
|
|
@focus=${this._expandSuggestions}
|
|
@blur=${this._collapseSuggestions}
|
|
select-on-focus
|
|
>
|
|
<pl-button
|
|
class="small transparent slim"
|
|
slot="after"
|
|
@click=${() => this.dispatchEvent(new CustomEvent("generate"))}
|
|
>
|
|
<pl-icon icon="generate"></pl-icon>
|
|
</pl-button>
|
|
${this._suggestions
|
|
? html`
|
|
<pl-drawer slot="below" collapsed>
|
|
<div class="scrolling hide-scrollbar">
|
|
<div class="horizontal layout">
|
|
${this._suggestions.map(
|
|
(suggestion) => html`
|
|
<pl-button
|
|
class="tiny skinny transparent"
|
|
@click=${() => {
|
|
this._valueInput.value = this.field.value = suggestion;
|
|
this._collapseSuggestions();
|
|
}}
|
|
>
|
|
<pl-icon icon="suggestion" class="right-margined"></pl-icon>
|
|
${suggestion}
|
|
</pl-button>
|
|
`
|
|
)}
|
|
</div>
|
|
</div>
|
|
</pl-drawer>
|
|
`
|
|
: ""}
|
|
</pl-input>
|
|
`;
|
|
|
|
default:
|
|
let inputType: string;
|
|
switch (this.field.type) {
|
|
case "email":
|
|
case "url":
|
|
case "date":
|
|
case "month":
|
|
inputType = this.field.type;
|
|
break;
|
|
case "pin":
|
|
case "credit":
|
|
inputType = "number";
|
|
break;
|
|
case "phone":
|
|
inputType = "tel";
|
|
break;
|
|
default:
|
|
inputType = "text";
|
|
}
|
|
return html`
|
|
<pl-input
|
|
class="value-input"
|
|
.placeholder=${$l("Enter Value Here")}
|
|
.type=${inputType}
|
|
.pattern=${this._fieldDef.pattern.toString()}
|
|
@input=${() => {
|
|
this.field.value = this._valueInput.value;
|
|
this._updateSuggestions();
|
|
}}
|
|
.value=${this.field.value}
|
|
@focus=${this._expandSuggestions}
|
|
@blur=${this._collapseSuggestions}
|
|
select-on-focus
|
|
>
|
|
${this._suggestions
|
|
? html`
|
|
<pl-drawer slot="below" collapsed>
|
|
<div class="scrolling hide-scrollbar">
|
|
<div class="horizontal layout">
|
|
${this._suggestions.map(
|
|
(suggestion) => html`
|
|
<pl-button
|
|
class="tiny skinny transparent"
|
|
@click=${() => {
|
|
this._valueInput.value = this.field.value = suggestion;
|
|
this._collapseSuggestions();
|
|
}}
|
|
>
|
|
<pl-icon icon="suggestion" class="right-margined"></pl-icon>
|
|
${suggestion}
|
|
</pl-button>
|
|
`
|
|
)}
|
|
</div>
|
|
</div>
|
|
</pl-drawer>
|
|
`
|
|
: ""}
|
|
</pl-input>
|
|
`;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div class="horizontal layout">
|
|
<div class="half-margined collapse stretch">
|
|
<div class="field-header">
|
|
<pl-input
|
|
class="transparent tiny name-input"
|
|
placeholder="${this.editing ? $l("Enter Field Name") : $l("Unnamed")}"
|
|
.value=${this.field.name}
|
|
@input=${() => (this.field.name = this._nameInput.value)}
|
|
?readonly=${!this.editing}
|
|
>
|
|
<div class="spacer" slot="before"></div>
|
|
<pl-icon icon="${this._fieldDef.icon}" slot="before"></pl-icon>
|
|
${Object.values(AuditType).map((type) =>
|
|
this.auditResults.some((res) => res.type == type)
|
|
? html`
|
|
<pl-icon
|
|
icon="${iconForAudit(type)}"
|
|
slot="after"
|
|
class="negative highlighted right-margined"
|
|
title=${titleTextForAudit(type)}
|
|
style="cursor: help;"
|
|
></pl-icon>
|
|
<pl-popover
|
|
trigger="hover"
|
|
class="double-padded max-width-20em"
|
|
style="text-transform: none; color: var(--color-foreground); pointer-events: none;"
|
|
slot="after"
|
|
>
|
|
<div class="large bold">
|
|
<pl-icon icon="${iconForAudit(type)}" class="inline"></pl-icon>
|
|
${titleTextForAudit(type)}
|
|
</div>
|
|
<div class="top-margined">${descriptionForAudit(type)}</div>
|
|
</pl-popover>
|
|
`
|
|
: ""
|
|
)}
|
|
</pl-input>
|
|
</div>
|
|
|
|
<div class="field-value">
|
|
${this.editing ? this._renderEditValue() : this._renderDisplayValue()}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="left-margined horizontal centering layout" ?hidden=${!this.editing}>
|
|
<pl-button class="transparent skinny" @click=${() => this.dispatchEvent(new CustomEvent("remove"))}>
|
|
<pl-icon icon="delete"></pl-icon>
|
|
</pl-button>
|
|
|
|
<div class="vertical centering layout">
|
|
<pl-button
|
|
class="transparent move-button"
|
|
@click=${() => this.dispatchEvent(new CustomEvent("moveup"))}
|
|
?disabled=${!this.canMoveUp}
|
|
>
|
|
<pl-icon icon="dropup"></pl-icon>
|
|
</pl-button>
|
|
|
|
<pl-button
|
|
class="transparent move-button"
|
|
@click=${() => this.dispatchEvent(new CustomEvent("movedown"))}
|
|
?disabled=${!this.canMoveDown}
|
|
>
|
|
<pl-icon icon="dropdown"></pl-icon>
|
|
</pl-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<pl-drawer class="drawer" collapsed>
|
|
<div class="end-justifying horizontal wrapping layout">
|
|
${this._fieldActions.map(
|
|
({ icon, action, label }) => html`
|
|
<pl-button
|
|
class="transparent small slim"
|
|
@click=${() => action(this.field.value)}
|
|
style="min-width: 7em"
|
|
>
|
|
<div class="half-spacing center-alinging horizontal layout">
|
|
<pl-icon icon=${icon}></pl-icon>
|
|
<div>${label}</div>
|
|
</div>
|
|
</pl-button>
|
|
`
|
|
)}
|
|
</div>
|
|
</pl-drawer>
|
|
`;
|
|
}
|
|
}
|