624 lines
28 KiB
TypeScript
624 lines
28 KiB
TypeScript
import { translate as $l } from "@padloc/locale/src/translate";
|
|
import { ErrorCode } from "@padloc/core/src/error";
|
|
import { Vault } from "@padloc/core/src/vault";
|
|
import { app } from "../globals";
|
|
import { shared } from "../styles";
|
|
import { alert } from "../lib/dialog";
|
|
import { StateMixin } from "../mixins/state";
|
|
import { Routing } from "../mixins/routing";
|
|
import "./logo";
|
|
import "./button";
|
|
import "./drawer";
|
|
import "./scroller";
|
|
import "./list";
|
|
import "./popover";
|
|
import { customElement, property, state } from "lit/decorators.js";
|
|
import { css, html, LitElement } from "lit";
|
|
import { formatDateFromNow } from "../lib/util";
|
|
import { until } from "lit/directives/until.js";
|
|
import { ProvisioningStatus } from "@padloc/core/src/provisioning";
|
|
|
|
const orgPages = [
|
|
{ path: "dashboard", label: $l("Dashboard"), icon: "dashboard" },
|
|
{ path: "members", label: $l("Members"), icon: "members" },
|
|
{ path: "groups", label: $l("Groups"), icon: "group" },
|
|
{ path: "vaults", label: $l("Vaults"), icon: "vaults" },
|
|
{ path: "settings", label: $l("Settings"), icon: "settings" },
|
|
{ path: "invites", label: $l("Invites"), icon: "mail" },
|
|
];
|
|
|
|
@customElement("pl-menu")
|
|
export class Menu extends Routing(StateMixin(LitElement)) {
|
|
readonly routePattern = /^([^\/]+)(?:\/([^\/]+)\/([^\/]+))?/;
|
|
|
|
@property()
|
|
selected: string;
|
|
|
|
@state()
|
|
private _expanded = new Set<string>();
|
|
|
|
async handleRoute(
|
|
[page, id, subPage]: [string, string, string],
|
|
{ vault, tag, favorites, recent, attachments, host }: { [prop: string]: string }
|
|
) {
|
|
this._expanded.clear();
|
|
switch (page) {
|
|
case "items":
|
|
if (vault) {
|
|
this.selected = `vault/${vault}`;
|
|
const vlt = app.getVault(vault)!;
|
|
if (vlt?.org) {
|
|
this._expanded.add(`org_${vlt.org.id}_vaults`);
|
|
}
|
|
} else if (tag) {
|
|
this.selected = `tag/${tag}`;
|
|
this._expanded.add(`tags`);
|
|
} else if (favorites) {
|
|
this.selected = "favorites";
|
|
} else if (recent) {
|
|
this.selected = "recent";
|
|
} else if (attachments) {
|
|
this.selected = "attachments";
|
|
} else if (host) {
|
|
this.selected = "host";
|
|
} else {
|
|
this.selected = "items";
|
|
}
|
|
break;
|
|
case "orgs":
|
|
this._expanded.clear();
|
|
this._expanded.add(`org_${id}_manage`);
|
|
this.selected = `orgs/${id}/${subPage}`;
|
|
break;
|
|
case "invite":
|
|
this.selected = `invite/${id}/${subPage}`;
|
|
break;
|
|
default:
|
|
this.selected = page;
|
|
}
|
|
|
|
await this.updateComplete;
|
|
}
|
|
|
|
private _goTo(path: string, params?: any, e?: Event) {
|
|
this.dispatchEvent(new CustomEvent("toggle-menu", { bubbles: true, composed: true }));
|
|
this.go(path, params);
|
|
e && e.stopPropagation();
|
|
}
|
|
|
|
private async _lock() {
|
|
this.dispatchEvent(new CustomEvent("toggle-menu", { bubbles: true, composed: true }));
|
|
await app.lock();
|
|
this.go("unlock");
|
|
}
|
|
|
|
private _displayVaultError(vault: Vault, e?: Event) {
|
|
e && e.stopPropagation();
|
|
|
|
const error = vault.error!;
|
|
|
|
switch (error.code) {
|
|
case ErrorCode.UNSUPPORTED_VERSION:
|
|
alert(
|
|
$l(
|
|
"A newer version of {0} is required to synchronize this vault. Please update to the latest version now!",
|
|
process.env.PL_APP_NAME!
|
|
),
|
|
{
|
|
title: "Update Required",
|
|
type: "warning",
|
|
}
|
|
);
|
|
return;
|
|
case ErrorCode.MISSING_ACCESS:
|
|
alert($l("This vault could not be synchronized because you no longer have access to it."), {
|
|
title: "Sync Failed",
|
|
type: "warning",
|
|
});
|
|
return;
|
|
case ErrorCode.DECRYPTION_FAILED:
|
|
case ErrorCode.ENCRYPTION_FAILED:
|
|
alert(
|
|
$l("This vault could not be synchronized because you currently don't have access to it's data."),
|
|
{
|
|
title: "Sync Failed",
|
|
type: "warning",
|
|
}
|
|
);
|
|
return;
|
|
default:
|
|
alert(
|
|
error.message ||
|
|
$l(
|
|
"An unknown error occured while synchronizing this vault. If this problem persists please contact customer support."
|
|
),
|
|
{
|
|
title: "Sync Failed",
|
|
type: "warning",
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private _toggleExpanded(val: string) {
|
|
this._expanded.has(val) ? this._expanded.delete(val) : this._expanded.add(val);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private _nextTheme() {
|
|
const currTheme = app.settings.theme;
|
|
app.setSettings({ theme: currTheme === "auto" ? "dark" : currTheme === "dark" ? "light" : "auto" });
|
|
}
|
|
|
|
static styles = [
|
|
shared,
|
|
css`
|
|
:host {
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
background: var(--menu-background);
|
|
color: var(--color-foreground);
|
|
border-right: solid 1px var(--border-color);
|
|
}
|
|
|
|
.sub-list {
|
|
font-size: var(--font-size-small);
|
|
display: block;
|
|
padding-left: calc(2 * var(--spacing));
|
|
padding-right: 0.3em;
|
|
}
|
|
|
|
pl-logo {
|
|
height: var(--menu-logo-height, 2.5em);
|
|
width: var(--menu-logo-width, auto);
|
|
margin: 1em auto 0 auto;
|
|
}
|
|
|
|
.syncing {
|
|
width: 20px;
|
|
height: 20px;
|
|
margin: 5px;
|
|
}
|
|
|
|
.get-premium {
|
|
background: var(--color-negative);
|
|
}
|
|
|
|
.section-header {
|
|
margin: 0.5em 1.5em;
|
|
}
|
|
|
|
.errors-button {
|
|
background: var(--color-negative);
|
|
padding: 0;
|
|
padding-right: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.menu-footer {
|
|
border-top: var(--menu-footer-border);
|
|
}
|
|
|
|
.menu-footer-button {
|
|
--button-background: transparent;
|
|
--button-color: var(--menu-footer-button-color, var(--button-color));
|
|
--button-padding: var(--menu-footer-button-padding, var(--button-padding));
|
|
width: var(--menu-footer-button-width);
|
|
}
|
|
|
|
.menu-footer-button-icon {
|
|
font-size: var(--menu-footer-button-icon-size);
|
|
color: var(--menu-footer-button-color, var(--button-color));
|
|
}
|
|
|
|
.menu-footer-button-label {
|
|
font-size: var(--menu-footer-button-label-size);
|
|
color: var(--menu-footer-button-color, var(--button-color));
|
|
}
|
|
`,
|
|
];
|
|
|
|
render() {
|
|
const mainVault = app.mainVault;
|
|
const account = app.account;
|
|
|
|
const tags = app.state.tags;
|
|
|
|
const count = app.count;
|
|
|
|
const currentHost =
|
|
this.app.state.context.browser?.url &&
|
|
new URL(this.app.state.context.browser.url).hostname.replace(/^www\./, "");
|
|
|
|
return html`
|
|
<div class="padded">
|
|
<pl-logo reveal></pl-logo>
|
|
|
|
<div class="subtle tiny text-centering">v${process.env.PL_VENDOR_VERSION}</div>
|
|
|
|
<div class="spacer"></div>
|
|
</div>
|
|
|
|
<pl-scroller class="stretch">
|
|
<pl-list itemSelector=".menu-item">
|
|
<div class="small subtle section-header">${$l("Vaults & Items")}</div>
|
|
|
|
${currentHost
|
|
? html`
|
|
<div
|
|
class="menu-item"
|
|
role="link"
|
|
@click=${() => this._goTo("items", { host: true })}
|
|
aria-selected=${this.selected === "host"}
|
|
?hidden=${!count.currentHost}
|
|
>
|
|
<pl-icon icon="web"></pl-icon>
|
|
|
|
<div class="stretch ellipsis">${currentHost}</div>
|
|
|
|
<div class="small subtle">${count.currentHost}</div>
|
|
</div>
|
|
`
|
|
: ""}
|
|
|
|
<div
|
|
class="menu-item"
|
|
role="link"
|
|
@click=${() => this._goTo("items", {})}
|
|
aria-selected=${this.selected === "items"}
|
|
>
|
|
<pl-icon icon="vaults"></pl-icon>
|
|
<div class="stretch">${$l("All Vaults")}</div>
|
|
<div class="small subtle">${count.total}</div>
|
|
</div>
|
|
|
|
<div
|
|
class="menu-item"
|
|
role="link"
|
|
class="transparent horizontal center-aligning text-left-aligning spacing layout"
|
|
@click=${() => this._goTo("items", { recent: true })}
|
|
aria-selected=${this.selected === "recent"}
|
|
>
|
|
<pl-icon icon="time"></pl-icon>
|
|
|
|
<div class="stretch">${$l("Recently Used")}</div>
|
|
|
|
<div class="small subtle">${count.recent}</div>
|
|
</div>
|
|
|
|
<div
|
|
class="menu-item favorites"
|
|
role="link"
|
|
@click=${() => this._goTo("items", { favorites: true })}
|
|
aria-selected=${this.selected === "favorites"}
|
|
>
|
|
<pl-icon icon="favorite"></pl-icon>
|
|
|
|
<div class="stretch">${$l("Favorites")}</div>
|
|
|
|
<div class="small subtle">${count.favorites}</div>
|
|
</div>
|
|
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo("items", { attachments: true })}
|
|
aria-selected=${this.selected === "attachments"}
|
|
>
|
|
<pl-icon icon="attachment"></pl-icon>
|
|
|
|
<div class="stretch">${$l("Attachments")}</div>
|
|
|
|
<div class="small subtle">${count.attachments}</div>
|
|
</div>
|
|
|
|
${mainVault
|
|
? html`
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo("items", { vault: mainVault.id })}
|
|
aria-selected=${this.selected === `vault/${mainVault.id}`}
|
|
>
|
|
<pl-icon icon="vault"></pl-icon>
|
|
<div class="stretch">${$l("My Vault")}</div>
|
|
${mainVault.error
|
|
? html`
|
|
<pl-button
|
|
class="small negative borderless skinny negatively-margined"
|
|
@click=${(e: Event) => this._displayVaultError(mainVault, e)}
|
|
>
|
|
<pl-icon icon="error"></pl-icon>
|
|
</pl-button>
|
|
`
|
|
: html` <div class="small subtle">${mainVault.items.size}</div> `}
|
|
</div>
|
|
`
|
|
: ""}
|
|
${app.orgs.map((org) => {
|
|
const vaults = app.vaults.filter((v) => v.org && v.org.id === org.id);
|
|
|
|
return html`
|
|
<div>
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._toggleExpanded(`org_${org.id}_vaults`)}
|
|
aria-expanded=${this._expanded.has(`org_${org.id}_vaults`)}
|
|
>
|
|
<pl-icon icon="vaults"></pl-icon>
|
|
<div class="stretch ellipsis">${org.name}</div>
|
|
<pl-button
|
|
class="small transparent round slim negatively-margined reveal-on-hover"
|
|
@click=${(e: Event) => this._goTo(`orgs/${org.id}`, undefined, e)}
|
|
>
|
|
<pl-icon icon="settings"></pl-icon>
|
|
</pl-button>
|
|
<pl-icon icon="chevron-down" class="small subtle dropdown-icon"></pl-icon>
|
|
</div>
|
|
|
|
<pl-drawer .collapsed=${!this._expanded.has(`org_${org.id}_vaults`)}>
|
|
<pl-list class="sub-list">
|
|
${vaults.map((vault) => {
|
|
return html`
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo("items", { vault: vault.id })}
|
|
aria-selected=${this.selected === `vault/${vault.id}`}
|
|
>
|
|
<pl-icon icon="vault"></pl-icon>
|
|
<div class="stretch ellipsis">${vault.name}</div>
|
|
|
|
${vault.error
|
|
? html`
|
|
<pl-button
|
|
class="small negative borderless skinny negatively-margined"
|
|
@click=${(e: Event) =>
|
|
this._displayVaultError(vault, e)}
|
|
>
|
|
<pl-icon icon="error"></pl-icon>
|
|
</pl-button>
|
|
`
|
|
: html` <div class="small subtle">${vault.items.size}</div> `}
|
|
</div>
|
|
`;
|
|
})}
|
|
|
|
<div
|
|
class="menu-item subtle"
|
|
@click=${() => this._goTo(`orgs/${org.id}/vaults/new`)}
|
|
>
|
|
<pl-icon icon="add"></pl-icon>
|
|
|
|
<div class="stretch">${$l("New Vault")}</div>
|
|
</div>
|
|
</pl-list>
|
|
</pl-drawer>
|
|
</div>
|
|
`;
|
|
})}
|
|
|
|
<div>
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._toggleExpanded("tags")}
|
|
aria-expanded=${this._expanded.has("tags")}
|
|
>
|
|
<pl-icon icon="tags"></pl-icon>
|
|
<div class="stretch ellipsis">${$l("Tags")}</div>
|
|
<pl-icon icon="chevron-down" class="small subtle dropdown-icon"></pl-icon>
|
|
</div>
|
|
|
|
<pl-drawer .collapsed=${!this._expanded.has("tags")}>
|
|
${tags.length
|
|
? html`
|
|
<pl-list class="sub-list">
|
|
${tags.map(
|
|
([tag, count]) => html`
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo("items", { tag })}
|
|
aria-selected=${this.selected === `tag/${tag}`}
|
|
>
|
|
<pl-icon icon="tag"></pl-icon>
|
|
|
|
<div class="stretch ellipsis">${tag}</div>
|
|
|
|
<div class="small subtle">${count}</div>
|
|
</div>
|
|
`
|
|
)}
|
|
</pl-list>
|
|
`
|
|
: html`
|
|
<div class="small padded subtle text-centering">
|
|
${$l("You don't have any tags yet.")}
|
|
</div>
|
|
`}
|
|
</pl-drawer>
|
|
</div>
|
|
|
|
<div class="small subtle section-header">${$l("Orgs & Teams")}</div>
|
|
|
|
<pl-list>
|
|
${app.orgs
|
|
.filter((org) => org.isAdmin(account!))
|
|
.map(
|
|
(org) => html`
|
|
<div>
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._toggleExpanded(`org_${org.id}_manage`)}
|
|
aria-expanded=${this._expanded.has(`org_${org.id}_manage`)}
|
|
>
|
|
<pl-icon icon="org"></pl-icon>
|
|
<div class="stretch ellipsis">${org.name}</div>
|
|
${app.getOrgProvisioning(org).status !== ProvisioningStatus.Active
|
|
? html`
|
|
<pl-icon
|
|
icon="warning"
|
|
class="small negative highlighted"
|
|
></pl-icon>
|
|
`
|
|
: ""}
|
|
<pl-icon icon="chevron-down" class="small subtle dropdown-icon"></pl-icon>
|
|
</div>
|
|
|
|
<pl-drawer .collapsed=${!this._expanded.has(`org_${org.id}_manage`)}>
|
|
<pl-list class="sub-list">
|
|
${orgPages.map(
|
|
({ label, icon, path }) => html` <div
|
|
class="menu-item"
|
|
aria-selected=${this.selected === `orgs/${org.id}/${path}`}
|
|
@click=${() => this._goTo(`orgs/${org.id}/${path}`)}
|
|
?hidden=${(["settings", "invites"].includes(path) &&
|
|
!org.isOwner(account!)) ||
|
|
(path === "groups" &&
|
|
!org.groups.length &&
|
|
app.getOrgFeatures(org).addGroup.hidden)}
|
|
>
|
|
<pl-icon icon="${icon}"></pl-icon>
|
|
|
|
<div class="stretch ellipsis">${label}</div>
|
|
|
|
${app.getOrgProvisioning(org).status !==
|
|
ProvisioningStatus.Active && path === "dashboard"
|
|
? html`
|
|
<pl-icon
|
|
icon="warning"
|
|
class="small negative highlighted"
|
|
></pl-icon>
|
|
`
|
|
: ""}
|
|
</div>`
|
|
)}
|
|
</pl-list>
|
|
</pl-drawer>
|
|
</div>
|
|
`
|
|
)}
|
|
|
|
<div
|
|
class="menu-item subtle"
|
|
?hidden=${app.getAccountFeatures().createOrg.hidden}
|
|
@click=${() =>
|
|
this.dispatchEvent(new CustomEvent("create-org", { bubbles: true, composed: true }))}
|
|
>
|
|
<pl-icon icon="add"></pl-icon>
|
|
|
|
<div class="stretch">${$l("New Organization")}</div>
|
|
</div>
|
|
</pl-list>
|
|
|
|
${app.authInfo?.invites.length
|
|
? html`
|
|
<div class="small subtle section-header">${$l("Invites")}</div>
|
|
${app.authInfo.invites.map(
|
|
(invite) => html`
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo(`invite/${invite.orgId}/${invite.id}`)}
|
|
aria-selected=${this.selected === `invite/${invite.orgId}/${invite.id}`}
|
|
>
|
|
<pl-icon icon="mail"></pl-icon>
|
|
|
|
<div class="stretch">${invite.orgName}</div>
|
|
|
|
<pl-icon icon="chevron-right" class="small subtle dropdown-icon"></pl-icon>
|
|
</div>
|
|
`
|
|
)}
|
|
`
|
|
: ""}
|
|
|
|
<div class="small subtle section-header">${$l("More")}</div>
|
|
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo("settings")}
|
|
aria-selected=${this.selected === "settings"}
|
|
>
|
|
<pl-icon icon="settings"></pl-icon>
|
|
|
|
<div class="stretch">${$l("Settings")}</div>
|
|
|
|
${app.getAccountProvisioning().status !== ProvisioningStatus.Active
|
|
? html` <pl-icon icon="warning" class="small negative highlighted"></pl-icon> `
|
|
: ""}
|
|
</div>
|
|
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo("generator")}
|
|
aria-selected=${this.selected === "generator"}
|
|
>
|
|
<pl-icon icon="generate"></pl-icon>
|
|
|
|
<div class="stretch">${$l("Password Generator")}</div>
|
|
</div>
|
|
|
|
<div
|
|
class="menu-item"
|
|
@click=${() => this._goTo("support")}
|
|
aria-selected=${this.selected === "support"}
|
|
>
|
|
<pl-icon icon="support"></pl-icon>
|
|
|
|
<div class="stretch">${$l("Support")}</div>
|
|
</div>
|
|
|
|
<div class="spacer"></div>
|
|
</pl-list>
|
|
</pl-scroller>
|
|
|
|
<div class="half-padded center-aligning horizontal layout menu-footer">
|
|
<pl-button class="menu-footer-button" @click=${this._lock} title="${$l("Lock App")}">
|
|
<div class="vertical centering layout">
|
|
<pl-icon icon="lock" class="menu-footer-button-icon"></pl-icon>
|
|
<div class="menu-footer-button-label">Lock</div>
|
|
</div>
|
|
</pl-button>
|
|
<pl-button class="menu-footer-button" @click=${this._nextTheme} title="Theme: ${app.settings.theme}">
|
|
<div class="vertical centering layout">
|
|
<pl-icon icon="theme-${app.settings.theme}" class="menu-footer-button-icon"></pl-icon>
|
|
<div class="menu-footer-button-label">Theme</div>
|
|
</div>
|
|
</pl-button>
|
|
<pl-popover
|
|
class="double-padded tiny"
|
|
trigger="hover"
|
|
.preferAlignment=${["top", "top-left", "top-right"]}
|
|
>
|
|
<strong>${$l("Theme:")}</strong> ${app.settings.theme}
|
|
</pl-popover>
|
|
<pl-button
|
|
class="menu-footer-button"
|
|
@click=${() => app.synchronize()}
|
|
.state=${app.state.syncing ? "loading" : "idle"}
|
|
>
|
|
<div class="vertical centering layout">
|
|
<pl-icon icon="refresh" class="menu-footer-button-icon"></pl-icon>
|
|
<div class="menu-footer-button-label">Sync</div>
|
|
</div>
|
|
</pl-button>
|
|
<pl-popover
|
|
class="double-padded tiny"
|
|
trigger="hover"
|
|
.preferAlignment=${["top", "top-left", "top-right"]}
|
|
>
|
|
<strong>${$l("Last Sync:")}</strong> ${app.state.stats.lastSync
|
|
? until(formatDateFromNow(app.state.stats.lastSync), "")
|
|
: $l("Never")}
|
|
</pl-popover>
|
|
<pl-button class="menu-footer-button" @click=${() => this._goTo("settings")}>
|
|
<div class="vertical centering layout">
|
|
<pl-icon icon="settings" class="menu-footer-button-icon"></pl-icon>
|
|
<div class="menu-footer-button-label">Settings</div>
|
|
</div>
|
|
</pl-button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|