Implement Stripe Integration for v4 (#417)

Add a proper implementation of StripeProvisioner, which builds on BasicProvisioner and derives provisioning profiles from Stripe subscriptions. There's been some refactoring of the core provisioning logic as well.
This commit is contained in:
Martin Kleinschrodt 2022-04-01 14:08:25 +02:00 committed by GitHub
parent 798ef7621a
commit 2f8c900a3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2273 additions and 858 deletions

View File

@ -67,45 +67,35 @@ jobs:
- name: Archive AppImage
uses: actions/upload-artifact@v2
if: matrix.platform == 'ubuntu-latest'
env:
TARGET_DIR: ${{ github.event.inputs.environment == 'Production' && 'release' || 'debug' }}
with:
name: padloc-linux-${{ github.sha }}-unsigned.AppImage
path: packages/tauri/src-tauri/target/${{ env.TARGET_DIR }}/bundle/linux/padloc*.AppImage
path: packages/tauri/src-tauri/target/release/bundle/appimage/padloc*.AppImage
if-no-files-found: error
- name: Archive deb
uses: actions/upload-artifact@v2
if: matrix.platform == 'ubuntu-latest'
env:
TARGET_DIR: ${{ github.event.inputs.environment == 'Production' && 'release' || 'debug' }}
with:
name: padloc-linux-${{ github.sha }}-unsigned.deb
path: packages/tauri/src-tauri/target/${{ env.TARGET_DIR }}/bundle/linux/*.deb
path: packages/tauri/src-tauri/target/release/bundle/deb/*.deb
if-no-files-found: error
- name: Archive dmg
uses: actions/upload-artifact@v2
if: matrix.platform == 'macos-latest'
env:
TARGET_DIR: ${{ github.event.inputs.environment == 'Production' && 'release' || 'debug' }}
with:
name: padloc-macos-${{ github.sha }}-unsigned.dmg
path: packages/tauri/src-tauri/target/${{ env.TARGET_DIR }}/bundle/macos/*.dmg
path: packages/tauri/src-tauri/target/release/bundle/dmg/*.dmg
if-no-files-found: error
- name: Archive app
uses: actions/upload-artifact@v2
if: matrix.platform == 'macos-latest'
env:
TARGET_DIR: ${{ github.event.inputs.environment == 'Production' && 'release' || 'debug' }}
with:
name: padloc-macos-${{ github.sha }}-unsigned.app
path: packages/tauri/src-tauri/target/${{ env.TARGET_DIR }}/bundle/macos/*.app
path: packages/tauri/src-tauri/target/release/bundle/macos/*.app
if-no-files-found: error
- name: Archive msi
uses: actions/upload-artifact@v2
if: matrix.platform == 'windows-latest'
env:
TARGET_DIR: ${{ github.event.inputs.environment == 'Production' && 'release' || 'debug' }}
with:
name: padloc-windows-${{ github.sha }}-unsigned.msi
path: packages/tauri/src-tauri/target/${{ env.TARGET_DIR }}/bundle/windows/*.msi
path: packages/tauri/src-tauri/target/release/bundle/msi/*.msi
if-no-files-found: error

View File

@ -18,8 +18,9 @@ body {
--font-family-fallback: sans-serif;
--font-family-mono: "Space Mono";
--font-size-base: medium;
--font-size-small: 0.85em;
--font-size-micro: 0.6em;
--font-size-tiny: 0.7em;
--font-size-small: 0.85em;
--font-size-large: 1.2em;
--font-size-big: 1.35em;
--font-size-huge: 1.5em;

View File

@ -17,6 +17,9 @@ export interface AlertOptions {
preventDismiss?: boolean;
vertical?: boolean;
preventAutoClose?: boolean;
maxWidth?: string;
width?: string;
hideOnDocumentVisibilityChange?: boolean;
}
@customElement("pl-alert-dialog")
@ -35,6 +38,10 @@ export class AlertDialog extends Dialog<AlertOptions, number> {
options: (string | TemplateResult)[] = [];
@property({ type: Boolean, reflect: true })
vertical: boolean = false;
@property()
maxWidth?: string;
@property()
width?: string;
static styles = [
...Dialog.styles,
@ -62,16 +69,16 @@ export class AlertDialog extends Dialog<AlertOptions, number> {
const { message, dialogTitle, options, icon, vertical } = this;
return html`
<div class="scrolling fit">
<div class="scrolling-vertically fit">
<div class="padded">
${dialogTitle || message
? html`
<div class="margined horizontal layout">
${icon ? html` <pl-icon class="big" icon="${icon}"></pl-icon> ` : ""}
<div class="stretch left-margined">
<div class="bold large">${dialogTitle}</div>
<div>${message}</div>
<div class="stretch fit-horizontally ${icon ? "left-margined" : ""}">
<div class="bold large bottom-half-margined">${dialogTitle}</div>
<div style="word-break: break-word;">${message}</div>
</div>
</div>
@ -100,7 +107,7 @@ export class AlertDialog extends Dialog<AlertOptions, number> {
super.done(i);
}
show({
async show({
message = "",
title = "",
options = ["OK"],
@ -109,6 +116,9 @@ export class AlertDialog extends Dialog<AlertOptions, number> {
vertical = false,
icon = this._icon(type),
preventAutoClose,
maxWidth,
width,
hideOnDocumentVisibilityChange = false,
}: AlertOptions = {}): Promise<number> {
this.message = message;
this.dialogTitle = title;
@ -120,8 +130,21 @@ export class AlertDialog extends Dialog<AlertOptions, number> {
if (typeof preventAutoClose !== "undefined") {
this.preventAutoClose = preventAutoClose;
}
this.maxWidth = maxWidth;
this.width = width;
return super.show();
const promise = super.show();
await this.updateComplete;
this._inner.style.setProperty("--pl-dialog-max-width", maxWidth || "inherit");
this._inner.style.setProperty("--pl-dialog-width", width || "inherit");
if (hideOnDocumentVisibilityChange) {
document.addEventListener("visibilitychange", () => this.done(), { once: true });
}
return promise;
}
private _icon(type: string) {

View File

@ -26,8 +26,8 @@ import "./menu";
import { registerPlatformAuthenticator, supportsPlatformAuthenticator } from "@padloc/core/src/platform";
import { AuthPurpose } from "@padloc/core/src/auth";
import { ProvisioningStatus } from "@padloc/core/src/provisioning";
import "./markdown-content";
import { displayProvisioning } from "../lib/provisioning";
import "./rich-content";
import { alertDisabledFeature, displayProvisioning, getDefaultStatusLabel } from "../lib/provisioning";
import { ItemsView } from "./items";
import { wait } from "@padloc/core/src/util";
@ -220,7 +220,6 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
position: absolute;
right: 0.2em;
bottom: 0.15em;
font-size: var(--font-size-small);
}
.menu-scrim {
@ -301,18 +300,18 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
<div class="offline-indicator">
${$l("o f f l i n e")}
<pl-button class="transparent slim" @click=${this._showOfflineAlert}>
<pl-icon icon="info"></pl-icon>
<pl-button class="transparent skinny" @click=${this._showOfflineAlert}>
<pl-icon icon="info-round"></pl-icon>
</pl-button>
</div>
`
: provisioning?.status === ProvisioningStatus.Frozen
? html`
<div class="offline-indicator">
${provisioning.statusLabel || $l("Account Frozen")}
${provisioning.statusLabel || getDefaultStatusLabel(provisioning.status)}
<pl-button class="transparent slim" @click=${() => displayProvisioning(provisioning)}>
<pl-icon icon="info"></pl-icon>
<pl-button class="transparent skinny" @click=${() => displayProvisioning(provisioning)}>
<pl-icon icon="info-round"></pl-icon>
</pl-button>
</div>
`
@ -448,6 +447,12 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
}
async _createOrg() {
const feature = app.getAccountFeatures().createOrg;
if (feature.disabled) {
alertDisabledFeature(feature);
return;
}
const org = await this._createOrgDialog.show();
if (org) {
router.go(`orgs/${org.id}`);

View File

@ -2,7 +2,6 @@ import { Vault } from "@padloc/core/src/vault";
import { VaultItem, Field, ItemTemplate, ITEM_TEMPLATES, FieldType } from "@padloc/core/src/item";
import { translate as $l } from "@padloc/locale/src/translate";
import { app, router } from "../globals";
import { alert } from "../lib/dialog";
import { Select } from "./select";
import { Dialog } from "./dialog";
import "./scroller";
@ -97,13 +96,6 @@ export class CreateItemDialog extends Dialog<Vault, VaultItem> {
return;
}
const quota = app.getItemsQuota(vault);
if (quota !== -1 && vault.items.size >= quota) {
this.done();
alert($l("You have reached the maximum number of items for this account!"), { type: "warning" });
return;
}
const item = await app.createItem({
name: this._template.name || "",
vault,

View File

@ -29,7 +29,7 @@ export class Dialog<I, R> extends LitElement {
dismissOnTapOutside: boolean = true;
@query(".inner")
private _inner: HTMLDivElement;
protected _inner: HTMLDivElement;
readonly hideApp: boolean = false;
@ -89,7 +89,7 @@ export class Dialog<I, R> extends LitElement {
.inner {
position: relative;
width: 100%;
width: var(--pl-dialog-width, 100%);
height: auto;
max-height: 100%;
box-sizing: border-box;

View File

@ -107,6 +107,12 @@ export class GroupView extends Routing(StateMixin(LitElement)) {
this._nameInput && (this._nameInput.value = (this._group && this._group.name) || "");
}
private _cancel() {
if (this.groupName === "new") {
this.go(`orgs/${this.orgId}/groups`);
}
}
private _addMember({ id }: OrgMember) {
this._members.push({ id });
this.requestUpdate();
@ -251,12 +257,12 @@ export class GroupView extends Routing(StateMixin(LitElement)) {
</pl-button>
<pl-input
class="transparent large bold skinny dashed stretch"
class="transparent large bold skinny stretch"
placeholder="Enter Group Name"
id="nameInput"
@change=${() => this.requestUpdate()}
>${group.name}</pl-input
@input=${() => this.requestUpdate()}
>
</pl-input>
<pl-button class="transparent left-margined" ?hidden=${!accountIsAdmin || this.groupName === "new"}>
<pl-icon icon="more"></pl-icon>
@ -434,12 +440,20 @@ export class GroupView extends Routing(StateMixin(LitElement)) {
</section>
</pl-scroller>
<div class="padded horizontal spacing evenly stretching layout" ?hidden=${!this.hasChanges}>
<pl-button class="primary" id="saveButton" ?disabled=${!this.hasChanges} @click=${this._save}>
<div
class="padded horizontal spacing evenly stretching layout"
?hidden=${this.groupName !== "new" && !this.hasChanges}
>
<pl-button
class="primary"
id="saveButton"
?disabled=${!this._nameInput?.value || (!this._members.length && !this._vaults.length)}
@click=${this._save}
>
${$l("Save")}
</pl-button>
<pl-button @click=${this.clearChanges}> ${this.hasChanges ? $l("Cancel") : $l("Close")} </pl-button>
<pl-button @click=${this._cancel}> ${$l("Cancel")} </pl-button>
</div>
</div>
`;

View File

@ -234,6 +234,10 @@ export class PlIcon extends LitElement {
content: "\\f071";
}
:host([icon="warning"]) > div::before {
content: "\\f071";
}
:host([icon="question"]) > div::before {
content: "\\f059";
}
@ -243,11 +247,11 @@ export class PlIcon extends LitElement {
}
:host([icon="group"]) > div::before {
content: "\\f509";
content: "\\e594";
}
:host([icon="members"]) > div::before {
content: "\\f0c0";
content: "\\e533";
}
:host([icon="vaults"]) > div::before {
@ -466,6 +470,10 @@ export class PlIcon extends LitElement {
content: "\\f505";
}
:host([icon="owner"]) > div::before {
content: "\\f6a4";
}
:host([icon="user-check"]) > div::before {
content: "\\f4fc";
}
@ -475,7 +483,7 @@ export class PlIcon extends LitElement {
}
:host([icon="dashboard"]) > div::before {
content: "\\f62a";
content: "\\e323";
}
:host([icon="update"]) > div::before {
@ -545,6 +553,10 @@ export class PlIcon extends LitElement {
:host([icon="celebrate"]) > div::before {
content: "\\e383";
}
:host([icon="frozen"]) > div::before {
content: "\\f2dc";
}
`,
];

View File

@ -337,14 +337,6 @@ export class ImportDialog extends Dialog<File, void> {
private async _import() {
const vault = this._vaultSelect.value!;
const quota = app.getItemsQuota(vault);
if (quota !== -1 && vault.items.size + this._items.length > quota) {
this.done();
alert($l("The number of imported items exceeds your remaining quota."), { type: "warning" });
return;
}
app.addItems(this._items, vault);
this.done();
alert($l("Successfully imported {0} items.", this._items.length.toString()), { type: "success" });

View File

@ -14,6 +14,7 @@ import { UnlockedOrg } from "@padloc/core/src/org";
import { UnlockedAccount } from "@padloc/core/src/account";
import { customElement, property, query, state } from "lit/decorators.js";
import { css, html, LitElement } from "lit";
import { checkFeatureDisabled } from "../lib/provisioning";
@customElement("pl-invite-view")
export class InviteView extends Routing(StateMixin(LitElement)) {
@ -116,6 +117,10 @@ export class InviteView extends Routing(StateMixin(LitElement)) {
}
private async _confirm() {
if (checkFeatureDisabled(app.getOrgFeatures(this._org!).addMember, this._org!.isOwner(app.account!))) {
return;
}
if (this._confirmButton.state === "loading") {
return;
}

View File

@ -30,6 +30,7 @@ import "./list";
import "./attachment";
import { customElement, property, query, queryAll, state } from "lit/decorators.js";
import { css, html, LitElement } from "lit";
import { checkFeatureDisabled } from "../lib/provisioning";
@customElement("pl-item-view")
export class ItemView extends Routing(StateMixin(LitElement)) {
@ -55,6 +56,10 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
return found && found.vault;
}
private get _org() {
return this._vault?.org ? app.getOrg(this._vault.org.id) : null;
}
private get _isEditable() {
return this._vault && app.isEditable(this._vault);
}
@ -143,10 +148,19 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
}
async addAttachment() {
if (this._checkAttachmentsDiabled()) {
return;
}
await this.updateComplete;
this._fileInput.click();
}
private _checkAttachmentsDiabled() {
return this._org
? checkFeatureDisabled(app.getOrgFeatures(this._org).attachments, this._org.isOwner(app.account!))
: checkFeatureDisabled(app.getAccountFeatures().attachments);
}
private _moveField(index: number, target: "up" | "down" | number) {
const field = this._fields[index];
this._fields.splice(index, 1);
@ -576,6 +590,10 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
}
private async _addFileAttachment(file: File) {
if (this._checkAttachmentsDiabled()) {
return;
}
if (!file) {
return;
}

View File

@ -18,7 +18,7 @@ import { GeneratorDialog } from "./generator-dialog";
import "./scroller";
import { Drawer } from "./drawer";
import { AccountProvisioning, ProvisioningStatus } from "@padloc/core/src/provisioning";
import "./markdown-content";
import "./rich-content";
import { displayProvisioning } from "../lib/provisioning";
import { StartAuthRequestResponse } from "@padloc/core/src/api";
import { Confetti } from "./confetti";

View File

@ -47,7 +47,9 @@ export class MemberItem extends LitElement {
${this.hideInfo
? ""
: html`
${groups.length === 1
${!groups.length
? ""
: groups.length === 1
? html`
<div class="tiny tag">
<pl-icon icon="group" class="inline"></pl-icon>
@ -61,9 +63,17 @@ export class MemberItem extends LitElement {
</div>
`}
${isOwner
? html` <div class="tiny tag warning">${$l("Owner")}</div> `
? html`
<div class="tiny tag warning">
<pl-icon class="inline" icon="owner"></pl-icon> ${$l("Owner")}
</div>
`
: isAdmin
? html` <div class="tiny tag highlight">${$l("Admin")}</div> `
? html`
<div class="tiny tag highlight">
<pl-icon class="inline" icon="admin"></pl-icon> ${$l("Admin")}
</div>
`
: isSuspended
? html` <div class="tiny tag warning">${$l("Suspended")}</div> `
: ""}

View File

@ -303,9 +303,17 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
<div class="small tags">
${isOwner
? html` <div class="tag warning">${$l("Owner")}</div> `
? html`
<div class="tag warning">
<pl-icon class="inline" icon="owner"></pl-icon> ${$l("Owner")}
</div>
`
: isAdmin
? html` <div class="tag highlight">${$l("Admin")}</div> `
? html`
<div class="tag highlight">
<pl-icon class="inline" icon="admin"></pl-icon> ${$l("Admin")}
</div>
`
: isSuspended
? html` <div class="tag warning">${$l("Suspended")}</div> `
: ""}

View File

@ -16,6 +16,7 @@ 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" },
@ -91,12 +92,6 @@ export class Menu extends Routing(StateMixin(LitElement)) {
this.go("unlock");
}
private _getPremium(e?: MouseEvent) {
e && e.stopPropagation();
this.dispatchEvent(new CustomEvent("get-premium", { bubbles: true, composed: true }));
this.dispatchEvent(new CustomEvent("toggle-menu", { bubbles: true, composed: true }));
}
private _displayVaultError(vault: Vault, e?: Event) {
e && e.stopPropagation();
@ -231,8 +226,6 @@ export class Menu extends Routing(StateMixin(LitElement)) {
const mainVault = app.mainVault;
const account = app.account;
const itemsQuota = app.getItemsQuota();
const tags = app.state.tags;
const count = app.count;
@ -340,15 +333,6 @@ export class Menu extends Routing(StateMixin(LitElement)) {
<pl-icon icon="error"></pl-icon>
</pl-button>
`
: itemsQuota !== -1
? html`
<pl-button
class="small negative borderless skinny negatively-margined"
@click=${this._getPremium}
>
${mainVault.items.size} / ${itemsQuota}
</pl-button>
`
: html` <div class="small subtle">${mainVault.items.size}</div> `}
</div>
`
@ -470,6 +454,14 @@ export class Menu extends Routing(StateMixin(LitElement)) {
>
<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>
@ -480,12 +472,25 @@ export class Menu extends Routing(StateMixin(LitElement)) {
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!)}
?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>
@ -496,6 +501,7 @@ export class Menu extends Routing(StateMixin(LitElement)) {
<div
class="menu-item subtle"
?hidden=${app.getAccountFeatures().createOrg.hidden}
@click=${() =>
this.dispatchEvent(new CustomEvent("create-org", { bubbles: true, composed: true }))}
>
@ -536,6 +542,10 @@ export class Menu extends Routing(StateMixin(LitElement)) {
<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

View File

@ -16,6 +16,8 @@ import "./scroller";
import "./list";
import { customElement, property } from "lit/decorators.js";
import { css, html, LitElement } from "lit";
import { ProvisioningStatus } from "@padloc/core/src/provisioning";
import "./rich-content";
@customElement("pl-org-dashboard")
export class OrgDashboard extends Routing(StateMixin(LitElement)) {
@ -70,7 +72,7 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
}
const org = this._org!;
const quota = app.getOrgProvisioning(org)?.quota;
const { status, quota, statusMessage } = app.getOrgProvisioning(org);
return html`
<div class="fullbleed vertical layout background">
@ -97,7 +99,7 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
<div
class="small double-padded list-item center-aligning spacing horizontal layout hover click"
@click=${() => this.go(`orgs/${this.orgId}/groups/new`)}
?hidden=${quota?.groups === 0}
?hidden=${!org.groups.length && app.getOrgFeatures(org).addGroup.hidden}
>
<pl-icon icon="group"></pl-icon>
<div>New Group</div>
@ -124,13 +126,42 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
<pl-scroller class="stretch">
<div class="sections">
${status === ProvisioningStatus.Frozen
? html`
<section class="negative highlighted box vertical layout">
<h2
class="uppercase bg-dark border-bottom semibold center-aligning horizontal layout"
>
<pl-icon icon="frozen" class="left-margined"></pl-icon>
<div class="padded">${$l("This org is frozen")}</div>
</h2>
<pl-rich-content
class="padded block stretch"
.content=${statusMessage}
.type=${"markdown"}
></pl-rich-content>
${org.isOwner(app.account!)
? html`
<pl-button
class="transparent half-margined"
@click=${() => this.go("settings/billing")}
>
<div>Review Billing</div>
<pl-icon icon="arrow-right" class="left-margined"></pl-icon>
</pl-button>
`
: ""}
</section>
`
: ""}
<section class="box" ?hidden=${!org.invites.length || !org.isOwner(this.app.account!)}>
<h2
class="uppercase bg-dark border-bottom semibold center-aligning spacing horizontal layout"
>
<div></div>
<pl-icon icon="mail" class="left-margined"></pl-icon>
<div>${$l("Invites")}</div>
<div class="tiny bold tag">${org.invites.length}</div>
<div class="subtle bold">${org.invites.length}</div>
<div class="stretch"></div>
<pl-button
class="skinny transparent half-margined"
@ -166,9 +197,17 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
<h2
class="uppercase bg-dark border-bottom semibold center-aligning spacing horizontal layout"
>
<div></div>
<pl-icon icon="members" class="left-margined"></pl-icon>
<div>${$l("Members")}</div>
<div class="tiny bold tag">${org.members.length}</div>
<div
class="${quota.members !== -1 && org.members.length > quota.members
? "negative highlight"
: "subtle"}"
>
<strong>${org.members.length}</strong>${quota.members !== -1
? ` / ${quota.members}`
: ""}
</div>
<div class="stretch"></div>
<pl-button
class="skinny transparent half-margined"
@ -200,17 +239,25 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
</pl-button>
</section>
<section class="box" ?hidden=${quota?.groups === 0}>
<section class="box" ?hidden=${quota?.groups === 0 && !org.groups.length}>
<h2
class="uppercase bg-dark border-bottom semibold center-aligning spacing horizontal layout"
>
<div></div>
<pl-icon icon="group" class="left-margined"></pl-icon>
<div>${$l("Groups")}</div>
<div class="tiny bold tag">${org.groups.length}</div>
<div
class="${quota.groups !== -1 && org.groups.length > quota.groups
? "negative highlight"
: "subtle"}"
>
<strong>${org.groups.length}</strong>${quota.groups !== -1
? ` / ${quota.groups}`
: ""}
</div>
<div class="stretch"></div>
<pl-button
class="skinny transparent half-margined"
@click=${() => this.go(`orgs/${this.orgId}/vaults/new`)}
@click=${() => this.go(`orgs/${this.orgId}/groups/new`)}
>
<pl-icon icon="add"></pl-icon>
</pl-button>
@ -261,9 +308,17 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
<h2
class="uppercase bg-dark border-bottom semibold center-aligning spacing horizontal layout"
>
<div></div>
<pl-icon icon="vaults" class="left-margined"></pl-icon>
<div>${$l("Vaults")}</div>
<div class="tiny bold tag">${org.vaults.length}</div>
<div
class="${quota.vaults !== -1 && org.vaults.length > quota.vaults
? "negative highlight"
: "subtle"}"
>
<strong>${org.vaults.length}</strong>${quota.vaults !== -1
? ` / ${quota.vaults}`
: ""}
</div>
<div class="stretch"></div>
<pl-button
class="skinny transparent half-margined"

View File

@ -11,6 +11,7 @@ import "./list";
import "./org-nav";
import { customElement, property } from "lit/decorators.js";
import { html, LitElement } from "lit";
import { checkFeatureDisabled } from "../lib/provisioning";
@customElement("pl-org-groups")
export class OrgGroupsView extends Routing(StateMixin(LitElement)) {
@ -29,6 +30,14 @@ export class OrgGroupsView extends Routing(StateMixin(LitElement)) {
handleRoute([orgId, groupName]: [string, string]) {
this.orgId = orgId;
this.groupName = groupName && decodeURIComponent(groupName);
if (
this._org &&
this.groupName === "new" &&
checkFeatureDisabled(app.getOrgFeatures(this._org).addGroup, this._org.isOwner(app.account!))
) {
this.redirect(`orgs/${orgId}/groups`);
}
}
private async _createGroup() {

View File

@ -10,6 +10,7 @@ import "./list";
import "./org-nav";
import { customElement, property } from "lit/decorators.js";
import { html, LitElement } from "lit";
import { checkFeatureDisabled } from "../lib/provisioning";
@customElement("pl-org-vaults")
export class OrgVaultsView extends Routing(StateMixin(LitElement)) {
@ -28,6 +29,14 @@ export class OrgVaultsView extends Routing(StateMixin(LitElement)) {
handleRoute([orgId, vaultId]: [string, string]) {
this.orgId = orgId;
this.vaultId = vaultId;
if (
this._org &&
this.vaultId === "new" &&
checkFeatureDisabled(app.getOrgFeatures(this._org).addVault, this._org.isOwner(app.account!))
) {
this.redirect(`orgs/${orgId}/vaults`);
}
}
private async _toggleVault(vault: { id: VaultID }) {

View File

@ -1,15 +1,19 @@
import { openExternalUrl } from "@padloc/core/src/platform";
import { css, LitElement } from "lit";
import { sanitize } from "dompurify";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { mardownToHtml } from "../lib/markdown";
import { mixins, shared } from "../styles";
import { icons } from "../styles/icons";
@customElement("pl-markdown-content")
export class MarkdownContent extends LitElement {
@customElement("pl-rich-content")
export class RichContent extends LitElement {
@property()
content = "";
type: "plain" | "markdown" | "html" = "markdown";
@property({ type: Boolean })
sanitize = true;
@ -64,6 +68,11 @@ export class MarkdownContent extends LitElement {
box-shadow: var(--button-shadow);
}
button.primary {
background: var(--button-primary-background, var(--button-background));
color: var(--button-primary-color, var(--button-color));
}
a.plain {
text-decoration: none !important;
}
@ -77,12 +86,27 @@ export class MarkdownContent extends LitElement {
for (const anchor of [...this.renderRoot.querySelectorAll("a[href]")] as HTMLAnchorElement[]) {
anchor.addEventListener("click", (e) => {
e.preventDefault();
openExternalUrl(anchor.href);
if (anchor.getAttribute("href")?.startsWith("#")) {
const el = this.renderRoot.querySelector(anchor.getAttribute("href")!);
el?.scrollIntoView();
} else {
openExternalUrl(anchor.href);
}
});
}
}
render() {
return mardownToHtml(this.content, this.sanitize);
switch (this.type) {
case "markdown":
return mardownToHtml(this.content, this.sanitize);
case "html":
const content = this.sanitize
? sanitize(this.content, { ADD_TAGS: ["pl-icon"], ADD_ATTR: ["icon"] })
: this.content;
return html`${unsafeHTML(content)}`;
default:
return html`${this.content}`;
}
}
}

View File

@ -67,6 +67,7 @@ export class Scroller extends LitElement {
overflow: auto;
padding: var(--scroller-inner-padding);
scrollbar-width: thin;
scroll-behavior: smooth;
}
${mixins.customScrollbar(".content")}

View File

@ -72,12 +72,28 @@ export class SettingsAccount extends Routing(StateMixin(LitElement)) {
return;
}
const ownedOrgs = app.orgs.filter((org) => org.isOwner(app.account!));
const deleted = await prompt(
$l(
"Are you sure you want to delete this account? " +
"All associated vaults and the data within them will be lost and any active subscriptions will be canceled immediately. " +
"This action can not be undone!"
),
html`
<div>
${$l(
"Are you sure you want to delete this account? " +
"All associated vaults and the data within them will be lost and any active subscriptions will be canceled immediately. " +
"This action can not be undone!"
)}
</div>
${ownedOrgs.length
? html`
<div class="padded top-margined negative highlighted box">
<strong>WARNING:</strong> ${$l(
"The following organizations are owned by you and will be deleted along with your account:"
)}
<strong>${ownedOrgs.map((org) => org.name).join(", ")}</strong>
</div>
`
: ""}
`,
{
type: "destructive",
title: $l("Delete Account"),

View File

@ -5,7 +5,7 @@ import { router } from "../globals";
import { translate as $l } from "@padloc/locale/src/translate";
import { customElement } from "lit/decorators.js";
import { shared } from "../styles";
import "./markdown-content";
import "./rich-content";
import { ProvisioningStatus } from "@padloc/core/src/provisioning";
@customElement("pl-settings-billing")
@ -37,20 +37,13 @@ export class SettingsBilling extends StateMixin(LitElement) {
</header>
<pl-scroller class="stretch">
<div class="double-padded centering vertical layout">
<div class="spacing vertical layout" style="width: 100%; max-width: 30em;">
<pl-markdown-content
.content=${provisioning.statusMessage}
<div class="padded">
<div class="spacing vertical layout">
<pl-rich-content
.content=${provisioning.billingPage?.content || provisioning.statusMessage}
.type=${provisioning.billingPage?.type || "markdown"}
class="padded"
></pl-markdown-content>
${provisioning.actionUrl
? html`
<pl-button @click=${() => window.open(provisioning.actionUrl, "_blank")}
>${provisioning.actionLabel || $l("Learn More")}</pl-button
>
`
: ""}
></pl-rich-content>
</div>
</div>
</pl-scroller>

View File

@ -29,7 +29,7 @@ import { Button } from "./button";
import { SessionInfo } from "@padloc/core/src/session";
import { KeyStoreEntryInfo } from "@padloc/core/src/key-store";
import { Toggle } from "./toggle";
import { Feature } from "@padloc/core/src/provisioning";
import { alertDisabledFeature } from "../lib/provisioning";
@customElement("pl-settings-security")
export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
@ -153,6 +153,12 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
}
private async _addAuthenticator() {
const feature = app.getAccountFeatures().manageAuthenticators;
if (feature.disabled) {
await alertDisabledFeature(feature);
return;
}
const choices: {
type?: AuthType;
label: TemplateResult | string;
@ -333,7 +339,7 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
static styles = [shared];
private _renderMFA() {
if (app.isFeatureDisabled(Feature.MultiFactorAuthentication)) {
if (app.getAccountFeatures().manageAuthenticators.hidden) {
return;
}
@ -402,7 +408,7 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
<pl-button
class="transparent"
style="display: flex; --button-padding: 0 0.3em;"
?disabled=${i === 0}
?hidden=${i === 0}
@click=${() => this._moveAuthenticator(a, "up")}
>
<pl-icon icon="dropup"></pl-icon>
@ -431,7 +437,7 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
}
private _renderSessions() {
if (!app.authInfo || !app.session || app.isFeatureDisabled(Feature.SessionManagement)) {
if (!app.authInfo || !app.session || app.getAccountFeatures().manageSessions.hidden) {
return;
}
const { sessions } = app.authInfo;
@ -489,7 +495,7 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
}
private _renderTrustedDevices() {
if (!app.authInfo || app.isFeatureDisabled(Feature.TrustedDeviceManagement)) {
if (!app.authInfo || app.getAccountFeatures().manageDevices.hidden) {
return;
}
const { trustedDevices, sessions } = app.authInfo;
@ -601,7 +607,7 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
}
private _renderBiometricUnlock() {
if (!app.authInfo || app.isFeatureDisabled(Feature.BiometricUnlock)) {
if (!app.authInfo || app.getAccountFeatures().quickUnlock.hidden) {
return;
}
const { keyStoreEntries, authenticators } = app.authInfo;

View File

@ -18,6 +18,7 @@ import "./settings-account";
import "./settings-display";
import "./settings-billing";
import "./settings-extension";
import { ProvisioningStatus } from "@padloc/core/src/provisioning";
@customElement("pl-settings")
export class Settings extends StateMixin(Routing(View)) {
@ -62,6 +63,10 @@ export class Settings extends StateMixin(Routing(View)) {
.selectable-list > :not(:last-child) {
border-bottom: solid 1px var(--border-color);
}
.pane {
--pane-left-width: var(--menu-width);
}
`,
];
@ -111,16 +116,6 @@ export class Settings extends StateMixin(Routing(View)) {
<pl-icon icon="display"></pl-icon>
<div class="stretch ellipsis">${$l("Display")}</div>
</div>
<div
role="link"
class="double-padded center-aligning spacing horizontal layout list-item click hover"
aria-selected=${this._page === "billing"}
@click=${() => this.go("settings/billing")}
hidden
>
<pl-icon icon="billing"></pl-icon>
<div class="stretch ellipsis">${$l("Billing")}</div>
</div>
<div
role="link"
class="double-padded center-aligning spacing horizontal layout list-item click hover"
@ -135,9 +130,13 @@ export class Settings extends StateMixin(Routing(View)) {
class="double-padded center-aligning spacing horizontal layout list-item click hover"
aria-selected=${this._page === "billing"}
@click=${() => this.go("settings/billing")}
?hidden=${!app.getAccountProvisioning().billingPage}
>
<pl-icon icon="billing"></pl-icon>
<div class="stretch ellipsis">${$l("Billing")}</div>
${app.getAccountProvisioning().status !== ProvisioningStatus.Active
? html` <pl-icon icon="warning" class="negative highlighted"></pl-icon> `
: ""}
</div>
<div
role="link"

View File

@ -4,7 +4,7 @@ import { View } from "./view";
import { customElement } from "lit/decorators.js";
import { css, html } from "lit";
import { Routing } from "../mixins/routing";
import "./markdown-content";
import "./rich-content";
import content from "assets/support.md";
@customElement("pl-support")
@ -14,7 +14,7 @@ export class Support extends StateMixin(Routing(View)) {
static styles = [
...View.styles,
css`
pl-markdown-content {
pl-rich-content {
display: block;
width: 100%;
max-width: 25em;
@ -40,7 +40,7 @@ export class Support extends StateMixin(Routing(View)) {
</pl-button>
</header>
<pl-scroller class="stretch">
<pl-markdown-content .content=${content}></pl-markdown-content>
<pl-rich-content .content=${content}></pl-rich-content>
</pl-scroller>
</div>
`;

View File

@ -70,6 +70,7 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
this.clearChanges();
if (vaultId === "new") {
this._nameInput.focus();
this._addMember(this._org?.getMember(app.account!)!);
}
}
@ -139,13 +140,20 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
this._nameInput && (this._nameInput.value = (this._vault && this._vault.name) || "");
}
private _cancel() {
this.clearChanges();
if (this.vaultId === "new") {
this.go(`orgs/${this.orgId}/vaults`);
}
}
private _addMember({ id, name }: OrgMember) {
this._members.push({ id, name, readonly: true });
this._members.push({ id, name, readonly: false });
this.requestUpdate();
}
private _addGroup(group: { name: string }) {
this._groups.push({ name: group.name, readonly: true });
this._groups.push({ name: group.name, readonly: false });
this.requestUpdate();
}
@ -174,6 +182,11 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
return;
}
if (!this._groups.length && !this._members.length) {
await alert($l("Please assign at least on member or group to this vault!"));
return;
}
this._saveButton.start();
try {
@ -296,7 +309,7 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
</header>
<pl-scroller class="stretch">
<section class="double-margined box">
<section class="double-margined box" ?hidden=${!org.groups.length}>
<h2 class="center-aligning horizontal layout bg-dark border-bottom">
<div class="padded uppercase stretch semibold">${$l("Groups")}</div>
@ -325,7 +338,7 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
`
: html`
<div class="double-padded small subtle text-centering">
${$l("No more Vautls available")}
${$l("No more Groups available")}
</div>
`}
</pl-popover>
@ -448,10 +461,20 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
</section>
</pl-scroller>
<div class="padded horizontal spacing evenly stretching layout" ?hidden=${!this.hasChanges}>
<pl-button class="primary" id="saveButton" @click=${this._save}> ${$l("Save")} </pl-button>
<div
class="padded horizontal spacing evenly stretching layout"
?hidden=${this.vaultId !== "new" && !this.hasChanges}
>
<pl-button
class="primary"
id="saveButton"
@click=${this._save}
?disabled=${!this._nameInput?.value || (!this._members.length && !this._groups.length)}
>
${$l("Save")}
</pl-button>
<pl-button @click=${this.clearChanges}> ${$l("Cancel")} </pl-button>
<pl-button @click=${this._cancel}> ${$l("Cancel")} </pl-button>
</div>
</div>
`;

View File

@ -2,50 +2,105 @@ import {
OrgProvisioning,
ProvisioningStatus,
AccountProvisioning,
VaultProvisioning,
Feature,
OrgFeature,
RichContent,
} from "@padloc/core/src/provisioning";
import { $l } from "@padloc/locale/src/translate";
import { html } from "lit";
import { alert } from "./dialog";
import "../elements/markdown-content";
import { app, router } from "../globals";
import "../elements/rich-content";
import { openExternalUrl } from "@padloc/core/src/platform";
export function checkFeatureDisabled(feature: Feature): boolean;
export function checkFeatureDisabled(feature: OrgFeature, isOwner: boolean): boolean;
export function checkFeatureDisabled(feature: Feature | OrgFeature, isOwner?: boolean) {
if (feature.disabled) {
alertDisabledFeature(feature, isOwner as boolean);
}
return feature.disabled;
}
async function alertMessage(message: string | RichContent, action?: { label: string; url: string }, title?: string) {
if (typeof message === "string") {
message = {
type: "plain",
content: message,
} as RichContent;
}
const choice = await alert(
html`<pl-rich-content .content=${message.content} .type=${message.type}></pl-rich-content>`,
{
icon: null,
width: "auto",
maxWidth: message?.type === "html" ? "100%" : "",
options: action ? [action.label!, $l("Dismiss")] : [$l("Dismiss")],
type: !action ? "choice" : "info",
title,
hideOnDocumentVisibilityChange: true,
}
);
if (action && choice === 0) {
openExternalUrl(action.url);
}
}
export function alertDisabledFeature(feature: Feature): Promise<void>;
export function alertDisabledFeature(feature: OrgFeature, isOwner: boolean): Promise<void>;
export async function alertDisabledFeature(feature: Feature | OrgFeature, isOwner?: boolean) {
const message =
feature instanceof OrgFeature && isOwner ? feature.messageOwner || feature.message : feature.message;
alertMessage(
message || "",
feature.actionLabel && feature.actionUrl ? { label: feature.actionLabel, url: feature.actionUrl } : undefined
);
}
export function getDefaultStatusMessage(status: ProvisioningStatus) {
switch (status) {
case ProvisioningStatus.Frozen:
return $l(
"Your account has been frozen, meaning you can still access your existing data, but you won't be able to create new vault items or edit existing ones."
);
case ProvisioningStatus.Suspended:
return $l(
"Your account has been suspended, meaning you can no longer use this service. If you believe your account has been suspended in error, please contact your service administrator or customer support."
);
case ProvisioningStatus.Unprovisioned:
return $l(
"You don't currently have permission to use this service. Please contact the service adminstrator to request access."
);
default:
return "";
}
}
export function getDefaultStatusLabel(status: ProvisioningStatus) {
switch (status) {
case ProvisioningStatus.Active:
return $l("Active");
case ProvisioningStatus.Deleted:
return $l("Account Deleted");
case ProvisioningStatus.Frozen:
return $l("Account Frozen");
case ProvisioningStatus.Suspended:
return $l("Account Suspended");
case ProvisioningStatus.Unprovisioned:
return $l("Access Denied");
}
}
export async function displayProvisioning({
status,
statusLabel,
statusMessage,
statusLabel,
actionUrl,
actionLabel,
}: AccountProvisioning | OrgProvisioning | VaultProvisioning) {
const options: { action: () => void; label: string }[] = [];
if (actionUrl) {
options.push({
action: () => openExternalUrl(actionUrl),
label: actionLabel || $l("Learn More"),
});
}
if (!!app.session) {
options.push({
action: async () => {
await app.logout();
router.go("start");
},
label: $l("Log Out"),
});
}
if (![ProvisioningStatus.Unprovisioned, ProvisioningStatus.Suspended].includes(status) || !app.account) {
options.push({ label: $l("Dismiss"), action: () => {} });
}
const choice = await alert(html`<pl-markdown-content .content=${statusMessage}></pl-markdown-content>`, {
icon: null,
options: options.map((o) => o.label),
preventDismiss: true,
title: statusLabel,
});
options[choice]?.action();
}: AccountProvisioning | OrgProvisioning) {
alertMessage(
statusMessage || getDefaultStatusMessage(status),
actionUrl && actionLabel ? { label: actionLabel, url: actionUrl } : undefined,
statusLabel || getDefaultStatusLabel(status)
);
}

View File

@ -8,6 +8,11 @@ export function AutoSync<B extends Constructor<ErrorHandling>>(baseClass: B) {
constructor(...args: any[]) {
super(...args);
app.loaded.then(() => this.startPeriodicSync());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && !app.state.locked) {
app.synchronize();
}
});
}
startPeriodicSync() {

View File

@ -50,6 +50,10 @@ export const base = css`
text-align: right;
}
.micro {
font-size: var(--font-size-micro);
}
.tiny {
font-size: var(--font-size-tiny);
}

View File

@ -275,9 +275,17 @@ export const layout = css`
overflow: auto;
}
.scrolling-vertically {
overflow: hidden auto;
}
.scrolling-horizontally {
overflow: auto hidden;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10em, 1fr));
grid-template-columns: repeat(auto-fill, minmax(var(--grid-column-width, 10em), 1fr));
grid-gap: var(--spacing);
}

View File

@ -9,6 +9,14 @@ export const misc = css`
${mixins.ellipsis()};
}
.nowrap {
white-space: nowrap;
}
.underlined {
text-decoration: underline;
}
pl-icon[spin] {
animation: spin 1s infinite;
transform-origin: center 49%;
@ -96,6 +104,16 @@ export const misc = css`
overflow: hidden;
}
.box.highlighted {
--box-border-color: var(--color-highlight);
--border-color: var(--color-highlight);
}
.box.negative.highlighted {
--box-border-color: var(--color-negative);
--border-color: var(--color-negative);
}
.background,
.bg {
background: var(--color-background);

View File

@ -5,7 +5,7 @@ import { Err, ErrorCode } from "./error";
import { PBES2Container } from "./container";
import { Storable } from "./storage";
import { VaultID } from "./vault";
import { Org, OrgID } from "./org";
import { Org, OrgInfo } from "./org";
import { VaultItemID } from "./item";
/** Unique identifier for [[Account]] objects */
@ -87,12 +87,8 @@ export class Account extends PBES2Container implements Storable {
revision?: string;
} = { id: "" };
/** IDs of all organizations this account is a member of */
orgs: {
id: OrgID;
name?: string;
revision?: string;
}[] = [];
/** All organizations this account is a member of */
orgs: OrgInfo[] = [];
/**
* Revision id used for ensuring continuity when synchronizing the account

View File

@ -3,7 +3,7 @@ import { Storable } from "./storage";
import { Serializable, Serialize, AsDate, AsSerializable, bytesToBase64, stringToBytes, equalBytes } from "./encoding";
import { Invite, InvitePurpose } from "./invite";
import { Vault, VaultID } from "./vault";
import { Org, OrgID, OrgType, OrgMember, OrgRole, Group, UnlockedOrg } from "./org";
import { Org, OrgID, OrgMember, OrgRole, Group, UnlockedOrg, OrgInfo } from "./org";
import { VaultItem, VaultItemID, Field, Tag, createVaultItem } from "./item";
import { Account, AccountID, UnlockedAccount } from "./account";
import { Auth } from "./auth";
@ -32,7 +32,7 @@ import { Err, ErrorCode } from "./error";
import { Attachment, AttachmentInfo } from "./attachment";
import { SimpleContainer } from "./container";
import { AESKeyParams, PBKDF2Params } from "./crypto";
import { Feature, ProvisioningStatus } from "./provisioning";
import { AccountFeatures, AccountProvisioning, OrgFeatures, OrgProvisioning, ProvisioningStatus } from "./provisioning";
/** Various usage stats */
export class Stats extends Serializable {
@ -487,11 +487,17 @@ export class App {
*/
async synchronize() {
this.setState({ syncing: true });
await this.fetchAccount();
await this.fetchAuthInfo();
await this.fetchAccount();
await this.fetchOrgs();
await this.syncVaults();
await this.save();
// const autoCreateOrg = await this.authInfo?.provisioning.orgs.find((org) => org.autoCreate);
// if (autoCreateOrg && !this.orgs.find((org) => org.id === autoCreateOrg.orgId)) {
// await this.createOrg(autoCreateOrg?.orgName || $l("My Org"));
// }
this.setStats({ lastSync: new Date() });
this.publish();
}
@ -963,9 +969,13 @@ export class App {
members: { id: AccountID; readonly: boolean }[] = [],
groups: { name: string; readonly: boolean }[] = []
): Promise<Vault> {
if (!members.length && !groups.length) {
throw new Error("You have to assign at least one member or group!");
}
let vault = new Vault();
vault.name = name;
vault.org = { id: org.id, name: org.name };
vault.org = org.info;
vault = await this.api.createVault(vault);
await this.fetchOrg(org);
@ -1272,7 +1282,7 @@ export class App {
// Update accessors
if (org) {
try {
const provisioning = this.authInfo?.provisioning.orgs.find((p) => p.orgId === org.id);
const provisioning = this.getOrgProvisioning(org);
if (provisioning?.status === ProvisioningStatus.Frozen) {
throw new Err(
ErrorCode.PROVISIONING_NOT_ALLOWED,
@ -1363,9 +1373,8 @@ export class App {
}
isEditable(vault: Vault) {
return (
this.hasWritePermissions(vault) && this.getVaultProvisioning(vault)?.status === ProvisioningStatus.Active
);
const provisioning = vault.org ? this.getOrgProvisioning(vault.org) : this.getAccountProvisioning();
return this.hasWritePermissions(vault) && provisioning?.status === ProvisioningStatus.Active;
}
private async _syncVault(vault: { id: VaultID; revision?: string }): Promise<Vault | null> {
@ -1551,10 +1560,9 @@ export class App {
}
/** Create a new [[Org]]ganization */
async createOrg(name: string, type: OrgType = OrgType.Business): Promise<Org> {
async createOrg(name: string): Promise<Org> {
let org = new Org();
org.name = name;
org.type = type;
org = await this.api.createOrg(org);
await org.initialize(this.account!);
org = await this.api.updateOrg(org);
@ -1580,11 +1588,11 @@ export class App {
async fetchOrg({ id, revision }: { id: OrgID; revision?: string }) {
const existing = this.getOrg(id);
if (existing && existing.revision === revision) {
if (existing && existing.revision === revision && existing.members.length) {
return existing;
}
const org = await this.api.getOrg(id);
let org = await this.api.getOrg(id);
// Verify that the updated organization object has a `minMemberUpdated`
// property equal to or higher than the previous (local) one.
@ -1592,6 +1600,11 @@ export class App {
throw new Err(ErrorCode.VERIFICATION_ERROR, "'minMemberUpdated' property may not decrease!");
}
if (this.account && !this.account.locked && org.owner === this.account.id && !org.members.length) {
await org.initialize(this.account);
org = await this.api.updateOrg(org);
}
this.putOrg(org);
await this.save();
return org;
@ -1912,23 +1925,19 @@ export class App {
*/
getAccountProvisioning() {
return this.authInfo?.provisioning?.account;
return this.authInfo?.provisioning?.account || new AccountProvisioning();
}
getOrgProvisioning({ id }: { id: string }) {
return this.authInfo?.provisioning?.orgs.find((p) => p.orgId === id);
return this.authInfo?.provisioning?.orgs.find((p) => p.orgId === id) || new OrgProvisioning();
}
getVaultProvisioning({ id }: { id: string }) {
return this.authInfo?.provisioning?.vaults.find((v) => v.vaultId === id);
getAccountFeatures() {
return this.getAccountProvisioning()?.features || new AccountFeatures();
}
getItemsQuota(vault: Vault | null = this.mainVault) {
return (vault && this.getVaultProvisioning(vault)?.quota.items) || 0;
}
isFeatureDisabled(feature: Feature) {
return this.getAccountProvisioning()?.disableFeatures.includes(feature);
getOrgFeatures(org: OrgInfo) {
return this.getOrgProvisioning(org)?.features || new OrgFeatures();
}
/**

View File

@ -224,6 +224,7 @@ export class Auth extends Serializable implements Storable {
id: string;
orgId: string;
orgName: string;
expires: string;
}[] = [];
constructor(public email: string = "") {

View File

@ -1,188 +0,0 @@
import { AccountID } from "./account";
import { OrgID } from "./org";
import { Serializable, AsSerializable, AsDate } from "./encoding";
export enum PlanType {
Free,
Premium,
Family,
Team,
Business,
}
export class Plan extends Serializable {
id = "";
type: PlanType = PlanType.Free;
name = "Free";
description = "";
items: number = -1;
storage: number = 0;
groups: number = 0;
vaults: number = 0;
min: number = 0;
max: number = 0;
available = false;
cost: number = 0;
features: string[] = [];
default = false;
color = "";
}
export class PaymentMethod extends Serializable {
id = "";
name = "";
}
export enum SubscriptionStatus {
Trialing = "trialing",
Active = "active",
Inactive = "inactive",
Canceled = "canceled",
}
export class Subscription extends Serializable {
id = "";
account: AccountID = "";
org: OrgID = "";
status: SubscriptionStatus = SubscriptionStatus.Active;
items: number = -1;
storage: number = 0;
groups: number = 0;
vaults: number = 0;
members: number = 0;
paymentError?: string = undefined;
paymentRequiresAuth?: string = undefined;
currentInvoice: string = "";
@AsDate()
periodEnd: Date = new Date(0);
@AsDate()
trialEnd?: Date;
@AsSerializable(Plan)
plan: Plan = new Plan();
}
export class BillingAddress extends Serializable {
name = "";
street = "";
postalCode = "";
city = "";
country = "";
constructor(params?: Partial<BillingAddress>) {
super();
if (params) {
Object.assign(this, params);
}
}
}
export class Discount extends Serializable {
name = "";
coupon = "";
}
export class BillingInfo extends Serializable {
customerId: string = "";
account: AccountID = "";
org: OrgID = "";
email: string = "";
@AsSerializable(Subscription)
subscription: Subscription | null = null;
@AsSerializable(PaymentMethod)
paymentMethod: PaymentMethod | null = null;
@AsSerializable(BillingAddress)
address: BillingAddress = new BillingAddress();
@AsSerializable(Discount)
discount: Discount | null = null;
@AsDate()
firstTrialStarted?: Date;
get trialDaysLeft() {
const daysSinceFirstTrial = this.firstTrialStarted
? Math.floor((Date.now() - this.firstTrialStarted.getTime()) / 24 / 60 / 60 / 1000)
: 0;
return Math.max(0, 30 - daysSinceFirstTrial);
}
}
export class UpdateBillingParams extends Serializable {
provider: string = "";
account?: AccountID = undefined;
org?: OrgID = undefined;
email?: string = undefined;
plan?: string = undefined;
planType?: PlanType = undefined;
members?: number = undefined;
paymentMethod?: { name: string } & any = undefined;
coupon?: string = undefined;
cancel?: boolean = undefined;
@AsSerializable(BillingAddress)
address?: BillingAddress;
constructor(params?: Partial<UpdateBillingParams>) {
super();
if (params) {
Object.assign(this, params);
}
}
}
export class BillingProviderInfo extends Serializable {
type: string = "";
config: {
[param: string]: string;
} = {};
@AsSerializable(Plan)
plans: Plan[] = [];
}
export interface BillingProvider {
update(params: UpdateBillingParams): Promise<void>;
delete(billingInfo: BillingInfo): Promise<void>;
getInfo(): BillingProviderInfo;
}
//
// const stubPlans: Plan[] = [
// {
// id: "personal",
// name: "Personal",
// description: "Basic Setup For Personal Use",
// storage: -1,
// available: true,
// default: true
// },
// {
// id: "shared",
// name: "Shared",
// description: "Basic Setup For Sharing",
// storage: -1,
// vaults: -1,
// available: true,
// orgType: 1
// },
// {
// id: "advanced",
// name: "Advanced",
// description: "Advanced Setup For Sharing",
// storage: -1,
// groups: -1,
// vaults: -1,
// available: true,
// orgType: 2
// }
// ].map(p => {
// const plan = new Plan();
// Object.assign(plan, p);
// return plan;
// });

View File

@ -71,6 +71,44 @@ export class Config extends Serializable {
return this;
}
toEnv(prefix = "PL_", includeUndefined = false) {
const vars: { [prop: string]: string } = {};
for (const { prop, type } of this._paramDefinitions || []) {
// type is another config object
if (typeof type === "function") {
const newPrefix = `${prefix}${prop.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase()}_`;
if (!this[prop] && !includeUndefined) {
continue;
}
const subVars =
this[prop]?.toEnv(newPrefix, includeUndefined) || new type().toEnv(newPrefix, includeUndefined);
Object.assign(vars, subVars);
continue;
}
const varName = `${prefix}${prop.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase()}`;
const val = this[prop];
if (typeof val === "undefined" && !includeUndefined) {
continue;
}
switch (type) {
case "string[]":
vars[varName] = val?.join(",") || "";
break;
default:
vars[varName] = val?.toString() || "";
}
}
return vars;
}
toRaw(version?: string) {
const raw = super.toRaw(version);
for (const { prop, secret } of this._paramDefinitions) {

View File

@ -103,12 +103,6 @@ export class Group extends Serializable {
/** Unique identifier for [[Org]]s */
export type OrgID = string;
export enum OrgType {
Basic,
Team,
Business,
}
export class OrgSecrets extends Serializable {
constructor({ invitesKey, privateKey }: Partial<OrgSecrets> = {}) {
super();
@ -122,6 +116,13 @@ export class OrgSecrets extends Serializable {
privateKey!: Uint8Array;
}
export interface OrgInfo {
id: OrgID;
name: string;
owner: AccountID;
revision: string;
}
/**
* Organizations are the central component of Padlocs secure data sharing architecture.
*
@ -168,8 +169,6 @@ export class Org extends SharedContainer implements Storable {
/** Unique identier */
id: OrgID = "";
type: OrgType = OrgType.Basic;
/** [[Account]] which created this organization */
owner: AccountID = "";
@ -245,6 +244,15 @@ export class Org extends SharedContainer implements Storable {
*/
revision: string = "";
get info(): OrgInfo {
return {
id: this.id,
name: this.name,
owner: this.owner,
revision: this.revision,
};
}
/** Whether the given [[Account]] is an [[OrgRole.Owner]] */
isOwner({ id }: { id: AccountID }) {
return this.owner === id;

View File

@ -1,14 +1,9 @@
import { AccountID } from "./account";
import { Account, AccountID } from "./account";
import { AsSerializable, Serializable } from "./encoding";
import { ErrorCode } from "./error";
import { OrgID } from "./org";
import { VaultID } from "./vault";
export enum Feature {
MultiFactorAuthentication = "multi_factor_authentication",
BiometricUnlock = "biometric_unlock",
SessionManagement = "session_management",
TrustedDeviceManagement = "trusted_device_management",
}
import { Storable, Storage } from "./storage";
import { getIdFromEmail } from "./util";
export enum ProvisioningStatus {
Unprovisioned = "unprovisioned",
@ -18,16 +13,6 @@ export enum ProvisioningStatus {
Deleted = "deleted",
}
export class VaultQuota extends Serializable {
constructor(vals: Partial<VaultQuota> = {}) {
super();
Object.assign(this, vals);
}
items = -1;
storage = 1000;
}
export class OrgQuota extends Serializable {
constructor(vals: Partial<OrgQuota> = {}) {
super();
@ -37,6 +22,7 @@ export class OrgQuota extends Serializable {
members = 50;
groups = 10;
vaults = 10;
storage = 1000;
}
export class AccountQuota extends Serializable {
@ -46,23 +32,86 @@ export class AccountQuota extends Serializable {
}
vaults = 1;
orgs = 3;
storage = 0;
}
// @AsSerializable(VaultQuota)
// vaults = new VaultQuota();
export type RichContent = {
type: "plain" | "markdown" | "html";
content: string;
};
// @AsSerializable(OrgQuota)
// orgs = new OrgQuota();
export class Feature extends Serializable {
constructor(vals: Partial<Feature> = {}) {
super();
Object.assign(this, vals);
}
// }
disabled: boolean = false;
hidden: boolean = false;
message?: RichContent = undefined;
actionUrl?: string = undefined;
actionLabel?: string = undefined;
}
export class AccountProvisioning extends Serializable {
export class OrgFeature extends Feature {
messageOwner?: RichContent = undefined;
}
export class AccountFeatures extends Serializable {
constructor(vals: Partial<AccountFeatures> = {}) {
super();
Object.assign(this, vals);
}
@AsSerializable(Feature)
createOrg: Feature = new Feature();
@AsSerializable(Feature)
quickUnlock: Feature = new Feature();
@AsSerializable(Feature)
manageAuthenticators: Feature = new Feature();
@AsSerializable(Feature)
manageSessions: Feature = new Feature();
@AsSerializable(Feature)
manageDevices: Feature = new Feature();
@AsSerializable(Feature)
attachments: Feature = new Feature();
@AsSerializable(Feature)
billing: Feature = new Feature();
}
export class OrgFeatures extends Serializable {
constructor(vals: Partial<OrgFeatures> = {}) {
super();
Object.assign(this, vals);
}
@AsSerializable(OrgFeature)
addMember: OrgFeature = new OrgFeature();
@AsSerializable(OrgFeature)
addGroup: OrgFeature = new OrgFeature();
@AsSerializable(OrgFeature)
addVault: OrgFeature = new OrgFeature();
@AsSerializable(OrgFeature)
attachments: OrgFeature = new OrgFeature();
}
export class AccountProvisioning extends Storable {
constructor(vals: Partial<AccountProvisioning> = {}) {
super();
Object.assign(this, vals);
}
id: string = "";
email: string = "";
accountId?: AccountID = undefined;
@ -71,28 +120,41 @@ export class AccountProvisioning extends Serializable {
statusLabel: string = "";
statusMessage: string = "";
statusMessage: RichContent | string = "";
actionUrl?: string = undefined;
actionLabel?: string = undefined;
metaData?: { [prop: string]: string } = undefined;
metaData?: any = undefined;
billingPage?: RichContent = undefined;
@AsSerializable(AccountQuota)
quota: AccountQuota = new AccountQuota();
disableFeatures: Feature[] = [];
@AsSerializable(AccountFeatures)
features: AccountFeatures = new AccountFeatures();
orgs: OrgID[] = [];
}
export class OrgProvisioning extends Serializable {
export class OrgProvisioning extends Storable {
constructor(vals: Partial<OrgProvisioning> = {}) {
super();
Object.assign(this, vals);
}
get id() {
return this.orgId;
}
orgId: OrgID = "";
orgName: string = "";
owner: AccountID = "";
status: ProvisioningStatus = ProvisioningStatus.Active;
statusLabel: string = "";
@ -103,30 +165,15 @@ export class OrgProvisioning extends Serializable {
actionLabel?: string = undefined;
metaData?: any = undefined;
autoCreate: boolean = false;
@AsSerializable(OrgQuota)
quota: OrgQuota = new OrgQuota();
}
export class VaultProvisioning extends Serializable {
constructor(vals: Partial<VaultProvisioning> = {}) {
super();
Object.assign(this, vals);
}
vaultId: VaultID = "";
status: ProvisioningStatus = ProvisioningStatus.Active;
statusLabel: string = "";
statusMessage: string = "";
actionUrl?: string = undefined;
actionLabel?: string = undefined;
@AsSerializable(VaultQuota)
quota: VaultQuota = new VaultQuota();
@AsSerializable(OrgFeatures)
features: OrgFeatures = new OrgFeatures();
}
export class Provisioning extends Serializable {
@ -136,19 +183,16 @@ export class Provisioning extends Serializable {
}
@AsSerializable(AccountProvisioning)
account!: AccountProvisioning;
account: AccountProvisioning = new AccountProvisioning();
@AsSerializable(OrgProvisioning)
orgs: OrgProvisioning[] = [];
@AsSerializable(VaultProvisioning)
vaults: VaultProvisioning[] = [];
}
export interface Provisioner {
getProvisioning(params: { email: string; accountId?: AccountID }): Promise<Provisioning>;
accountDeleted(params: { email: string; accountId?: AccountID }): Promise<void>;
orgDeleted(params: { id: OrgID }): Promise<void>;
}
export class StubProvisioner implements Provisioner {
@ -157,4 +201,80 @@ export class StubProvisioner implements Provisioner {
}
async accountDeleted(_params: { email: string; accountId?: string }) {}
async orgDeleted(_params: { id: OrgID }) {}
}
export class BasicProvisioner implements Provisioner {
constructor(public readonly storage: Storage) {}
async getProvisioning({
email,
accountId,
}: {
email: string;
accountId?: string | undefined;
}): Promise<Provisioning> {
const id = await getIdFromEmail(email);
const provisioning = new Provisioning();
provisioning.account = await this.storage
.get(AccountProvisioning, id)
.catch(() => new AccountProvisioning({ id, email, accountId }));
if (!provisioning.account.accountId && accountId) {
provisioning.account.accountId = accountId;
await this.storage.save(provisioning.account);
}
const account =
provisioning.account.accountId &&
(await this.storage.get(Account, provisioning.account.accountId).catch(() => null));
const orgIds = account
? [...new Set([...provisioning.account.orgs, ...account.orgs.map((org) => org.id)])]
: provisioning.account.orgs;
provisioning.orgs = await Promise.all(
orgIds.map((id) =>
this.storage
.get(OrgProvisioning, id)
.catch(() => new OrgProvisioning({ orgId: id }))
.then((prov) => {
// Delete messages meant for owner if this org is not owned by this user
if (prov.owner !== provisioning.account.accountId) {
for (const feature of Object.values(prov.features)) {
delete feature.messageOwner;
}
}
return prov;
})
)
);
return provisioning;
}
async accountDeleted({ email }: { email: string; accountId?: string | undefined }): Promise<void> {
const id = await getIdFromEmail(email);
const prov = await this.storage.get(AccountProvisioning, id);
for (const orgId of prov.orgs) {
await this.storage.delete(new OrgProvisioning({ orgId }));
}
await this.storage.delete(prov);
}
async orgDeleted({ id }: { id: OrgID }): Promise<void> {
try {
const orgProv = await this.storage.get(OrgProvisioning, id);
await this.storage.delete(new OrgProvisioning({ orgId: id }));
const accountProv = await this.storage.get(AccountProvisioning, orgProv.owner);
accountProv.orgs = accountProv.orgs.filter((id) => id !== orgProv.id);
await this.storage.save(accountProv);
console.log("org deleted", orgProv, accountProv);
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
}
}
}

View File

@ -699,8 +699,24 @@ export class Controller extends API {
}
async getAuthInfo() {
const { auth, provisioning } = this._requireAuth();
const { auth, account, provisioning } = this._requireAuth();
this.log("account.getAuthInfo");
for (const { autoCreate, orgId, orgName } of provisioning.orgs) {
if (autoCreate && !account.orgs.some((org) => org.id === orgId)) {
const org = new Org();
org.name = orgName;
org.id = orgId;
org.revision = await uuid();
org.owner = account.id;
org.created = new Date();
org.updated = new Date();
await this.storage.save(org);
account.orgs.push(org.info);
await this.storage.save(account);
}
}
return new AuthInfo({
trustedDevices: auth.trustedDevices,
authenticators: auth.authenticators,
@ -820,17 +836,14 @@ export class Controller extends API {
// Make sure that the account is not owner of any organizations
const orgs = await Promise.all(account.orgs.map(({ id }) => this.storage.get(Org, id)));
if (orgs.some((org) => org.isOwner(account))) {
throw new Err(
ErrorCode.BAD_REQUEST,
"This account is the owner of one or more organizations and cannot " +
"be deleted. Please delete all your owned organizations first!"
);
}
for (const org of orgs) {
org.removeMember(account);
await this.storage.save(org);
if (org.isOwner(account)) {
await this.deleteOrg(org.id);
} else {
org.removeMember(account);
await this.storage.save(org);
}
}
await this.provisioner.accountDeleted(auth);
@ -857,23 +870,16 @@ export class Controller extends API {
throw new Err(ErrorCode.BAD_REQUEST, "Please provide an organization name!");
}
const existingOrgs = await Promise.all(account.orgs.map(({ id }) => this.storage.get(Org, id)));
const ownedOrgs = existingOrgs.filter((o) => o.owner === account.id);
if (provisioning.account.status !== ProvisioningStatus.Active) {
if (
provisioning.account.status !== ProvisioningStatus.Active ||
provisioning.account.features.createOrg.disabled
) {
throw new Err(
ErrorCode.PROVISIONING_NOT_ALLOWED,
"You're not allowed to create an organization right now."
);
}
if (provisioning.account.quota.orgs !== -1 && ownedOrgs.length >= provisioning.account.quota.orgs) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the maximum number of organizations for this account!"
);
}
org.id = await uuid();
org.revision = await uuid();
org.owner = account.id;
@ -882,7 +888,7 @@ export class Controller extends API {
await this.storage.save(org);
account.orgs.push({ id: org.id, name: org.name });
account.orgs.push(org.info);
await this.storage.save(account);
this.log("org.create", { org: { name: org.name, id: org.id, owner: org.owner } });
@ -926,7 +932,7 @@ export class Controller extends API {
// Get existing org based on the id
const org = await this.storage.get(Org, id);
const orgInfo = { owner: org.owner, name: org.name, id: org.id, type: org.type };
const orgInfo = org.info;
const orgProvisioning = provisioning.orgs.find((o) => o.orgId === id);
@ -934,13 +940,6 @@ export class Controller extends API {
throw new Err(ErrorCode.PROVISIONING_NOT_ALLOWED, "Could not find provisioning for this organization!");
}
if (orgProvisioning.status === ProvisioningStatus.Frozen) {
throw new Err(
ErrorCode.PROVISIONING_NOT_ALLOWED,
'You can not make any updates to an organization while it is in "frozen" state!'
);
}
// Check the revision id to make sure the changes are based on the most
// recent version stored on the server. This is to ensure continuity in
// case two clients try to make changes to an organization at the same
@ -969,6 +968,7 @@ export class Controller extends API {
const removedMembers = org.members.filter(({ id }) => !members.some((m) => id === m.id));
const addedInvites = invites.filter(({ id }) => !org.getInvite(id));
const removedInvites = org.invites.filter(({ id }) => !invites.some((inv) => id === inv.id));
const addedGroups = groups.filter((group) => !org.getGroup(group.name));
// Only org owners can add or remove members, change roles or create invites
if (
@ -989,7 +989,11 @@ export class Controller extends API {
}
// Check members quota
if (orgProvisioning.quota.members !== -1 && members.length > orgProvisioning.quota.members) {
if (
addedMembers.length &&
orgProvisioning.quota.members !== -1 &&
members.length > orgProvisioning.quota.members
) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the maximum number of members for this organization!"
@ -997,7 +1001,7 @@ export class Controller extends API {
}
// Check groups quota
if (orgProvisioning.quota.groups !== -1 && groups.length > orgProvisioning.quota.groups) {
if (addedGroups.length && orgProvisioning.quota.groups !== -1 && groups.length > orgProvisioning.quota.groups) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the maximum number of groups for this organization!"
@ -1032,7 +1036,12 @@ export class Controller extends API {
promises.push(
(async () => {
const auth = await this._getAuth(invite.email);
auth.invites.push({ id: invite.id, orgId: org.id, orgName: org.name });
auth.invites.push({
id: invite.id,
orgId: org.id,
orgName: org.name,
expires: invite.expires.toISOString(),
});
let path = "";
const params = new URLSearchParams();
@ -1184,6 +1193,8 @@ export class Controller extends API {
})
);
await this.provisioner.orgDeleted(org);
await this.storage.delete(org);
this.log("org.delete", { org: { name: org.name, id: org.id, owner: org.owner } });
@ -1470,22 +1481,32 @@ export class Controller extends API {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
if (vault.org) {
const prov = provisioning.orgs.find((o) => o.orgId === vault.org!.id);
const quota = prov?.quota.storage || 0;
const org = await this.storage.get(Org, vault.org.id);
const usagePerVault = await Promise.all(org.vaults.map((v) => this.attachmentStorage.getUsage(v.id)));
const usage = usagePerVault.reduce((total, each) => total + each, 0);
if (quota !== -1 && usage + att.size > quota * 1e6) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the file storage limit for this org!"
);
}
} else {
const quota = provisioning.account.quota.storage;
const usage = await this.attachmentStorage.getUsage(account.mainVault.id);
if (quota !== -1 && usage + att.size > quota * 1e6) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the file storage limit for this account!"
);
}
}
att.id = await uuid();
const currentUsage = await this.attachmentStorage.getUsage(vault.id);
const vaultProvisioning = provisioning.vaults.find((v) => v.vaultId === vault.id);
if (!vaultProvisioning) {
throw new Err(ErrorCode.PROVISIONING_NOT_ALLOWED, "No provisioning found for this vault!");
}
if (vaultProvisioning.quota.storage !== -1 && currentUsage + att.size > vaultProvisioning.quota.storage * 1e6) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the file storage limit for this vault!"
);
}
await this.attachmentStorage.put(att);
this.log("vault.createAttachment", {
@ -1749,6 +1770,13 @@ export class Controller extends API {
updateAuth = true;
}
// Remove expired invites
const nonExpiredInvites = auth.invites.filter((invite) => new Date(invite.expires || 0) > new Date());
if (nonExpiredInvites.length < auth.invites.length) {
auth.invites = nonExpiredInvites;
updateAuth = true;
}
if (updateAuth) {
await this.storage.save(auth);
}
@ -1916,11 +1944,7 @@ export class Server {
try {
const vault = await this.storage.get(Vault, vaultInfo.id);
vault.name = vaultInfo.name;
vault.org = {
id: org.id,
name: org.name,
revision: org.revision,
};
vault.org = org.info;
await this.storage.save(vault);
vaultInfo.revision = vault.revision;
@ -1942,10 +1966,7 @@ export class Server {
try {
const acc = await this.storage.get(Account, member.id);
acc.orgs = [
...acc.orgs.filter((o) => o.id !== org.id),
{ id: org.id, name: org.name, revision: org.revision },
];
acc.orgs = [...acc.orgs.filter((o) => o.id !== org.id), org.info];
await this.storage.save(acc);

View File

@ -200,3 +200,16 @@ export function capitalize(string: string) {
.join(" ");
return phrase;
}
export function stripPropertiesRecursive(obj: object, properties: string[]) {
for (const [key, value] of Object.entries(obj)) {
if (properties.includes(key)) {
delete obj[key];
continue;
}
if (typeof value === "object") {
stripPropertiesRecursive(value, properties);
}
}
return obj;
}

View File

@ -2,7 +2,7 @@ import { SharedContainer } from "./container";
import { Storable } from "./storage";
import { VaultItemCollection } from "./collection";
import { AccountID, UnlockedAccount } from "./account";
import { OrgID } from "./org";
import { OrgInfo } from "./org";
import { Exclude, AsDate } from "./encoding";
import { Err } from "./error";
@ -19,7 +19,7 @@ export class Vault extends SharedContainer implements Storable {
id: VaultID = "";
/** The [[Org]] this vault belongs to (if a shared vault) */
org?: { id: OrgID; name: string; revision?: string } = undefined;
org?: OrgInfo = undefined;
/** Vault name */
name = "";

View File

@ -29,7 +29,7 @@
"mongodb": "4.1.0",
"nodemailer": "6.6.1",
"pg": "8.7.1",
"stripe": "8.194.0",
"stripe": "^8.212.0",
"ts-node": "10.1.0",
"typescript": "4.4.3"
},
@ -37,7 +37,7 @@
"@types/chai": "4.2.18",
"@types/mocha": "8.2.2",
"chai": "4.3.4",
"mocha": "^9.2.2",
"mocha": "9.2.2",
"ts-node-dev": "^1.1.8"
},
"engines": {
@ -2581,9 +2581,9 @@
}
},
"node_modules/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true
},
"node_modules/minipass": {
@ -3284,9 +3284,9 @@
}
},
"node_modules/stripe": {
"version": "8.194.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.194.0.tgz",
"integrity": "sha512-iERByJUNA7sdkfQ3fD1jcrAZqPxCtTmL2EUzvHUVLXyoacDrflkq4ux5KFxYhfCIerrOAhquVj17+sBHn96/Kg==",
"version": "8.212.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.212.0.tgz",
"integrity": "sha512-xQ2uPMRAmRyOiMZktw3hY8jZ8LFR9lEQRPEaQ5WcDcn51kMyn46GeikOikxiFTHEN8PeKRdwtpz4yNArAvu/Kg==",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.6.0"
@ -5769,9 +5769,9 @@
}
},
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true
},
"minipass": {
@ -6270,9 +6270,9 @@
"dev": true
},
"stripe": {
"version": "8.194.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.194.0.tgz",
"integrity": "sha512-iERByJUNA7sdkfQ3fD1jcrAZqPxCtTmL2EUzvHUVLXyoacDrflkq4ux5KFxYhfCIerrOAhquVj17+sBHn96/Kg==",
"version": "8.212.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.212.0.tgz",
"integrity": "sha512-xQ2uPMRAmRyOiMZktw3hY8jZ8LFR9lEQRPEaQ5WcDcn51kMyn46GeikOikxiFTHEN8PeKRdwtpz4yNArAvu/Kg==",
"requires": {
"@types/node": ">=8.1.0",
"qs": "^6.6.0"

View File

@ -40,7 +40,7 @@
"mongodb": "4.1.0",
"nodemailer": "6.6.1",
"pg": "8.7.1",
"stripe": "8.194.0",
"stripe": "8.212.0",
"ts-node": "10.1.0",
"typescript": "4.4.3"
},

View File

@ -9,7 +9,6 @@ import { MongoDBStorageConfig } from "./storage/mongodb";
import { AuthType } from "@padloc/core/src/auth";
import { OpenIdConfig } from "./auth/openid";
import { TotpAuthConfig } from "@padloc/core/src/auth/totp";
import { SimpleProvisionerConfig } from "./provisioning/simple";
import { StripeProvisionerConfig } from "./provisioning/stripe";
import { MixpanelConfig } from "./logging/mixpanel";
import { HTTPReceiverConfig } from "./transport/http";
@ -64,7 +63,7 @@ export class AttachmentStorageConfig extends Config {
}
@ConfigParam()
backend: "memory" | "fs" | "s3" = "memory";
backend: "memory" | "fs" | "s3" = "fs";
@ConfigParam(FSAttachmentStorageConfig)
fs?: FSAttachmentStorageConfig;
@ -111,10 +110,7 @@ export class AuthConfig extends Config {
export class ProvisioningConfig extends Config {
@ConfigParam()
backend: "simple" | "stripe" = "simple";
@ConfigParam(SimpleProvisionerConfig)
simple?: SimpleProvisionerConfig;
backend: "basic" | "stripe" = "basic";
@ConfigParam(StripeProvisionerConfig)
stripe?: StripeProvisionerConfig;

View File

@ -12,7 +12,7 @@ import { WebAuthnConfig, WebAuthnServer } from "./auth/webauthn";
import { SMTPSender } from "./email/smtp";
import { MongoDBStorage } from "./storage/mongodb";
import { ConsoleMessenger, ErrorMessage } from "@padloc/core/src/messenger";
import { FSAttachmentStorage } from "./attachments/fs";
import { FSAttachmentStorage, FSAttachmentStorageConfig } from "./attachments/fs";
import {
AttachmentStorageConfig,
DataStorageConfig,
@ -23,7 +23,7 @@ import {
} from "./config";
import { MemoryStorage, VoidStorage } from "@padloc/core/src/storage";
import { MemoryAttachmentStorage } from "@padloc/core/src/attachment";
import { SimpleProvisioner, SimpleProvisionerConfig } from "./provisioning/simple";
import { BasicProvisioner } from "@padloc/core/src/provisioning";
import { OpenIDServer } from "./auth/openid";
import { TotpAuthConfig, TotpAuthServer } from "@padloc/core/src/auth/totp";
import { EmailAuthServer } from "@padloc/core/src/auth/email";
@ -34,6 +34,7 @@ import { MongoDBLogger } from "./logging/mongodb";
import { MixpanelLogger } from "./logging/mixpanel";
import { PostgresStorage } from "./storage/postgres";
import { ErrorCode } from "@padloc/core/src/error";
import { stripPropertiesRecursive } from "@padloc/core/src/util";
const rootDir = resolve(__dirname, "../../..");
const assetsDir = resolve(rootDir, process.env.PL_ASSETS_DIR || "assets");
@ -43,28 +44,31 @@ if (!process.env.PL_APP_NAME) {
process.env.PL_APP_NAME = name;
}
async function initDataStorage({ backend, leveldb, mongodb, postgres }: DataStorageConfig) {
switch (backend) {
async function initDataStorage(config: DataStorageConfig) {
switch (config.backend) {
case "leveldb":
return new LevelDBStorage(leveldb || new LevelDBStorageConfig());
if (!config.leveldb) {
config.leveldb = new LevelDBStorageConfig();
}
return new LevelDBStorage(config.leveldb);
case "mongodb":
if (!mongodb) {
if (!config.mongodb) {
throw "PL_DATA_STORAGE_BACKEND was set to 'mongodb', but no related configuration was found!";
}
const storage = new MongoDBStorage(mongodb);
const storage = new MongoDBStorage(config.mongodb);
await storage.init();
return storage;
case "postgres":
if (!postgres) {
if (!config.postgres) {
throw "PL_DATA_STORAGE_BACKEND was set to 'postgres', but no related configuration was found!";
}
return new PostgresStorage(postgres);
return new PostgresStorage(config.postgres);
case "memory":
return new MemoryStorage();
case "void":
return new VoidStorage();
default:
throw `Invalid value for PL_DATA_STORAGE_BACKEND: ${backend}! Supported values: leveldb, mongodb`;
throw `Invalid value for PL_DATA_STORAGE_BACKEND: ${config.backend}! Supported values: leveldb, mongodb`;
}
}
@ -130,22 +134,22 @@ async function initEmailSender({ backend, smtp }: EmailConfig) {
}
}
async function initAttachmentStorage({ backend, s3, fs }: AttachmentStorageConfig) {
switch (backend) {
async function initAttachmentStorage(config: AttachmentStorageConfig) {
switch (config.backend) {
case "memory":
return new MemoryAttachmentStorage();
case "s3":
if (!s3) {
if (!config.s3) {
throw "PL_ATTACHMENTS_BACKEND was set to 's3', but no related configuration was found!";
}
return new S3AttachmentStorage(s3!);
return new S3AttachmentStorage(config.s3);
case "fs":
if (!fs) {
throw "PL_ATTACHMENTS_BACKEND was set to 'fs', but no related configuration was found!";
if (!config.fs) {
config.fs = new FSAttachmentStorageConfig();
}
return new FSAttachmentStorage(fs!);
return new FSAttachmentStorage(config.fs);
default:
throw `Invalid value for PL_ATTACHMENTS_BACKEND: ${backend}! Supported values: fs, s3, memory`;
throw `Invalid value for PL_ATTACHMENTS_BACKEND: ${config.backend}! Supported values: fs, s3, memory`;
}
}
@ -197,13 +201,8 @@ async function initAuthServers(config: PadlocConfig) {
async function initProvisioner(config: PadlocConfig, storage: Storage) {
switch (config.provisioning.backend) {
case "simple":
if (!config.provisioning.simple) {
config.provisioning.simple = new SimpleProvisionerConfig();
}
const simpleProvisioner = new SimpleProvisioner(config.provisioning.simple, storage);
await simpleProvisioner.init();
return simpleProvisioner;
case "basic":
return new BasicProvisioner(storage);
case "stripe":
if (!config.provisioning.stripe) {
throw "PL_PROVISIONING_BACKEND was set to 'stripe', but no related configuration was found!";
@ -278,9 +277,17 @@ async function start() {
const config = getConfig();
try {
await init(config);
console.log("Server started with config: ", JSON.stringify(config.toRaw(), null, 4));
console.log(
"Server started with config: ",
JSON.stringify(stripPropertiesRecursive(config.toRaw(), ["kind", "version"]), null, 4)
);
} catch (e) {
console.error("Init failed. Error: ", e, "\nConfig: ", JSON.stringify(config.toRaw(), null, 4));
console.error(
"Init failed. Error: ",
e,
"\nConfig: ",
JSON.stringify(stripPropertiesRecursive(config.toRaw(), ["kind", "version"]), null, 4)
);
}
}

View File

@ -1,14 +1,9 @@
import {
AccountProvisioning,
AccountQuota,
Feature,
OrgProvisioning,
OrgQuota,
Provisioner,
BasicProvisioner,
Provisioning,
ProvisioningStatus,
VaultProvisioning,
VaultQuota,
} from "@padloc/core/src/provisioning";
import { getIdFromEmail } from "@padloc/core/src/util";
import { Storage } from "@padloc/core/src/storage";
@ -16,25 +11,20 @@ import { ErrorCode } from "@padloc/core/src/error";
import { Config, ConfigParam } from "@padloc/core/src/config";
import { createServer, IncomingMessage, ServerResponse } from "http";
import { readBody } from "../transport/http";
import { Account, AccountID } from "@padloc/core/src/account";
import { Org, OrgID } from "@padloc/core/src/org";
import { AsSerializable } from "@padloc/core/src/encoding";
import { AccountID } from "@padloc/core/src/account";
export class DefaultAccountQuota extends Config implements AccountQuota {
@ConfigParam("number")
vaults = 1;
@ConfigParam("number")
orgs = 3;
storage = 1000;
}
export class DefaultAccountProvisioning
extends Config
implements
Pick<
AccountProvisioning,
"status" | "statusLabel" | "statusMessage" | "actionUrl" | "actionLabel" | "quota" | "disableFeatures"
>
Pick<AccountProvisioning, "status" | "statusLabel" | "statusMessage" | "actionUrl" | "actionLabel" | "quota">
{
@ConfigParam()
status: ProvisioningStatus = ProvisioningStatus.Active;
@ -53,12 +43,9 @@ export class DefaultAccountProvisioning
@ConfigParam(DefaultAccountQuota)
quota: DefaultAccountQuota = new DefaultAccountQuota();
@ConfigParam("string[]")
disableFeatures: Feature[] = [];
}
export class SimpleProvisionerConfig extends Config {
export class ApiProvisionerConfig extends Config {
@ConfigParam("number")
port: number = 4000;
@ -96,7 +83,7 @@ interface ProvisioningRequest {
updates: ProvisioningUpdate[];
}
class ProvisioningEntry extends AccountProvisioning {
export class ProvisioningEntry extends Provisioning {
constructor(vals: Partial<ProvisioningEntry> = {}) {
super();
Object.assign(this, vals);
@ -104,21 +91,17 @@ class ProvisioningEntry extends AccountProvisioning {
id: string = "";
@AsSerializable(OrgQuota)
orgQuota: OrgQuota = new OrgQuota();
@AsSerializable(VaultQuota)
vaultQuota: VaultQuota = new VaultQuota();
scheduledUpdates: ScheduledProvisioningUpdate[] = [];
metaData?: { [prop: string]: string } = undefined;
metaData?: any = undefined;
}
export class SimpleProvisioner implements Provisioner {
constructor(public readonly config: SimpleProvisionerConfig, private readonly storage: Storage) {}
export class ApiProvisioner extends BasicProvisioner {
constructor(public readonly config: ApiProvisionerConfig, public readonly storage: Storage) {
super(storage);
}
private async _getProvisioningEntry({ email, accountId }: { email: string; accountId?: string | undefined }) {
protected async _getProvisioningEntry({ email, accountId }: { email: string; accountId?: string | undefined }) {
const id = await getIdFromEmail(email);
try {
@ -139,100 +122,50 @@ export class SimpleProvisioner implements Provisioner {
const provisioning = new ProvisioningEntry({
id,
email,
accountId,
status: this.config.default.status,
statusLabel: this.config.default.statusLabel,
statusMessage: this.config.default.statusMessage,
actionUrl: this.config.default.actionUrl,
actionLabel: this.config.default.actionLabel,
account: new AccountProvisioning({
email,
accountId,
status: this.config.default.status,
statusLabel: this.config.default.statusLabel,
statusMessage: this.config.default.statusMessage,
actionUrl: this.config.default.actionUrl,
actionLabel: this.config.default.actionLabel,
quota: this.config.default.quota,
}),
});
try {
const { status, statusLabel, statusMessage, actionUrl, actionLabel } = await this.storage.get(
ProvisioningEntry,
"[default]"
);
const {
account: { status, statusLabel, statusMessage, actionUrl, actionLabel },
} = await this.storage.get(ProvisioningEntry, "[default]");
provisioning.status = status;
provisioning.statusLabel = statusLabel;
provisioning.statusMessage = statusMessage;
provisioning.actionUrl = actionUrl;
provisioning.actionLabel = actionLabel;
provisioning.account.status = status;
provisioning.account.statusLabel = statusLabel;
provisioning.account.statusMessage = statusMessage;
provisioning.account.actionUrl = actionUrl;
provisioning.account.actionLabel = actionLabel;
} catch (e) {}
return provisioning;
}
private async _getOrgProvisioning(account: Account, { id }: { id: OrgID }) {
const org = await this.storage.get(Org, id);
const { email, id: accountId } = org.isOwner(account) ? account : await this.storage.get(Account, org.owner);
const { status, statusLabel, statusMessage, orgQuota, vaultQuota } = await this._getProvisioningEntry({
email,
accountId,
});
const vaults = org.getVaultsForMember(account);
return {
org: new OrgProvisioning({
orgId: org.id,
status,
statusLabel,
statusMessage,
quota: orgQuota,
}),
vaults: vaults.map(
(v) =>
new VaultProvisioning({
vaultId: v.id,
status,
statusLabel,
statusMessage,
quota: vaultQuota,
})
),
};
}
async getProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) {
const provisioningEntry = await this._getProvisioningEntry({ email, accountId });
const provisioning = new Provisioning({
account: new AccountProvisioning({
...provisioningEntry,
quota: this.config.default.quota,
disableFeatures: this.config.default.disableFeatures,
}),
});
if (accountId) {
const account = await this.storage.get(Account, accountId);
const orgs = await Promise.all(account.orgs.map((org) => this._getOrgProvisioning(account, org)));
provisioning.orgs = orgs.map((o) => o.org);
provisioning.vaults = [
new VaultProvisioning({
vaultId: account.mainVault.id,
status: provisioningEntry.status,
statusLabel: provisioningEntry.statusLabel,
statusMessage: provisioningEntry.statusMessage,
quota: provisioningEntry.vaultQuota,
}),
...orgs.flatMap((o) => o.vaults),
];
}
return provisioning;
return this._getProvisioningEntry({ email, accountId });
}
async accountDeleted(_params: { email: string; accountId?: string }): Promise<void> {
// const id = await getIdFromEmail(email);
// try {
// const provisioning = await this.storage.get(ProvisioningEntry, id);
// if (provisioning) {
// await this.storage.delete(provisioning);
// }
// } catch (e) {
// if (e.code !== ErrorCode.NOT_FOUND) {
// throw e;
// }
// }
async accountDeleted({ email }: { email: string; accountId?: string }): Promise<void> {
const id = await getIdFromEmail(email);
try {
const provisioning = await this.storage.get(ProvisioningEntry, id);
if (provisioning) {
provisioning.account.status = ProvisioningStatus.Deleted;
}
await this.storage.save(provisioning);
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
}
}
async init() {
@ -240,15 +173,15 @@ export class SimpleProvisioner implements Provisioner {
}
private _applyUpdate(entry: ProvisioningEntry, update: ProvisioningUpdate) {
entry.status = update.status;
entry.statusLabel = update.statusLabel;
entry.statusMessage = update.statusMessage;
entry.actionUrl = update.actionUrl || this.config.default.actionUrl;
entry.actionLabel = update.actionLabel || this.config.default.actionLabel;
entry.account.status = update.status;
entry.account.statusLabel = update.statusLabel;
entry.account.statusMessage = update.statusMessage;
entry.account.actionUrl = update.actionUrl || this.config.default.actionUrl;
entry.account.actionLabel = update.actionLabel || this.config.default.actionLabel;
entry.metaData = update.metaData;
}
private async _handleRequest({ default: defaultProv, updates = [] }: ProvisioningRequest) {
private async _handleUpdateRequest({ default: defaultProv, updates = [] }: ProvisioningRequest) {
if (defaultProv) {
const entry = new ProvisioningEntry(defaultProv);
entry.id = "[default]";
@ -346,7 +279,7 @@ export class SimpleProvisioner implements Provisioner {
return null;
}
private async _handlePost(httpReq: IncomingMessage, httpRes: ServerResponse) {
protected async _handlePost(httpReq: IncomingMessage, httpRes: ServerResponse) {
let request: ProvisioningRequest;
try {
@ -366,7 +299,7 @@ export class SimpleProvisioner implements Provisioner {
}
try {
await this._handleRequest(request);
await this._handleUpdateRequest(request);
} catch (e) {
httpRes.statusCode = 500;
httpRes.end("Unexpected Error");
@ -377,7 +310,7 @@ export class SimpleProvisioner implements Provisioner {
httpRes.end();
}
private async _handleGet(httpReq: IncomingMessage, httpRes: ServerResponse) {
protected async _handleGet(httpReq: IncomingMessage, httpRes: ServerResponse) {
const email = new URL(httpReq.url!, "http://localhost").searchParams.get("email");
if (!email) {
@ -423,30 +356,31 @@ export class SimpleProvisioner implements Provisioner {
);
}
async _startServer() {
const server = createServer(async (httpReq, httpRes) => {
if (this.config.apiKey) {
let authHeader = httpReq.headers["authorization"];
authHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader;
const apiKeyMatch = authHeader?.match(/^Bearer (.+)$/);
if (!apiKeyMatch || apiKeyMatch[1] !== this.config.apiKey) {
httpRes.statusCode = 401;
httpRes.end();
return;
}
protected async _handleRequest(httpReq: IncomingMessage, httpRes: ServerResponse) {
if (this.config.apiKey) {
let authHeader = httpReq.headers["authorization"];
authHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader;
const apiKeyMatch = authHeader?.match(/^Bearer (.+)$/);
if (!apiKeyMatch || apiKeyMatch[1] !== this.config.apiKey) {
httpRes.statusCode = 401;
httpRes.end();
return;
}
}
switch (httpReq.method) {
case "POST":
return this._handlePost(httpReq, httpRes);
break;
case "GET":
return this._handleGet(httpReq, httpRes);
default:
httpRes.statusCode = 405;
httpRes.end();
}
});
switch (httpReq.method) {
case "POST":
return this._handlePost(httpReq, httpRes);
case "GET":
return this._handleGet(httpReq, httpRes);
default:
httpRes.statusCode = 405;
httpRes.end();
}
}
private async _startServer() {
const server = createServer((req, res) => this._handleRequest(req, res));
server.listen(this.config.port);
}

File diff suppressed because it is too large Load Diff