diff --git a/.github/workflows/build-tauri.yml b/.github/workflows/build-tauri.yml index 4c76ad7f..5e9d6f05 100644 --- a/.github/workflows/build-tauri.yml +++ b/.github/workflows/build-tauri.yml @@ -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 diff --git a/assets/fonts/FontAwesome/fa-brands-400.woff2 b/assets/fonts/FontAwesome/fa-brands-400.woff2 index a8800698..11a7640d 100644 Binary files a/assets/fonts/FontAwesome/fa-brands-400.woff2 and b/assets/fonts/FontAwesome/fa-brands-400.woff2 differ diff --git a/assets/fonts/FontAwesome/fa-duotone-900.woff2 b/assets/fonts/FontAwesome/fa-duotone-900.woff2 index b9757890..3a67898a 100644 Binary files a/assets/fonts/FontAwesome/fa-duotone-900.woff2 and b/assets/fonts/FontAwesome/fa-duotone-900.woff2 differ diff --git a/assets/fonts/FontAwesome/fa-light-300.woff2 b/assets/fonts/FontAwesome/fa-light-300.woff2 index d9bcd8a5..478229f6 100644 Binary files a/assets/fonts/FontAwesome/fa-light-300.woff2 and b/assets/fonts/FontAwesome/fa-light-300.woff2 differ diff --git a/assets/fonts/FontAwesome/fa-regular-400.woff2 b/assets/fonts/FontAwesome/fa-regular-400.woff2 index dff92f71..3b82efc3 100644 Binary files a/assets/fonts/FontAwesome/fa-regular-400.woff2 and b/assets/fonts/FontAwesome/fa-regular-400.woff2 differ diff --git a/assets/fonts/FontAwesome/fa-solid-900.woff2 b/assets/fonts/FontAwesome/fa-solid-900.woff2 index 9774065f..3dbaa2b3 100644 Binary files a/assets/fonts/FontAwesome/fa-solid-900.woff2 and b/assets/fonts/FontAwesome/fa-solid-900.woff2 differ diff --git a/assets/fonts/FontAwesome/fa-thin-100.woff2 b/assets/fonts/FontAwesome/fa-thin-100.woff2 index 0583c9c8..3f1f82be 100644 Binary files a/assets/fonts/FontAwesome/fa-thin-100.woff2 and b/assets/fonts/FontAwesome/fa-thin-100.woff2 differ diff --git a/assets/theme.css b/assets/theme.css index 0c00a1b5..65d2816d 100644 --- a/assets/theme.css +++ b/assets/theme.css @@ -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; diff --git a/packages/app/src/elements/alert-dialog.ts b/packages/app/src/elements/alert-dialog.ts index 5de83e6d..9dff8978 100644 --- a/packages/app/src/elements/alert-dialog.ts +++ b/packages/app/src/elements/alert-dialog.ts @@ -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 { 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 { const { message, dialogTitle, options, icon, vertical } = this; return html` -
+
${dialogTitle || message ? html`
${icon ? html` ` : ""} -
-
${dialogTitle}
-
${message}
+
+
${dialogTitle}
+
${message}
@@ -100,7 +107,7 @@ export class AlertDialog extends Dialog { super.done(i); } - show({ + async show({ message = "", title = "", options = ["OK"], @@ -109,6 +116,9 @@ export class AlertDialog extends Dialog { vertical = false, icon = this._icon(type), preventAutoClose, + maxWidth, + width, + hideOnDocumentVisibilityChange = false, }: AlertOptions = {}): Promise { this.message = message; this.dialogTitle = title; @@ -120,8 +130,21 @@ export class AlertDialog extends Dialog { 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) { diff --git a/packages/app/src/elements/app.ts b/packages/app/src/elements/app.ts index 008abd9a..e4168691 100644 --- a/packages/app/src/elements/app.ts +++ b/packages/app/src/elements/app.ts @@ -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
${$l("o f f l i n e")} - - + +
` : provisioning?.status === ProvisioningStatus.Frozen ? html`
- ${provisioning.statusLabel || $l("Account Frozen")} + ${provisioning.statusLabel || getDefaultStatusLabel(provisioning.status)} - displayProvisioning(provisioning)}> - + displayProvisioning(provisioning)}> +
` @@ -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}`); diff --git a/packages/app/src/elements/create-item-dialog.ts b/packages/app/src/elements/create-item-dialog.ts index ed44256e..8c0e0c0e 100644 --- a/packages/app/src/elements/create-item-dialog.ts +++ b/packages/app/src/elements/create-item-dialog.ts @@ -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 { 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, diff --git a/packages/app/src/elements/dialog.ts b/packages/app/src/elements/dialog.ts index cbef5a43..f83ae12d 100644 --- a/packages/app/src/elements/dialog.ts +++ b/packages/app/src/elements/dialog.ts @@ -29,7 +29,7 @@ export class Dialog extends LitElement { dismissOnTapOutside: boolean = true; @query(".inner") - private _inner: HTMLDivElement; + protected _inner: HTMLDivElement; readonly hideApp: boolean = false; @@ -89,7 +89,7 @@ export class Dialog extends LitElement { .inner { position: relative; - width: 100%; + width: var(--pl-dialog-width, 100%); height: auto; max-height: 100%; box-sizing: border-box; diff --git a/packages/app/src/elements/group-view.ts b/packages/app/src/elements/group-view.ts index 7f1d31bb..87ba0994 100644 --- a/packages/app/src/elements/group-view.ts +++ b/packages/app/src/elements/group-view.ts @@ -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)) { this.requestUpdate()} - >${group.name} this.requestUpdate()} > + @@ -434,12 +440,20 @@ export class GroupView extends Routing(StateMixin(LitElement)) { -
- +
+ ${$l("Save")} - ${this.hasChanges ? $l("Cancel") : $l("Close")} + ${$l("Cancel")}
`; diff --git a/packages/app/src/elements/icon.ts b/packages/app/src/elements/icon.ts index 396096cf..d8e4daa4 100644 --- a/packages/app/src/elements/icon.ts +++ b/packages/app/src/elements/icon.ts @@ -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"; + } `, ]; diff --git a/packages/app/src/elements/import-dialog.ts b/packages/app/src/elements/import-dialog.ts index e4141292..0d37c1dd 100644 --- a/packages/app/src/elements/import-dialog.ts +++ b/packages/app/src/elements/import-dialog.ts @@ -337,14 +337,6 @@ export class ImportDialog extends Dialog { 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" }); diff --git a/packages/app/src/elements/invite-view.ts b/packages/app/src/elements/invite-view.ts index 9c2dcedd..8f131a22 100644 --- a/packages/app/src/elements/invite-view.ts +++ b/packages/app/src/elements/invite-view.ts @@ -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; } diff --git a/packages/app/src/elements/item-view.ts b/packages/app/src/elements/item-view.ts index 1901cd61..95a5bf09 100644 --- a/packages/app/src/elements/item-view.ts +++ b/packages/app/src/elements/item-view.ts @@ -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; } diff --git a/packages/app/src/elements/login-signup.ts b/packages/app/src/elements/login-signup.ts index bca1ede4..bd5df35e 100644 --- a/packages/app/src/elements/login-signup.ts +++ b/packages/app/src/elements/login-signup.ts @@ -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"; diff --git a/packages/app/src/elements/member-item.ts b/packages/app/src/elements/member-item.ts index e2001913..fd4e25c6 100644 --- a/packages/app/src/elements/member-item.ts +++ b/packages/app/src/elements/member-item.ts @@ -47,7 +47,9 @@ export class MemberItem extends LitElement { ${this.hideInfo ? "" : html` - ${groups.length === 1 + ${!groups.length + ? "" + : groups.length === 1 ? html`
@@ -61,9 +63,17 @@ export class MemberItem extends LitElement {
`} ${isOwner - ? html`
${$l("Owner")}
` + ? html` +
+ ${$l("Owner")} +
+ ` : isAdmin - ? html`
${$l("Admin")}
` + ? html` +
+ ${$l("Admin")} +
+ ` : isSuspended ? html`
${$l("Suspended")}
` : ""} diff --git a/packages/app/src/elements/member-view.ts b/packages/app/src/elements/member-view.ts index dd7654b4..44f62453 100644 --- a/packages/app/src/elements/member-view.ts +++ b/packages/app/src/elements/member-view.ts @@ -303,9 +303,17 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
${isOwner - ? html`
${$l("Owner")}
` + ? html` +
+ ${$l("Owner")} +
+ ` : isAdmin - ? html`
${$l("Admin")}
` + ? html` +
+ ${$l("Admin")} +
+ ` : isSuspended ? html`
${$l("Suspended")}
` : ""} diff --git a/packages/app/src/elements/menu.ts b/packages/app/src/elements/menu.ts index 6ebc884a..5ba1c4a2 100644 --- a/packages/app/src/elements/menu.ts +++ b/packages/app/src/elements/menu.ts @@ -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)) { ` - : itemsQuota !== -1 - ? html` - - ${mainVault.items.size} / ${itemsQuota} - - ` : html`
${mainVault.items.size}
`}
` @@ -470,6 +454,14 @@ export class Menu extends Routing(StateMixin(LitElement)) { >
${org.name}
+ ${app.getOrgProvisioning(org).status !== ProvisioningStatus.Active + ? html` + + ` + : ""}
@@ -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)} >
${label}
+ + ${app.getOrgProvisioning(org).status !== + ProvisioningStatus.Active && path === "dashboard" + ? html` + + ` + : ""}
` )} @@ -496,6 +501,7 @@ export class Menu extends Routing(StateMixin(LitElement)) {
@@ -97,7 +99,7 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
this.go(`orgs/${this.orgId}/groups/new`)} - ?hidden=${quota?.groups === 0} + ?hidden=${!org.groups.length && app.getOrgFeatures(org).addGroup.hidden} >
New Group
@@ -124,13 +126,42 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {
+ ${status === ProvisioningStatus.Frozen + ? html` +
+

+ +
${$l("This org is frozen")}
+

+ + ${org.isOwner(app.account!) + ? html` + this.go("settings/billing")} + > +
Review Billing
+ +
+ ` + : ""} +
+ ` + : ""} +

-
+
${$l("Invites")}
-
${org.invites.length}
+
${org.invites.length}
-
+
${$l("Members")}
-
${org.members.length}
+
+ ${org.members.length}${quota.members !== -1 + ? ` / ${quota.members}` + : ""} +

-
+

-
+
${$l("Groups")}
-
${org.groups.length}
+
+ ${org.groups.length}${quota.groups !== -1 + ? ` / ${quota.groups}` + : ""} +
this.go(`orgs/${this.orgId}/vaults/new`)} + @click=${() => this.go(`orgs/${this.orgId}/groups/new`)} > @@ -261,9 +308,17 @@ export class OrgDashboard extends Routing(StateMixin(LitElement)) {

-
+
${$l("Vaults")}
-
${org.vaults.length}
+
+ ${org.vaults.length}${quota.vaults !== -1 + ? ` / ${quota.vaults}` + : ""} +
{ 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}`; + } } } diff --git a/packages/app/src/elements/scroller.ts b/packages/app/src/elements/scroller.ts index 3a73cb1d..61588d36 100644 --- a/packages/app/src/elements/scroller.ts +++ b/packages/app/src/elements/scroller.ts @@ -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")} diff --git a/packages/app/src/elements/settings-account.ts b/packages/app/src/elements/settings-account.ts index f98adbdb..39bf758d 100644 --- a/packages/app/src/elements/settings-account.ts +++ b/packages/app/src/elements/settings-account.ts @@ -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` +
+ ${$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!" + )} +
+ ${ownedOrgs.length + ? html` +
+ WARNING: ${$l( + "The following organizations are owned by you and will be deleted along with your account:" + )} + ${ownedOrgs.map((org) => org.name).join(", ")} +
+ ` + : ""} + `, { type: "destructive", title: $l("Delete Account"), diff --git a/packages/app/src/elements/settings-billing.ts b/packages/app/src/elements/settings-billing.ts index 5dbee40d..9236f48b 100644 --- a/packages/app/src/elements/settings-billing.ts +++ b/packages/app/src/elements/settings-billing.ts @@ -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) { -
-
- +
+ - - ${provisioning.actionUrl - ? html` - window.open(provisioning.actionUrl, "_blank")} - >${provisioning.actionLabel || $l("Learn More")} - ` - : ""} + >
diff --git a/packages/app/src/elements/settings-security.ts b/packages/app/src/elements/settings-security.ts index d5a94303..3499c571 100644 --- a/packages/app/src/elements/settings-security.ts +++ b/packages/app/src/elements/settings-security.ts @@ -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)) { this._moveAuthenticator(a, "up")} > @@ -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; diff --git a/packages/app/src/elements/settings.ts b/packages/app/src/elements/settings.ts index 97b216b9..c6e05b0a 100644 --- a/packages/app/src/elements/settings.ts +++ b/packages/app/src/elements/settings.ts @@ -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)) {
${$l("Display")}
-
this.go("settings/billing")} - hidden - > - -
${$l("Billing")}
-
this.go("settings/billing")} + ?hidden=${!app.getAccountProvisioning().billingPage} >
${$l("Billing")}
+ ${app.getAccountProvisioning().status !== ProvisioningStatus.Active + ? html` ` + : ""}
- +
`; diff --git a/packages/app/src/elements/vault-view.ts b/packages/app/src/elements/vault-view.ts index 2ab296b4..ee64f497 100644 --- a/packages/app/src/elements/vault-view.ts +++ b/packages/app/src/elements/vault-view.ts @@ -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)) { -
+

${$l("Groups")}
@@ -325,7 +338,7 @@ export class VaultView extends Routing(StateMixin(LitElement)) { ` : html`
- ${$l("No more Vautls available")} + ${$l("No more Groups available")}
`} @@ -448,10 +461,20 @@ export class VaultView extends Routing(StateMixin(LitElement)) {

-
- ${$l("Save")} +
+ + ${$l("Save")} + - ${$l("Cancel")} + ${$l("Cancel")}
`; diff --git a/packages/app/src/lib/provisioning.ts b/packages/app/src/lib/provisioning.ts index 4930e4fb..2b75c1b4 100644 --- a/packages/app/src/lib/provisioning.ts +++ b/packages/app/src/lib/provisioning.ts @@ -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``, + { + 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; +export function alertDisabledFeature(feature: OrgFeature, isOwner: boolean): Promise; +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``, { - 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) + ); } diff --git a/packages/app/src/mixins/auto-sync.ts b/packages/app/src/mixins/auto-sync.ts index 7a5e12b5..d807f134 100644 --- a/packages/app/src/mixins/auto-sync.ts +++ b/packages/app/src/mixins/auto-sync.ts @@ -8,6 +8,11 @@ export function AutoSync>(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() { diff --git a/packages/app/src/styles/base.ts b/packages/app/src/styles/base.ts index 71007423..5c9f42a7 100644 --- a/packages/app/src/styles/base.ts +++ b/packages/app/src/styles/base.ts @@ -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); } diff --git a/packages/app/src/styles/layout.ts b/packages/app/src/styles/layout.ts index 6e6cff2a..9c752f24 100644 --- a/packages/app/src/styles/layout.ts +++ b/packages/app/src/styles/layout.ts @@ -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); } diff --git a/packages/app/src/styles/misc.ts b/packages/app/src/styles/misc.ts index f804b617..65d5c1d4 100644 --- a/packages/app/src/styles/misc.ts +++ b/packages/app/src/styles/misc.ts @@ -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); diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index a7c2b9e9..0340112e 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -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 diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index aba3358b..20021510 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -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 { + 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 { @@ -1551,10 +1560,9 @@ export class App { } /** Create a new [[Org]]ganization */ - async createOrg(name: string, type: OrgType = OrgType.Business): Promise { + async createOrg(name: string): Promise { 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(); } /** diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 00bb999e..f0967409 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -224,6 +224,7 @@ export class Auth extends Serializable implements Storable { id: string; orgId: string; orgName: string; + expires: string; }[] = []; constructor(public email: string = "") { diff --git a/packages/core/src/billing.ts b/packages/core/src/billing.ts deleted file mode 100644 index 33aaa9f0..00000000 --- a/packages/core/src/billing.ts +++ /dev/null @@ -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) { - 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) { - 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; - delete(billingInfo: BillingInfo): Promise; - 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; -// }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 75a73702..16c098f9 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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) { diff --git a/packages/core/src/org.ts b/packages/core/src/org.ts index 62db27ec..377e23b4 100644 --- a/packages/core/src/org.ts +++ b/packages/core/src/org.ts @@ -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 = {}) { 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; diff --git a/packages/core/src/provisioning.ts b/packages/core/src/provisioning.ts index 8a88e02e..1f38ac64 100644 --- a/packages/core/src/provisioning.ts +++ b/packages/core/src/provisioning.ts @@ -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 = {}) { - super(); - Object.assign(this, vals); - } - - items = -1; - storage = 1000; -} - export class OrgQuota extends Serializable { constructor(vals: Partial = {}) { 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 = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}) { 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 = {}) { 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 = {}) { - 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; - accountDeleted(params: { email: string; accountId?: AccountID }): Promise; + orgDeleted(params: { id: OrgID }): Promise; } 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 { + 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 { + 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 { + 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; + } + } + } } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 31a1bcc2..5a8fb3d6 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -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); diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 6d6fcb35..fb748d0a 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -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; +} diff --git a/packages/core/src/vault.ts b/packages/core/src/vault.ts index f0c5ade3..efe02646 100644 --- a/packages/core/src/vault.ts +++ b/packages/core/src/vault.ts @@ -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 = ""; diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index f9723da7..1e81b24e 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -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" diff --git a/packages/server/package.json b/packages/server/package.json index 0fd2c60b..d66bcd27 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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" }, diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 6ab80edc..fd6830e5 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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; diff --git a/packages/server/src/init.ts b/packages/server/src/init.ts index 5ad073f4..09b705eb 100644 --- a/packages/server/src/init.ts +++ b/packages/server/src/init.ts @@ -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) + ); } } diff --git a/packages/server/src/provisioning/simple.ts b/packages/server/src/provisioning/api.ts similarity index 59% rename from packages/server/src/provisioning/simple.ts rename to packages/server/src/provisioning/api.ts index 62ea04cd..816b4c18 100644 --- a/packages/server/src/provisioning/simple.ts +++ b/packages/server/src/provisioning/api.ts @@ -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 { @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 = {}) { 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 { - // 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 { + 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); } diff --git a/packages/server/src/provisioning/stripe.ts b/packages/server/src/provisioning/stripe.ts index d7cc40c0..8de41f5f 100644 --- a/packages/server/src/provisioning/stripe.ts +++ b/packages/server/src/provisioning/stripe.ts @@ -1,12 +1,27 @@ -import { createServer } from "http"; import Stripe from "stripe"; import { Storage } from "@padloc/core/src/storage"; -import { getIdFromEmail } from "@padloc/core/src/util"; import { readBody } from "../transport/http"; -import { AccountProvisioning, OrgProvisioning, Provisioner, Provisioning } from "@padloc/core/src/provisioning"; import { Config, ConfigParam } from "@padloc/core/src/config"; -import { Account, AccountID } from "@padloc/core/src/account"; -import { Org, OrgID } from "@padloc/core/src/org"; +import { + AccountFeatures, + AccountQuota, + RichContent, + OrgQuota, + ProvisioningStatus, + OrgProvisioning, + OrgFeatures, + BasicProvisioner, + AccountProvisioning, + Provisioning, +} from "@padloc/core/src/provisioning"; +import { uuid } from "@padloc/core/src/util"; +import { Org } from "@padloc/core/src/org"; +import { createServer, IncomingMessage, ServerResponse } from "http"; +import { getCryptoProvider } from "@padloc/core/src/platform"; +import { base64ToBytes, bytesToBase64, equalCT, stringToBytes } from "@padloc/core/src/encoding"; +import { HMACKeyParams, HMACParams } from "@padloc/core/src/crypto"; +import { URLSearchParams } from "url"; +import { Account } from "@padloc/core/src/account"; export class StripeProvisionerConfig extends Config { @ConfigParam("string", true) @@ -16,144 +31,627 @@ export class StripeProvisionerConfig extends Config { publicKey!: string; @ConfigParam() - webhookPort!: number; + url: string = ""; + + @ConfigParam("number") + port: number = 4000; + + @ConfigParam("string", true) + portalSecret!: string; + + @ConfigParam("string", true) + webhookSecret?: string; + + @ConfigParam("number") + urlsExpireAfter: number = 48 * 60 * 60; + + @ConfigParam("number") + forceSyncAfter: number = 24 * 60 * 60; } -class AccountProvisioningEntry extends AccountProvisioning { - constructor(vals: Partial = {}) { - super(); - Object.assign(this, vals); - } - - id: string = ""; - - customer?: Stripe.Customer = undefined; +enum Tier { + Free = "free", + Premium = "premium", + Family = "family", + Team = "team", + Business = "business", } -// class OrgProvisioningEntry extends OrgProvisioning { -// constructor(vals: Partial = {}) { -// super(); -// Object.assign(this, vals); -// } +enum PortalAction { + UpdateSubscription = "update_subscription", + CancelSubscription = "cancel_subscription", + ReactivateSubscription = "reactivate_subscription", + UpdateBillingInfo = "update_billing_info", + AddPaymentMethod = "add_payment_method", +} -// customer?: Stripe.Customer = undefined; -// } +// Noop tag used for syntax highlighting +const html = (strings: TemplateStringsArray, ...keys: any[]): string => { + return strings.slice(0, strings.length - 1).reduce((p, s, i) => p + s + keys[i], "") + strings[strings.length - 1]; +}; -export class StripeProvisioner implements Provisioner { +export class StripeProvisioner extends BasicProvisioner { private _stripe: Stripe; + private _products = new Map< + string, + { product: Stripe.Product; tier: Tier; priceAnnual?: Stripe.Price; priceMonthly?: Stripe.Price } + >(); + private _tiers = { + [Tier.Free]: { + order: 0, + name: "Free", + description: "For your basic password management needs.", + minSeats: undefined, + maxSeats: undefined, + features: ["Unlimited vault items", "Unlimited devices"], + disabledFeatures: [ + "Multi-Factor authentication", + "Shared vaults", + "Encrypted file storage", + "Password audits", + ], + }, + [Tier.Premium]: { + order: 1, + name: "Premium", + description: "Power up your password manager!", + minSeats: undefined, + maxSeats: undefined, + features: [ + "Unlimited Vault Items", + "Unlimited Devices", + "Multi-Factor Authentication", + "Up to 1GB encrypted file storage", + ], + disabledFeatures: ["Shared Vaults"], + }, + [Tier.Family]: { + order: 2, + name: "Family", + description: "Easy and straightforward password management and file storage for the entire familiy.", + minSeats: 5, + maxSeats: 10, + features: [ + "Unlimited Vault Items", + "Unlimited Devices", + "Multi-Factor Authentication", + "Up to 1GB encrypted file storage", + "Up to 5 Shared Vaults", + ], + disabledFeatures: [], + }, + [Tier.Team]: { + order: 3, + name: "Team", + description: "Powerful collaborative password management for your team.", + minSeats: 5, + maxSeats: 50, + features: [ + "Unlimited Vault Items", + "Unlimited Devices", + "Multi-Factor Authentication", + "Up to 5GB encrypted file storage", + "Up to 20 Shared Vaults", + "Up to 10 groups for easier permission management", + ], + disabledFeatures: [], + }, + [Tier.Business]: { + order: 4, + name: "Business", + description: "Best-in-class online protection for your business.", + minSeats: 10, + maxSeats: 200, + features: [ + "Unlimited Vault Items", + "Unlimited Devices", + "Multi-Factor Authentication", + "Up to 20GB encrypted file storage", + "Unlimited Vaults", + "Unlimited Groups", + ], + disabledFeatures: [], + }, + }; - constructor(public config: StripeProvisionerConfig, public storage: Storage) { + constructor(public readonly config: StripeProvisionerConfig, public readonly storage: Storage) { + super(storage); this._stripe = new Stripe(config.secretKey, { apiVersion: "2020-08-27" }); } async init() { - this._startWebhook(); - } - - async getProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) { - const accountProvisioning = await this._getAccountProvisioning({ email, accountId }); - const provisioning = new Provisioning({ - account: accountProvisioning, - }); - if (accountId) { - const account = await this.storage.get(Account, accountId); - provisioning.orgs = await Promise.all(account.orgs.map((org) => this._getOrgProvisioning(org))); + if (!this.config.portalSecret) { + this.config.portalSecret = bytesToBase64(await getCryptoProvider().generateKey(new HMACKeyParams())); } - return provisioning; + + await this._loadPlans(); + await this._startServer(); } async accountDeleted(params: { email: string; accountId?: string }): Promise { - const entry = await this._getAccountProvisioningEntry(params); + const { account } = await this.getProvisioning(params); - if (!entry.customer) { - return; + if (account.metaData?.customer) { + try { + await this._stripe.customers.del(account.metaData.customer.id); + } catch (e) { + // If the customer is already gone we can ignore the error + if (e.code !== "resource_missing") { + throw e; + } + } + delete account.metaData.customer; } - try { - await this._stripe.customers.del(entry.customer.id); - await this.storage.delete(entry); - } catch (e) { - // If the customer is already gone we can ignore the error - if (e.code !== "resource_missing") { - throw e; + await super.accountDeleted(params); + } + + async getProvisioning(opts: { email: string; accountId?: string | undefined }) { + const provisioning = await super.getProvisioning(opts); + if ( + provisioning.account.accountId && + (!provisioning.account.metaData?.customer || + !provisioning.account.metaData?.lastSync || + provisioning.account.metaData.lastSync < Date.now() - this.config.forceSyncAfter * 1000) + ) { + console.log("sync billing!!"); + await this._syncBilling(provisioning); + } + return super.getProvisioning(opts); + } + + private _getProduct(tier: Tier) { + return [...this._products.values()].find((entry) => entry.tier === tier); + } + + private _getSubscriptionInfo(customer: Stripe.Customer) { + const subscription = customer.subscriptions?.data[0] || null; + const item = subscription?.items.data[0]; + const prod = (item && this._products.get(item.price.product as string)) || { + tier: Tier.Free, + product: null, + }; + return { + ...prod, + subscription, + item, + tierInfo: this._tiers[prod.tier], + price: item?.price, + }; + } + + private async _loadPlans() { + this._products.clear(); + for await (const price of this._stripe.prices.list({ expand: ["data.product"] })) { + const product = price.product as Stripe.Product; + const tier = product.metadata.tier as Tier | undefined; + if (!tier) { + continue; } + + if (!this._products.has(product.id)) { + this._products.set(product.id, { + tier, + product, + }); + } + + this._products.get(product.id)![price.recurring?.interval === "month" ? "priceMonthly" : "priceAnnual"] = + price; } } - private async _getAccountProvisioningEntry({ - email, - accountId, - }: { - email: string; - accountId?: string | undefined; - }) { - const id = await getIdFromEmail(email); - try { - const entry = await this.storage.get(AccountProvisioningEntry, id); - if (accountId && !entry.accountId) { - entry.accountId = accountId; - await this.storage.save(entry); - } - return entry; - } catch (e) { - return new AccountProvisioningEntry({ - email, - accountId, + private async _getCustomer({ email, accountId, metaData }: AccountProvisioning, fetch = false) { + let customer = metaData?.customer as Stripe.Customer | Stripe.DeletedCustomer | undefined; + + // Refresh customer + if (customer && fetch) { + customer = await this._stripe.customers.retrieve(customer.id, { + expand: ["subscriptions", "tax_ids"], }); } - } - - private async _getAccountProvisioning({ - email, - accountId, - }: { - email: string; - accountId?: string | undefined; - }): Promise { - const entry = await this._getAccountProvisioningEntry({ email, accountId }); - - if (!entry.accountId) { - return entry as AccountProvisioning; - } // Try to find customer with same email address that isn't assoziated with a different account or org - if (!entry.customer || entry.customer.deleted) { - const existingCustomers = await this._stripe.customers.list({ email, expand: ["data.subscriptions"] }); - entry.customer = existingCustomers.data.find( + if (!customer || customer.deleted) { + const existingCustomers = await this._stripe.customers.list({ + email, + expand: ["data.subscriptions", "data.tax_ids"], + }); + customer = existingCustomers.data.find( (c) => !c.metadata.org && (!c.metadata.account || c.metadata.account === accountId) ); } // Create a new customer - if (!entry.customer || entry.customer.deleted) { - entry.customer = await this._stripe.customers.create({ + if (!customer || customer.deleted) { + const account = accountId ? await this.storage.get(Account, accountId).catch(() => null) : null; + console.log("creating customer...", accountId, account?.email, account?.name); + console.trace(); + const testClock = await this._stripe.testHelpers.testClocks.create({ + name: `Test Clock for ${email}`, + frozen_time: Math.floor(Date.now() / 1000), + }); + customer = await this._stripe.customers.create({ email, - // name: acc.name, + test_clock: testClock.id, + name: account?.name, metadata: { - account: entry.accountId, + account: accountId!, }, }); } - if (!entry.customer.subscriptions?.data.length) { - const checkoutSession = await this._stripe.checkout.sessions.create({ - customer: entry.customer.id, - cancel_url: "https://web.padloc.app", - success_url: "https://web.padloc.app", + return customer; + } + + private _getAccountQuota(tier: Tier) { + switch (tier) { + case Tier.Free: + return new AccountQuota({ + vaults: 1, + storage: 0, + }); + default: + return new AccountQuota({ + vaults: 1, + storage: 1000, + }); + } + } + + private async _getUpgradeMessage( + customer: Stripe.Customer, + tiers: Tier[], + title = "Upgrade Required", + message = "Your current plan does not support this feature. Please upgrade to continue!", + allowUpdate = true, + highlightFeature?: string + ): Promise { + return { + type: "html", + content: html` +
+

${title}

+
${message}
+ ${!allowUpdate + ? html` +
+ You don't have the permissions to make changes to this subscription. Please ask the + organization's owner to make any necessary changes. +
+ ` + : ""} +
+
+ ${( + await Promise.all( + tiers.map((tier) => this._renderTier(tier, customer, allowUpdate, highlightFeature)) + ) + ).join("")} +
+
+
+ `, + }; + } + + private async _getAccountFeatures(tier: Tier, customer: Stripe.Customer) { + const features = new AccountFeatures(); + + if (tier === Tier.Free) { + features.manageAuthenticators.disabled = true; + features.manageAuthenticators.message = await this._getUpgradeMessage( + customer, + [Tier.Premium, Tier.Family, Tier.Team, Tier.Business], + undefined, + undefined, + true, + "Multi-Factor Authentication" + ); + features.attachments.disabled = true; + features.attachments.message = await this._getUpgradeMessage( + customer, + [Tier.Premium, Tier.Family, Tier.Team, Tier.Business], + undefined, + undefined, + true, + "File Storage" + ); + } + + if (![Tier.Family, Tier.Team, Tier.Business].includes(tier)) { + features.createOrg.disabled = true; + features.createOrg.message = await this._getUpgradeMessage(customer, [ + Tier.Family, + Tier.Team, + Tier.Business, + ]); + } + + return features; + } + + private _getOrgQuota(customer: Stripe.Customer) { + const { item, tier } = this._getSubscriptionInfo(customer); + + switch (tier) { + case Tier.Family: + return new OrgQuota({ + members: item?.quantity || 1, + vaults: 5, + groups: 0, + storage: 1000, + }); + case Tier.Team: + return new OrgQuota({ + members: item?.quantity || 1, + vaults: 20, + groups: 10, + storage: 5000, + }); + case Tier.Business: + return new OrgQuota({ + members: item?.quantity || 1, + vaults: 50, + groups: 20, + storage: 5000, + }); + default: + return new OrgQuota({ + members: 1, + vaults: 0, + groups: 0, + storage: 0, + }); + } + } + + private async _getOrgFeatures(customer: Stripe.Customer, tier: Tier, quota: OrgQuota, org?: Org | null) { + const features = new OrgFeatures(); + + if (tier === Tier.Family) { + features.addGroup.hidden = true; + features.addGroup.disabled = true; + } + + if (org) { + if (org.members.length >= (this._tiers[tier]?.maxSeats || 0)) { + features.addMember.disabled = true; + features.addMember.message = await this._getUpgradeMessage( + customer, + [Tier.Team, Tier.Business], + "Upgrade Required", + "You have reached the maximum number of orginization members for this plan. Please upgrade to the next tier to add more!", + false + ); + features.addMember.messageOwner = await this._getUpgradeMessage( + customer, + [Tier.Team, Tier.Business], + "Upgrade Required", + "You have reached the maximum number of orginization members for this plan. Please upgrade to the next tier to add more!", + true + ); + } else if (quota.members !== -1 && org?.members.length >= quota.members) { + features.addMember.disabled = true; + features.addMember.message = { + type: "plain", + content: + "You have reached your member limit. Please ask the organization owner to increase the number of seats in your subscription!", + }; + features.addMember.messageOwner = { + type: "html", + content: html` +
+

Additional Seats Required

+
+ You have reached your member limit. Please increase the number of seats in your + subscription! +
+ + + +
+ `, + }; + } + if (quota.groups !== -1 && org?.groups.length >= quota.groups) { + features.addGroup.disabled = true; + features.addGroup.message = await this._getUpgradeMessage( + customer, + [Tier.Team, Tier.Business], + "Upgrade Required", + "You have reached the maximum number of groups for this plan. Please upgrade to the next tier to add more!", + false, + "Groups" + ); + features.addGroup.messageOwner = await this._getUpgradeMessage( + customer, + [Tier.Team, Tier.Business], + "Upgrade Required", + "You have reached the maximum number of groups for this plan. Please upgrade to the next tier to add more!", + true, + "Groups" + ); + } + if (quota.vaults !== -1 && org?.vaults.length >= quota.vaults) { + features.addVault.disabled = true; + features.addVault.message = await this._getUpgradeMessage( + customer, + [Tier.Family, Tier.Team, Tier.Business], + "Upgrade Required", + "You have reached the maximum number of vaults for this plan. Please upgrade to the next tier to add more!", + false, + "Vaults" + ); + features.addVault.messageOwner = await this._getUpgradeMessage( + customer, + [Tier.Family, Tier.Team, Tier.Business], + "Upgrade Required", + "You have reached the maximum number of vaults for this plan. Please upgrade to the next tier to add more!", + true, + "Vaults" + ); + } + } + + return features; + } + + private async _getOrgProvisioning( + account: AccountProvisioning, + customer: Stripe.Customer, + existing?: OrgProvisioning + ) { + const { tier, subscription } = this._getSubscriptionInfo(customer); + + const org = existing && (await this.storage.get(Org, existing.orgId).catch(() => null)); + const quota = this._getOrgQuota(customer); + + const provisioning = new OrgProvisioning({ + orgId: existing?.orgId || (await uuid()), + orgName: + org?.name || + (tier === Tier.Family + ? "Family" + : tier === Tier.Team + ? "My Team" + : tier === Tier.Business + ? "My Business" + : "My Org"), + owner: account.accountId, + autoCreate: !org, + quota, + features: await this._getOrgFeatures(customer, tier, quota, org), + }); + + switch (subscription?.status || "canceled") { + case "canceled": + provisioning.status = ProvisioningStatus.Frozen; + provisioning.statusMessage = + "This organization has been frozen because the subscription was canceled! Please renew the subscription to unfreeze it!"; + break; + case "unpaid": + provisioning.status = ProvisioningStatus.Frozen; + provisioning.statusMessage = + "This organization has been frozen because there was a problem with the last payment. Please review your billing info and update your payment method if necessary!"; + break; + default: + provisioning.status = ProvisioningStatus.Active; + } + + if (org && provisioning.status === ProvisioningStatus.Active) { + if (org.members.length > provisioning.quota.members) { + provisioning.status = ProvisioningStatus.Frozen; + provisioning.statusMessage = + "This organization has been frozen because it's number of members exceeds the number of seats in your current subscription. To unfreeze this organization, please either purchase additional seats or remove members until the number of members matches the number of seats."; + } else if (org.groups.length > provisioning.quota.groups) { + provisioning.status = ProvisioningStatus.Frozen; + provisioning.statusMessage = + "This organization has been frozen because it's number of groups exceeds the maximum number of groups allowed in your current plan. To unfreeze this organization, please either upgrade to a higher tier or remove groups until the number of groups matches your quota"; + } else if (org.vaults.length > provisioning.quota.vaults) { + provisioning.status = ProvisioningStatus.Frozen; + provisioning.statusMessage = + "This organization has been frozen because it's number of vaults exceeds the maximum number of vaults allowed in your current plan. To unfreeze this organization, please either upgrade to a higher tier or remove vaults until the number of vaults matches your quota"; + } + } + + return provisioning; + } + + protected async _syncBilling({ account, orgs }: Provisioning) { + const customer = await this._getCustomer(account, true); + const { subscription, tier } = this._getSubscriptionInfo(customer); + const paymentMethods = (await this._stripe.customers.listPaymentMethods(customer.id, { type: "card" })).data; + const latestInvoice = + subscription && + (await this._stripe.invoices.retrieve(subscription.latest_invoice as string, { + expand: ["payment_intent", "lines.data.price.product"], + })); + + switch (subscription?.status) { + case "canceled": + case "incomplete": + case "incomplete_expired": + case "past_due": + case "unpaid": + account.status = ProvisioningStatus.Frozen; + account.actionLabel = "Learn More"; + account.actionUrl = "https://padloc.app/help/"; // TODO: Point to specific article/section + break; + default: + account.status = ProvisioningStatus.Active; + account.actionLabel = ""; + account.actionUrl = ""; + } + + account.quota = this._getAccountQuota(tier); + account.features = await this._getAccountFeatures(tier, customer); + + if (!account.metaData) { + account.metaData = {}; + } + account.metaData.customer = customer; + account.metaData.paymentMethods = paymentMethods; + account.metaData.latestInvoice = latestInvoice; + + if (subscription?.status === "trialing" && !account.metaData.firstTrialStarted) { + account.metaData.firstTrialStarted = Date.now(); + } + + account.billingPage = await this._renderBillingPage(customer, paymentMethods, latestInvoice); + + const existingOrg = orgs.find((o) => o.owner === account.accountId); + + if (existingOrg || [Tier.Family, Tier.Team, Tier.Business].includes(tier)) { + const org = await this._getOrgProvisioning(account, customer, existingOrg); + await this.storage.save(org); + account.orgs = [org.id]; + // Org will be auto-created, so hide create org button now + account.features.createOrg.hidden = true; + } + + account.metaData.lastSync = Date.now(); + + await this.storage.save(account); + } + + private async _getStripeUrl(customer: Stripe.Customer, action?: PortalAction, tier?: Tier) { + const { subscription } = this._getSubscriptionInfo(customer); + + if ( + action && + [PortalAction.UpdateSubscription, PortalAction.CancelSubscription].includes(action) && + !subscription + ) { + tier = tier || Tier.Premium; + const tierInfo = this._tiers[tier]; + const price = this._getProduct(tier)?.priceMonthly; + + if (!price) { + return null; + } + + const session = await this._stripe.checkout.sessions.create({ + customer: customer.id, + cancel_url: `${this.config.url}/callback`, + success_url: `${this.config.url}/callback`, mode: "subscription", payment_method_types: ["card"], line_items: [ { - // price_data: { - // currency: "USD", - // product: "prod_KAKP7w3M7Lzwvg", - // recurring: { - // interval: "month", - // interval_count: 12, - // }, - // }, - price: "price_1JVztbLGYleXiL7bebBwQWw3", - quantity: 1, + price: price.id, + adjustable_quantity: + tierInfo.minSeats || tierInfo.maxSeats + ? { + enabled: true, + minimum: Math.max(tierInfo.minSeats), + maximum: tierInfo.maxSeats, + } + : undefined, + quantity: tierInfo.minSeats || 1, }, ], subscription_data: { @@ -171,72 +669,729 @@ export class StripeProvisioner implements Provisioner { shipping: "never", }, }); - entry.actionUrl = checkoutSession.url || undefined; - } else { - const portalSession = await this._stripe.billingPortal.sessions.create({ customer: entry.customer.id }); - entry.actionUrl = portalSession.url; + return session.url; } - await this.storage.save(entry); + const session = await this._stripe.billingPortal.sessions.create({ + customer: customer.id, + return_url: `${this.config.url}/callback`, + }); - return entry as AccountProvisioning; + switch (action) { + case PortalAction.UpdateSubscription: + if (!subscription) { + return null; + } + + if (!tier) { + return `${session.url}/subscriptions/${subscription.id}/update`; + } + + const prod = this._getProduct(tier); + if (!prod) { + return null; + } + const { priceMonthly, priceAnnual } = prod; + const currentPrice = subscription!.items.data[0].price; + const price = currentPrice.recurring?.interval === "month" ? priceMonthly : priceAnnual; + + if (!price) { + return null; + } + + return `${session.url}/subscriptions/${subscription.id}/preview/${price.id}`; + case PortalAction.CancelSubscription: + if (!subscription) { + return null; + } + return `${session.url}/subscriptions/${subscription.id}/cancel`; + case PortalAction.ReactivateSubscription: + if (!subscription) { + return null; + } + return `${session.url}/subscriptions/${subscription.id}/reactivate`; + case PortalAction.UpdateBillingInfo: + return `${session.url}/customer/update`; + case PortalAction.AddPaymentMethod: + return `${session.url}/payment_methods`; + default: + return session.url; + } } - private async _getOrgProvisioning({ id }: { id: OrgID }) { - const org = await this.storage.get(Org, id); - const { email, id: accountId } = await this.storage.get(Account, org.owner); - const { status, statusMessage } = await this._getAccountProvisioning({ - email, - accountId, - }); - return new OrgProvisioning({ - orgId: org.id, - status, - statusMessage, - }); + protected async _handlePortalRequest(httpReq: IncomingMessage, httpRes: ServerResponse) { + const params = new URL(httpReq.url!, "http://localhost").searchParams; + + if (!(await this._verifyPortalParams(params))) { + httpRes.writeHead(401); + httpRes.write("Invalid or expired url!"); + httpRes.end(); + } + + const email = params.get("email"); + const action = (params.get("action") as PortalAction | null) || undefined; + const tier = (params.get("tier") as Tier | null) || undefined; + + if (!email) { + httpRes.statusCode = 400; + httpRes.end(); + return; + } + + const provisioning = await this.getProvisioning({ email }); + + if (!provisioning.account.accountId) { + httpRes.statusCode = 400; + httpRes.end(); + return; + } + + const customer = await this._getCustomer(provisioning.account); + + const url = await this._getStripeUrl(customer, action, tier); + + if (!url) { + httpRes.statusCode = 400; + httpRes.end(); + return; + } + + httpRes.writeHead(302, { Location: url }); + httpRes.end(); } - private async _startWebhook() { - const server = createServer(async (httpReq, httpRes) => { - httpRes.on("error", (e) => { - console.error(e); - }); + private async _signPortalParams(params: URLSearchParams) { + params.set("ts", Date.now().toString()); - let event: Stripe.Event; + params.sort(); - try { - const body = await readBody(httpReq); + const sig = await getCryptoProvider().sign( + base64ToBytes(this.config.portalSecret), + stringToBytes(params.toString()), + new HMACParams() + ); + + params.set("sig", bytesToBase64(sig)); + } + + private async _verifyPortalParams(params: URLSearchParams) { + const sig = params.get("sig"); + const ts = Number(params.get("ts")); + + if (!sig || isNaN(ts) || Date.now() - ts < 0 || Date.now() - ts > this.config.urlsExpireAfter * 1000) { + return false; + } + + params.delete("sig"); + params.sort(); + + const sig1 = base64ToBytes(sig); + const sig2 = await getCryptoProvider().sign( + base64ToBytes(this.config.portalSecret), + stringToBytes(params.toString()), + new HMACParams() + ); + + return equalCT(sig1, sig2); + } + + private async _getPortalUrl(customer: Stripe.Customer, action?: PortalAction, tier?: Tier) { + const url = new URL(`${this.config.url}/portal`); + + url.searchParams.set("email", customer.email!); + + if (action) { + url.searchParams.set("action", action); + } + + if (tier) { + url.searchParams.set("tier", tier); + } + + await this._signPortalParams(url.searchParams); + + return url.toString(); + } + + private async _renderTier(tier: Tier, cus: Stripe.Customer, allowUpdate = true, highlightFeature?: string) { + const prod = this._getProduct(tier)!; + if (!prod) { + return ""; + } + const { priceMonthly, priceAnnual } = prod; + const { subscription, item, price, tier: currentTier } = this._getSubscriptionInfo(cus); + const isCurrent = currentTier === tier; + const perSeat = [Tier.Family, Tier.Team, Tier.Business].includes(tier); + const info = this._tiers[tier]; + const hf = highlightFeature?.toLowerCase(); + + const res = html` +
+
+
+
${info.name}
+ ${isCurrent ? html`
Current Plan
` : ""} +
+
+ ${priceMonthly && (!price || price.recurring?.interval === "month") + ? html` + + $${(priceMonthly.unit_amount! / 100).toFixed(2)} + ${perSeat ? "/ seat " : ""} / month + + ` + : ""} + ${priceAnnual && (!price || price.recurring?.interval === "year") + ? html` + ${priceMonthly ? html`or ` : ""} + + $${(priceAnnual.unit_amount! / 100).toFixed(2)} + ${perSeat ? "/ seat " : ""} / year + + ` + : ""} +
+
${info.description}
+
+
+ ${info.features + .map( + (feature) => + html` +
+ +
${feature}
+
+ ` + ) + .join("")} + ${info.disabledFeatures + .map( + (feature) => + html` +
+ +
${feature}
+
+ ` + ) + .join("")} +
+ ${(isCurrent && tier === Tier.Free) || !allowUpdate + ? "" + : isCurrent + ? html` +
+ ${item?.quantity && item.quantity > 1 + ? html` +
+ Current seats: ${item.quantity} +
+ ` + : ""} + + + +
+ ` + : html` + + `} +
+
+ `; + return res; + } + + private async _renderSubscription( + customer: Stripe.Customer, + paymentMethods: Stripe.PaymentMethod[], + latestInvoice: Stripe.Invoice | null + ) { + const { tier, tierInfo, subscription, item } = this._getSubscriptionInfo(customer); + const paymentMethod = + subscription && paymentMethods.find((pm) => pm.id === subscription.default_payment_method); + const country = customer.address?.country || paymentMethod?.card?.country || undefined; + const periodEnd = (subscription && new Date(subscription.current_period_end * 1000))?.toLocaleDateString( + country + ); + const status = subscription?.status || "active"; + const paymentError = (latestInvoice?.payment_intent as Stripe.PaymentIntent)?.last_payment_error; + + return html` +
+
+
+
${tierInfo.name}
+
+ ${status} +
+
+ ${subscription + ? html` +
+ ${paymentError + ? html` + Payment failed + ${paymentError.payment_method?.card + ? html` + using •••• + ${paymentError.payment_method.card.last4} + ` + : ""} ` + : subscription.cancel_at_period_end + ? html` Cancels on ${periodEnd} ` + : html` + Renews on ${periodEnd} + ${paymentMethod?.card + ? html` + using •••• + ${paymentMethod.card.last4} + ` + : ""} + `} +
+ ` + : ""} +
+
+ ${tierInfo.features + .map( + (feature) => html` +
+ +
${feature}
+
+ ` + ) + .join("")} + ${tierInfo.disabledFeatures + .map( + (feature) => html` +
+ +
${feature}
+
+ ` + ) + .join("")} +
+ ${subscription + ? html` + ${item?.quantity && item.quantity > 1 + ? html` +
+ Current seats: + ${item.quantity} +
+ ` + : ""} + ${subscription.cancel_at_period_end + ? html` + + + + ` + : html` + + + + + + + `} + ` + : html` + + + + `} +
+
+
+ ${latestInvoice + ? html` + + ` + : ""} + `; + } + + private async _renderCustomerInfo( + customer: Stripe.Customer, + paymentMethods: Stripe.PaymentMethod[], + latestInvoice: Stripe.Invoice | null + ) { + const paymentIntent = latestInvoice?.payment_intent as Stripe.PaymentIntent; + const paymentError = paymentIntent?.last_payment_error; + return html` +
+
Billing Address
+
+
+
Email
+
${customer.email}
+
+
+
Address
+ ${customer.name ? html`
${customer.name}
` : ""} + ${customer.address?.line1 ? html`
${customer.address?.line1}
` : ""} + ${customer.address?.line2 ? html`
${customer.address?.line2}
` : ""} + ${customer.address?.city + ? html`
${customer.address?.postal_code} ${customer.address?.city}
` + : ""} +
+ ${customer.tax_ids?.data[0] + ? html` +
+
Tax ID
+
${customer.tax_ids.data[0].value}
+
+ ` + : ""} + +
+
+ +
+
Payment Methods
+
+ ${paymentMethods + .map(({ id, card }) => { + return html` + ${card + ? html` +
+
+ +
•••• ${card.last4}
+
Expires ${card.exp_month} / ${card.exp_year}
+
+ ${paymentError && paymentError.payment_method?.id === id + ? html`
+ + ${paymentError.message || + "There was a problem with your payment method."} +
` + : ""} +
+ ` + : ""} + `; + }) + .join("")} +
+ ${paymentMethods.length + ? html` + + + + ` + : html` + + + + `} +
+
+
+ `; + } + + private async _renderBillingPage( + customer: Stripe.Customer, + paymentMethods: Stripe.PaymentMethod[], + latestInvoice: Stripe.Invoice | null + ): Promise { + // const { tier } = this._getSubscriptionInfo(customer); + return { + type: "html", + content: html` +
+

Subscription

+ +
+ ${await this._renderSubscription(customer, paymentMethods, latestInvoice)} +
+
+ +
+

Billing Info

+ +
+ ${await this._renderCustomerInfo(customer, paymentMethods, latestInvoice)} +
+
+ +

Plans

+
+ ${( + await Promise.all( + [Tier.Free, Tier.Premium, Tier.Family, Tier.Team, Tier.Business] + // .filter((t) => this._tiers[t].order >= this._tiers[tier].order) + .map((tier) => this._renderTier(tier, customer)) + ) + ).join("\n")} +
+ `, + }; + } + + private async _handleStripeEvent(httpReq: IncomingMessage, httpRes: ServerResponse) { + httpRes.on("error", (e) => { + console.error(e); + }); + + let event: Stripe.Event; + + try { + const body = await readBody(httpReq); + if (this.config.webhookSecret) { + console.log("verifying signature", httpReq.headers["stripe-signature"]); + event = this._stripe.webhooks.constructEvent( + body, + httpReq.headers["stripe-signature"] as string, + this.config.webhookSecret + ); + } else { event = JSON.parse(body); - } catch (e) { - httpRes.statusCode = 400; + } + } catch (e) { + httpRes.statusCode = 400; + httpRes.end(); + return; + } + + console.log("handle stripe event", event.type); + + let customer: Stripe.Customer | Stripe.DeletedCustomer | undefined = undefined; + + switch (event.type) { + case "customer.updated": + customer = event.data.object as Stripe.Customer; + break; + case "customer.subscription.deleted": + case "customer.subscription.created": + case "customer.subscription.updated": + const sub = event.data.object as Stripe.Subscription; + customer = await this._stripe.customers.retrieve(sub.customer as string); + break; + } + if (customer && !customer.deleted && customer.email) { + console.log( + "event received for customer", + event.type, + customer.id, + customer.email, + customer.metadata.account + ); + const provisioning = await this.getProvisioning({ + email: customer.email, + accountId: customer.metadata.account, + }); + await this._syncBilling(provisioning); + } + + httpRes.statusCode = 200; + httpRes.end(); + } + + protected async _handleSyncBilling(httpReq: IncomingMessage, httpRes: ServerResponse) { + let params: { email: string; accountId?: string }; + try { + const body = await readBody(httpReq); + params = JSON.parse(body); + } catch (e) { + httpRes.statusCode = 400; + httpRes.end(); + return; + } + + const provisioning = await this.getProvisioning({ + email: params.email, + accountId: params.accountId, + }); + + await this._syncBilling(provisioning); + + httpRes.statusCode = 200; + httpRes.end(); + } + + protected async _handleCallbackRequest(_httpReq: IncomingMessage, httpRes: ServerResponse) { + // const params = new URL(httpReq.url!, "http://localhost").searchParams; + // const message = params.get("message"); + + httpRes.write( + html` + + + + + + + + ` + ); + httpRes.end(); + } + + protected async _handleRequest(httpReq: IncomingMessage, httpRes: ServerResponse) { + const path = new URL(httpReq.url!, "http://localhost").pathname; + + if (path === "/stripe_webhooks") { + if (httpReq.method !== "POST") { + httpRes.statusCode = 405; httpRes.end(); return; } - let customer: Stripe.Customer | Stripe.DeletedCustomer | undefined = undefined; + return this._handleStripeEvent(httpReq, httpRes); + } - switch (event.type) { - case "customer.created": - case "customer.deleted": - case "customer.updated": - customer = event.data.object as Stripe.Customer; - break; - case "customer.subscription.deleted": - case "customer.subscription.created": - case "customer.subscription.updated": - const sub = event.data.object as Stripe.Subscription; - customer = await this._stripe.customers.retrieve(sub.customer as string); - break; + if (path === "/sync") { + if (httpReq.method !== "POST") { + httpRes.statusCode = 405; + httpRes.end(); + return; } - if (customer && !customer.deleted) { - console.log("found customer!", customer); + return this._handleSyncBilling(httpReq, httpRes); + } + + if (path === "/portal") { + if (httpReq.method !== "GET") { + httpRes.statusCode = 405; + httpRes.end(); + return; } - httpRes.statusCode = 200; - httpRes.end(); - }); + return this._handlePortalRequest(httpReq, httpRes); + } - server.listen(this.config.webhookPort); + if (path == "/callback") { + if (httpReq.method !== "GET") { + httpRes.statusCode = 405; + httpRes.end(); + return; + } + + return this._handleCallbackRequest(httpReq, httpRes); + } + + httpRes.statusCode = 400; + httpRes.end(); + } + + private async _startServer() { + const server = createServer((req, res) => this._handleRequest(req, res)); + server.listen(this.config.port); } }