Display totp tokens and attachments in list view

This commit is contained in:
Martin Kleinschrodt 2019-08-06 08:55:05 +02:00
parent 3ae8504614
commit 3d39e54215
7 changed files with 200 additions and 117 deletions

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ packages/server/db
packages/cordova/platforms
packages/cordova/plugins
packages/cordova/www
packages/cordova/dist

View File

@ -133,18 +133,20 @@ export class BaseElement extends LitElement {
}
updated(changed: Map<string, any>): void {
super.updated(changed);
let proto = this;
const observers = (<typeof BaseElement>this.constructor)._observers;
if (!observers) {
return;
}
for (const observer of observers) {
if (observer.props.some(p => changed.has(p))) {
observer.handler.call(this, changed);
do {
const observers = (<typeof BaseElement>proto.constructor)._observers;
if (!observers) {
return;
}
}
for (const observer of observers) {
if (observer.props.some(p => changed.has(p))) {
observer.handler.call(this, changed);
}
}
} while (proto instanceof BaseElement && (proto = Object.getPrototypeOf(proto)));
}
}

View File

@ -1,12 +1,11 @@
import { FieldType, FieldDef, FIELD_DEFS } from "@padloc/core/src/item";
import { localize as $l } from "@padloc/core/src/locale";
import { totp } from "@padloc/core/src/otp";
import { base32ToBytes } from "@padloc/core/src/encoding";
import { shared } from "../styles";
import { BaseElement, element, html, svg, css, property, query, observe } from "./base";
import { BaseElement, element, html, css, property, query } from "./base";
import "./icon";
import { Input } from "./input";
import { Select } from "./select";
import "./totp";
@element("pl-field")
export class FieldElement extends BaseElement {
@ -22,16 +21,6 @@ export class FieldElement extends BaseElement {
@property()
type: FieldType = "note";
@property()
private _totpToken = "";
@property()
private _totpLastTick = 0;
private get _totpAge() {
return Math.min(1, (Date.now() - this._totpLastTick) / 3e4);
}
@query("#nameInput")
private _nameInput: Input;
@ -130,33 +119,8 @@ export class FieldElement extends BaseElement {
border: none;
}
.totp {
pl-totp {
padding: 0 10px;
display: flex;
align-items: center;
user-select: text;
-webkit-user-select: text;
}
.countdown {
width: 16px;
height: 16px;
margin-left: 8px;
border-radius: 100%;
}
.countdown circle {
transform-origin: center center;
transform: rotate(-90deg);
fill: none;
stroke: currentColor;
stroke-width: 8;
stroke-dasharray: 25;
transition: stroke-dashoffset 1s;
}
.countdown circle.bg {
opacity: 0.2;
}
.drag-handle {
@ -251,17 +215,7 @@ export class FieldElement extends BaseElement {
${this.type === "totp" && !this.editing
? html`
<div class="totp">
${this._totpToken.substring(0, 3)}&nbsp;${this._totpToken.substring(3, 6)}
${svg`
<svg class="countdown" viewBox="0 0 10 10" ?hidden=${!this._totpToken}>
<circle cx="5" cy="5" r="4" class="bg" />
<circle cx="5" cy="5" r="4" style="stroke-dashoffset: ${Math.floor(
this._totpAge * -25
)}"/>
</svg>
`}
</div>
<pl-totp .secret=${this.value} .time=${Date.now()}></pl-totp>
`
: html`
<pl-input
@ -298,22 +252,4 @@ export class FieldElement extends BaseElement {
this._valueInput.masked = !this._valueInput.masked;
this.requestUpdate();
}
@observe("type")
@observe("value")
private async _updateTOTP() {
if (this.type !== "totp" || !this.value) {
this._totpToken = "";
return;
}
if (this._totpAge >= 1) {
this._totpLastTick = Math.floor(Date.now() / 3e4) * 3e4;
this._totpToken = await totp(base32ToBytes(this.value));
}
setTimeout(() => this._updateTOTP(), 2000);
this.requestUpdate();
}
}

View File

@ -83,7 +83,9 @@ export class ItemDialog extends Dialog<string, void> {
return super.show();
}
addAttachment() {
async addAttachment() {
await this.updateComplete;
if (this._vault!.id === app.mainVault!.id && !app.account!.quota.storage && app.billingConfig) {
this.dispatch("get-premium", {
message: $l("Upgrade to Premium now and get 1GB of encrypted file storage!"),

View File

@ -2,20 +2,23 @@ import { VaultItem, Field, Tag, FIELD_DEFS } from "@padloc/core/src/item";
import { Vault, VaultID } from "@padloc/core/src/vault";
import { localize as $l } from "@padloc/core/src/locale";
import { debounce, wait, escapeRegex } from "@padloc/core/src/util";
import { AttachmentInfo } from "@padloc/core/src/attachment";
import { cache } from "lit-html/directives/cache";
import { StateMixin } from "../mixins/state";
import { setClipboard } from "../clipboard";
import { app, router } from "../init";
import { dialog, confirm } from "../dialog";
import { mixins } from "../styles";
import { mask } from "../util";
import { mask, fileIcon, fileSize } from "../util";
import { element, html, css, property, query, listen, observe } from "./base";
import { View } from "./view";
import { Input } from "./input";
import { MoveItemsDialog } from "./move-items-dialog";
import { AttachmentDialog } from "./attachment-dialog";
import "./icon";
import "./items-filter";
import "./virtual-list";
import "./totp";
interface ListItem {
item: VaultItem;
@ -75,6 +78,9 @@ export class ItemsList extends StateMixin(View) {
@dialog("pl-move-items-dialog")
private _moveItemsDialog: MoveItemsDialog;
@dialog("pl-attachment-dialog")
private _attachmentDialog: AttachmentDialog;
private _multiSelect = new Map<string, ListItem>();
private _updateItems = debounce(() => {
@ -273,7 +279,6 @@ export class ItemsList extends StateMixin(View) {
.item-field > * {
transition: transform 0.2s cubic-bezier(1, -0.3, 0, 1.3), opacity 0.2s;
transform-origin: 50px center;
}
.item-field:not(.copied) .copied-message,
@ -283,11 +288,12 @@ export class ItemsList extends StateMixin(View) {
}
.copied-message {
${mixins.fullbleed()}
border-radius: inherit;
font-weight: bold;
color: var(--color-primary);
border-radius: inherit;
padding: 14px;
${mixins.fullbleed()}
text-align: center;
line-height: 45px;
}
.copied-message::before {
@ -317,6 +323,24 @@ export class ItemsList extends StateMixin(View) {
${mixins.ellipsis()}
}
.item-field-value > * {
vertical-align: middle;
}
.item-field.attachment {
display: flex;
align-items: center;
}
.attachment .file-icon {
display: inline-block;
height: 1em;
width: 1em;
font-size: 90%;
border-radius: 0;
vertical-align: middle;
}
.item:focus:not([selected]) {
border-color: var(--color-highlight);
color: #4ca8d9;
@ -573,6 +597,10 @@ export class ItemsList extends StateMixin(View) {
setTimeout(() => fieldEl.classList.remove("copied"), 1000);
}
private _openAttachment(a: AttachmentInfo, item: VaultItem, e: MouseEvent) {
e.stopPropagation();
this._attachmentDialog.show({ info: a, item: item.id });
}
private _getItems(): ListItem[] {
const recentCount = 0;
@ -638,26 +666,27 @@ export class ItemsList extends StateMixin(View) {
return items;
}
private _renderItem(item: ListItem) {
private _renderItem(li: ListItem) {
const { item, warning } = li;
const tags = [];
// const vaultName = item.vault.toString();
// const vaultName = vault.toString();
// tags.push({ name: vaultName, icon: "", class: "highlight" });
if (item.warning) {
if (warning) {
tags.push({ icon: "error", class: "tag warning", name: "" });
}
const t = item.item.tags.find(t => t === router.params.tag) || item.item.tags[0];
const t = item.tags.find(t => t === router.params.tag) || item.tags[0];
if (t) {
tags.push({
name: item.item.tags.length > 1 ? `${t} (+${item.item.tags.length - 1})` : t,
name: item.tags.length > 1 ? `${t} (+${item.tags.length - 1})` : t,
icon: "",
class: ""
});
}
const attCount = (item.item.attachments && item.item.attachments.length) || 0;
const attCount = (item.attachments && item.attachments.length) || 0;
if (attCount) {
tags.push({
name: "",
@ -666,7 +695,7 @@ export class ItemsList extends StateMixin(View) {
});
}
if (item.item.favorited && item.item.favorited.includes(app.account!.id)) {
if (item.favorited && item.favorited.includes(app.account!.id)) {
tags.push({
name: "",
icon: "favorite",
@ -675,28 +704,14 @@ export class ItemsList extends StateMixin(View) {
}
return html`
${cache(
false
? html`
<div class="section-header" ?hidden=${!item.firstInSection}>
<div>${item.section}</div>
<div class="spacer"></div>
<div>${item.section}</div>
</div>
`
: html``
)}
<div class="item" ?selected=${item.item.id === this.selected} @click=${() => this.selectItem(item)}>
<div class="item" ?selected=${item.id === this.selected} @click=${() => this.selectItem(li)}>
${cache(
this.multiSelect
? html`
<div
class="item-check"
?hidden=${!this.multiSelect}
?checked=${this._multiSelect.has(item.item.id)}
?checked=${this._multiSelect.has(item.id)}
></div>
`
: ""
@ -704,8 +719,8 @@ export class ItemsList extends StateMixin(View) {
<div class="item-body">
<div class="item-header">
<div class="item-name" ?disabled=${!item.item.name}>
${item.item.name || $l("No Name")}
<div class="item-name" ?disabled=${!item.name}>
${item.name || $l("No Name")}
</div>
<div class="tags small">
@ -724,26 +739,47 @@ export class ItemsList extends StateMixin(View) {
</div>
<div class="item-fields">
${item.item.fields.map((f: Field, i: number) => {
${item.fields.map((f: Field, i: number) => {
const fieldDef = FIELD_DEFS[f.type] || FIELD_DEFS.text;
return html`
<div
class="item-field tap"
@click=${(e: MouseEvent) => this._copyField(item.item, i, e)}
>
<div class="item-field tap" @click=${(e: MouseEvent) => this._copyField(item, i, e)}>
<div class="item-field-label">
<div class="item-field-name">${f.name || $l("Unnamed")}</div>
<div class="item-field-value">${fieldDef.mask ? mask(f.value) : f.value}</div>
${f.type === "totp"
? html`
<pl-totp class="item-field-value" .secret=${f.value}></pl-totp>
`
: html`
<div class="item-field-value">
${fieldDef.mask ? mask(f.value) : f.value}
</div>
`}
</div>
<div class="copied-message">${$l("copied")}</div>
</div>
`;
})}
${item.attachments.map(
a => html`
<div
class="item-field attachment tap"
@click=${(e: MouseEvent) => this._openAttachment(a, item, e)}
>
<div class="item-field-label">
<div class="item-field-name ellipsis">${a.name}</div>
<div class="item-field-value">
<pl-icon icon=${fileIcon(a.type)} class="file-icon"></pl-icon>
<span>${fileSize(a.size)}</span>
</div>
</div>
</div>
`
)}
${cache(
!item.item.fields.length
!item.fields.length && !item.attachments.length
? html`
<div class="item-field" disabled ?hidden=${!!item.item.fields.length}>
<div class="item-field" disabled ?hidden=${!!item.fields.length}>
<div class="item-field-label">
<div class="item-field-name">
${$l("No Fields")}

View File

@ -0,0 +1,105 @@
import { styleMap } from "lit-html/directives/style-map.js";
import { hotp } from "@padloc/core/src/otp";
import { base32ToBytes } from "@padloc/core/src/encoding";
import { shared } from "../styles";
import { BaseElement, element, html, svg, css, property } from "./base";
import "./icon";
@element("pl-totp")
export class TOTP extends BaseElement {
@property()
secret: string = "";
@property()
interval = 30;
@property()
private _token = "";
@property()
private _age = 0;
private _counter = 0;
private _updateTimeout = -1;
static styles = [
shared,
css`
:host {
display: flex;
align-items: center;
user-select: text;
-webkit-user-select: text;
font: inherit;
}
.countdown {
width: 1em;
height: 1em;
margin-left: 0.3em;
border-radius: 100%;
}
.countdown circle {
transform-origin: center center;
transform: rotate(-90deg);
fill: none;
stroke: currentColor;
stroke-width: 8;
stroke-dasharray: 25;
transition: stroke-dashoffset 1s linear;
}
.countdown circle.bg {
opacity: 0.2;
}
`
];
async _update(updInt = 2000) {
window.clearTimeout(this._updateTimeout);
const time = Date.now();
const counter = Math.floor(time / 1000 / this.interval);
if (counter !== this._counter) {
this._token = await hotp(base32ToBytes(this.secret), counter);
this._counter = counter;
}
this._age = ((Date.now() / 1000) % this.interval) / this.interval;
if (updInt) {
this._updateTimeout = window.setTimeout(() => this._update(updInt), updInt);
}
}
connectedCallback() {
super.connectedCallback();
this._update();
}
disconnectedCallback() {
super.disconnectedCallback();
window.clearTimeout(this._updateTimeout);
}
render() {
return html`
<span>
${this._token.substring(0, 3)}&nbsp;${this._token.substring(3, 6)}
</span>
${svg`
<svg class="countdown" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="4" class="bg" />
<circle
cx="5"
cy="5"
r="4"
style=${styleMap({ strokeDashoffset: Math.floor(this._age * -25).toString() })}
/>
</svg>
`}
`;
}
}

View File

@ -47,6 +47,7 @@ export class VirtualList<T> extends BaseElement {
connectedCallback() {
super.connectedCallback();
this.addEventListener("scroll", () => this._updateIndizes(), { passive: true });
this._updateBounds();
}
@observe("data", "itemMinWidth", "minItemWidth", "itemHeight")