Display totp tokens and attachments in list view
This commit is contained in:
parent
3ae8504614
commit
3d39e54215
|
@ -12,3 +12,4 @@ packages/server/db
|
|||
packages/cordova/platforms
|
||||
packages/cordova/plugins
|
||||
packages/cordova/www
|
||||
packages/cordova/dist
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)} ${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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!"),
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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)} ${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>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue