Merge pull request #426 from padloc/feature/transfer-ownership

Add ability to transfer organisation ownership
This commit is contained in:
Martin Kleinschrodt 2022-04-12 08:33:53 +02:00 committed by GitHub
commit e604d10d25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 302 additions and 80 deletions

View File

@ -107,11 +107,11 @@ export class InviteView extends Routing(StateMixin(LitElement)) {
this._resendButton.start();
let org = this._org!;
try {
this._resendButton?.success();
const newInvite = (await app.createInvites(org, [this._invite.email], this._invite.purpose))[0];
this.go(`orgs/${this.orgId}/invites/${newInvite.id}`, undefined, true);
this._resendButton.success();
} catch (e) {
this._resendButton.fail();
this._resendButton?.fail();
alert(e.message, { type: "warning" });
}
}

View File

@ -152,8 +152,9 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
this.requestUpdate();
} catch (e) {
this._saveButton?.fail();
alert(e.message || $l("Something went wrong. Please try again later!"), { type: "warning" });
throw e;
alert(e.message || $l("Something went wrong while processing your request. Please try again later!"), {
type: "warning",
});
}
}
@ -178,7 +179,9 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
this._saveButton.success();
} catch (e) {
this._saveButton.fail();
throw e;
alert(e.message || $l("Something went wrong while processing your request. Please try again later!"), {
type: "warning",
});
}
}
}
@ -202,7 +205,33 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
this.requestUpdate();
} catch (e) {
this._saveButton.fail();
throw e;
alert(e.message || $l("Something went wrong while processing your request. Please try again later!"), {
type: "warning",
});
}
}
}
private async _makeOwner() {
const member = this._member!;
const confirmed = await confirm(
$l("Are you sure you want to transfer this organizations ownership to {0}?", member.name || member.email),
$l("Make Owner"),
$l("Cancel")
);
if (confirmed) {
this._saveButton.start();
try {
await app.transferOwnership(this._org!, member);
this._saveButton.success();
this.requestUpdate();
alert($l("The organization ownership was transferred successfully!"), { type: "success" });
} catch (e) {
this._saveButton.fail();
alert(e.message || $l("Something went wrong while processing your request. Please try again later!"), {
type: "warning",
});
}
}
}
@ -224,7 +253,9 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
this.requestUpdate();
} catch (e) {
this._saveButton.fail();
throw e;
alert(e.message || $l("Something went wrong while processing your request. Please try again later!"), {
type: "warning",
});
}
}
}
@ -246,7 +277,9 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
this.requestUpdate();
} catch (e) {
this._saveButton.fail();
throw e;
alert(e.message || $l("Something went wrong while processing your request. Please try again later!"), {
type: "warning",
});
}
}
}
@ -373,6 +406,17 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
<div class="ellipsis">${$l("Remove Admin")}</div>
</div>
`}
${accountIsOwner && !isOwner
? html`
<div
class="small double-padded list-item center-aligning spacing horizontal layout hover click"
@click=${this._makeOwner}
>
<pl-icon icon="owner"></pl-icon>
<div class="ellipsis">${$l("Make Owner")}</div>
</div>
`
: ""}
</pl-list>
</pl-popover>
</header>

View File

@ -339,6 +339,7 @@ export class Menu extends Routing(StateMixin(LitElement)) {
: ""}
${app.orgs.map((org) => {
const vaults = app.vaults.filter((v) => v.org && v.org.id === org.id);
const isAdmin = org.isAdmin(app.account!);
return html`
<div>
@ -352,6 +353,7 @@ export class Menu extends Routing(StateMixin(LitElement)) {
<pl-button
class="small transparent round slim negatively-margined reveal-on-hover"
@click=${(e: Event) => this._goTo(`orgs/${org.id}`, undefined, e)}
?hidden=${!isAdmin}
>
<pl-icon icon="settings"></pl-icon>
</pl-button>
@ -388,6 +390,7 @@ export class Menu extends Routing(StateMixin(LitElement)) {
<div
class="menu-item subtle"
@click=${() => this._goTo(`orgs/${org.id}/vaults/new`)}
?hidden=${!isAdmin}
>
<pl-icon icon="add"></pl-icon>

View File

@ -135,22 +135,33 @@ export class OrgSettingsView extends Routing(StateMixin(LitElement)) {
<pl-scroller class="stretch">
<div class="vertical center-aligning padded layout">
<div class="vertical spacing layout fill-horizontally max-width-30em">
<section class="padded vertical spacing layout">
<h2 class="margined section-header">${$l("Security")}</h2>
<section class="margined box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Security")}</h2>
<pl-button id="rotateKeysButton" @click=${this._rotateKeys}>
${$l("Rotate Cryptographic Keys")}
</pl-button>
<div>
<div class="half-padded list-item">
<pl-button id="rotateKeysButton" @click=${this._rotateKeys}>
${$l("Rotate Cryptographic Keys")}
</pl-button>
</div>
</div>
</section>
<section class="padded vertical spacing layout">
<h2 class="margined section-header">${$l("General")}</h2>
<section class="margined box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("More")}</h2>
<pl-button @click=${this._changeName}> ${$l("Change Organization Name")} </pl-button>
<pl-button class="negative" @click=${this._deleteOrg}>
${$l("Delete Organization")}
</pl-button>
<div>
<div class="half-padded list-item">
<pl-button @click=${this._changeName}>
${$l("Change Organization Name")}
</pl-button>
</div>
<div class="half-padded list-item">
<pl-button class="negative" @click=${this._deleteOrg}>
${$l("Delete Organization")}
</pl-button>
</div>
</div>
</section>
</div>
</div>

View File

@ -509,11 +509,6 @@ export class App {
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();
}
@ -1140,7 +1135,7 @@ export class App {
// If the revision to be fetched matches the revision stored locally,
// we don't need to fetch anything
if (localVault && revision && localVault.revision === revision) {
if (localVault && revision && localVault.revision === revision && !localVault.error) {
return localVault;
}
@ -1191,6 +1186,8 @@ export class App {
this._migrateFavorites(result);
result.error = undefined;
await this.saveVault(result);
return result;
@ -1787,6 +1784,19 @@ export class App {
});
}
/**
* Transfers an organizations ownership to a different member
*/
async transferOwnership(org: Org, member: OrgMember) {
if (!this.account || this.account.locked) {
throw "App needs to be logged in and unlocked to transfer an organizations ownership!";
}
await this.updateOrg(org.id, async (org) => {
await org.unlock(this.account as UnlockedAccount);
await org.makeOwner(member);
});
}
/*
* ===================
* INVITE MANAGEMENT

View File

@ -435,7 +435,6 @@ export class Org extends SharedContainer implements Storable {
await this.generateKeys();
// Rotate org encryption key
delete this.encryptedData;
await this.updateAccessors(this.members.filter((m) => m.role === OrgRole.Owner));
// Re-sign all members
@ -568,8 +567,8 @@ export class Org extends SharedContainer implements Storable {
/**
* Removes a member from the organization
*/
async removeMember(member: { id: AccountID }) {
if (!this.privateKey) {
async removeMember(member: { id: AccountID }, reSignMembers = true) {
if (reSignMembers && !this.privateKey) {
throw "Organisation needs to be unlocked first.";
}
@ -581,16 +580,46 @@ export class Org extends SharedContainer implements Storable {
// Remove member
this.members = this.members.filter((m) => m.id !== member.id);
// Verify remaining members (since we're going to re-sign them)
if (reSignMembers) {
// Verify remaining members (since we're going to re-sign them)
await this.verifyAll();
// Bump minimum update date
this.minMemberUpdated = new Date();
// Re-sign all members
await Promise.all(
this.members.filter((m) => m.role !== OrgRole.Suspended).map((m) => this.addOrUpdateMember(m))
);
}
}
/**
* Transfers organization ownership to a different member
*/
async makeOwner(member: { id: AccountID }) {
if (!this.privateKey) {
throw "Organisation needs to be unlocked first.";
}
// Verify members and groups with current public key
await this.verifyAll();
// Bump minimum update date
this.minMemberUpdated = new Date();
const newOwner = this.getMember(member);
const existingOwner = this.getMember({ id: this.owner })!;
// Re-sign all members
await Promise.all(
this.members.filter((m) => m.role !== OrgRole.Suspended).map((m) => this.addOrUpdateMember(m))
);
if (!newOwner || !existingOwner) {
throw "New and/or existing owner not found.";
}
newOwner.role = OrgRole.Owner;
existingOwner.role = OrgRole.Admin;
this.owner = newOwner.id;
await this.addOrUpdateMember(newOwner);
await this.addOrUpdateMember(existingOwner);
await this.updateAccessors(this.members.filter((m) => m.role === OrgRole.Owner));
}
toString() {

View File

@ -1,7 +1,7 @@
import { Account, AccountID } from "./account";
import { AsSerializable, Serializable } from "./encoding";
import { ErrorCode } from "./error";
import { OrgID } from "./org";
import { Err, ErrorCode } from "./error";
import { Org, OrgID, OrgInfo } from "./org";
import { Storable, Storage } from "./storage";
import { getIdFromEmail } from "./util";
@ -192,7 +192,12 @@ export class Provisioning extends Serializable {
export interface Provisioner {
getProvisioning(params: { email: string; accountId?: AccountID }): Promise<Provisioning>;
accountDeleted(params: { email: string; accountId?: AccountID }): Promise<void>;
orgDeleted(params: { id: OrgID }): Promise<void>;
orgDeleted(params: OrgInfo): Promise<void>;
orgOwnerChanged(
org: OrgInfo,
prevOwner: { email: string; id: AccountID },
newOwner: { email: string; id: AccountID }
): Promise<void>;
}
export class StubProvisioner implements Provisioner {
@ -201,7 +206,12 @@ export class StubProvisioner implements Provisioner {
}
async accountDeleted(_params: { email: string; accountId?: string }) {}
async orgDeleted(_params: { id: OrgID }) {}
async orgDeleted(_params: OrgInfo) {}
async orgOwnerChanged(
_org: { id: string },
_prevOwner: { email: string; id: string },
_newOwner: { email: string; id: string }
): Promise<void> {}
}
export class BasicProvisioner implements Provisioner {
@ -214,12 +224,9 @@ export class BasicProvisioner implements Provisioner {
email: string;
accountId?: string | undefined;
}): Promise<Provisioning> {
const id = await getIdFromEmail(email);
const provisioning = new Provisioning();
provisioning.account = await this.storage
.get(AccountProvisioning, id)
.catch(() => new AccountProvisioning({ id, email, accountId }));
provisioning.account = await this._getOrCreateAccountProvisioning({ email, accountId });
if (!provisioning.account.accountId && accountId) {
provisioning.account.accountId = accountId;
@ -227,27 +234,25 @@ export class BasicProvisioner implements Provisioner {
}
const account =
provisioning.account.accountId &&
(await this.storage.get(Account, provisioning.account.accountId).catch(() => null));
(provisioning.account.accountId &&
(await this.storage.get(Account, provisioning.account.accountId).catch(() => null))) ||
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;
}
orgIds.map((orgId) =>
this._getOrCreateOrgProvisioning(orgId).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 prov;
})
)
);
@ -263,18 +268,87 @@ export class BasicProvisioner implements Provisioner {
await this.storage.delete(prov);
}
async orgDeleted({ id }: { id: OrgID }): Promise<void> {
async orgDeleted({ id }: OrgInfo): Promise<void> {
try {
const orgProv = await this.storage.get(OrgProvisioning, id);
const owner = await this.storage.get(Account, orgProv.owner);
await this.storage.delete(new OrgProvisioning({ orgId: id }));
const accountProv = await this.storage.get(AccountProvisioning, orgProv.owner);
const accountProv = await this.storage.get(AccountProvisioning, await getIdFromEmail(owner.email));
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;
}
}
}
async orgOwnerChanged(
{ id }: OrgInfo,
prevOwner: { email: string; id: AccountID },
newOwner: { email: string; id: AccountID }
) {
const [orgProv, prevOwnerProv, newOwnerProv] = await Promise.all([
this._getOrCreateOrgProvisioning(id),
this._getOrCreateAccountProvisioning(prevOwner),
this._getOrCreateAccountProvisioning(newOwner),
]);
if (newOwnerProv.orgs.length) {
throw new Err(
ErrorCode.PROVISIONING_NOT_ALLOWED,
"You cannot transfer this organization to this account because they're already owner of a different organization."
);
}
orgProv.owner = newOwner.id;
prevOwnerProv.orgs = prevOwnerProv.orgs.filter((o) => o !== id);
newOwnerProv.orgs.push(id);
await Promise.all([
this.storage.save(orgProv),
this.storage.save(prevOwnerProv),
this.storage.save(newOwnerProv),
]);
}
private async _getOrCreateAccountProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) {
let prov: AccountProvisioning;
const id = await getIdFromEmail(email);
try {
prov = await this.storage.get(AccountProvisioning, id);
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
prov = new AccountProvisioning({ id, email, accountId });
await this.storage.save(prov);
}
return prov;
}
private async _getOrCreateOrgProvisioning(orgId: OrgID) {
let prov: OrgProvisioning;
try {
prov = await this.storage.get(OrgProvisioning, orgId);
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
const org = await this.storage.get(Org, orgId).catch(() => null);
prov = new OrgProvisioning({
orgId,
owner: org?.owner,
orgName: org?.name || "My Org",
});
await this.storage.save(prov);
}
return prov;
}
}

View File

@ -841,7 +841,7 @@ export class Controller extends API {
if (org.isOwner(account)) {
await this.deleteOrg(org.id);
} else {
org.removeMember(account);
await org.removeMember(account, false);
await this.storage.save(org);
}
}
@ -927,6 +927,7 @@ export class Controller extends API {
invites,
revision,
minMemberUpdated,
owner,
}: Org) {
const { account, provisioning } = this._requireAuth();
@ -970,10 +971,11 @@ export class Controller extends API {
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
// Only org owners can add or remove members, change roles, create invites or transfer ownership
if (
!isOwner &&
(addedMembers.length ||
(owner !== org.owner ||
addedMembers.length ||
removedMembers.length ||
addedInvites.length ||
removedInvites.length ||
@ -1014,6 +1016,14 @@ export class Controller extends API {
vaults,
});
if (org.owner !== owner) {
await this.provisioner.orgOwnerChanged(
org,
org.getMember({ id: org.owner })!,
org.getMember({ id: owner })!
);
}
// certain properties may only be updated by organization owners
if (isOwner) {
Object.assign(org, {
@ -1026,6 +1036,7 @@ export class Controller extends API {
accessors,
invites,
minMemberUpdated,
owner,
});
}
@ -1193,10 +1204,10 @@ export class Controller extends API {
})
);
await this.provisioner.orgDeleted(org);
await this.storage.delete(org);
await this.provisioner.orgDeleted(org);
this.log("org.delete", { org: { name: org.name, id: org.id, owner: org.owner } });
}

View File

@ -15,7 +15,7 @@ import {
Provisioning,
} from "@padloc/core/src/provisioning";
import { uuid } from "@padloc/core/src/util";
import { Org } from "@padloc/core/src/org";
import { Org, OrgInfo } from "@padloc/core/src/org";
import { createServer, IncomingMessage, ServerResponse } from "http";
import { getCryptoProvider } from "@padloc/core/src/platform";
import { base64ToBytes, bytesToBase64, stringToBytes } from "@padloc/core/src/encoding";
@ -186,6 +186,19 @@ export class StripeProvisioner extends BasicProvisioner {
await super.accountDeleted(params);
}
async orgDeleted(org: OrgInfo): Promise<void> {
await super.orgDeleted(org);
const account = await this.storage.get(Account, org.owner);
const provisioning = await this.getProvisioning(account);
const { tier } = this._getSubscriptionInfo(provisioning.account.metaData.customer);
if ([Tier.Business, Tier.Team, Tier.Family].includes(tier)) {
await this._setTier(provisioning, Tier.Premium);
await this._syncBilling(provisioning);
}
}
async getProvisioning(opts: { email: string; accountId?: string | undefined }) {
const provisioning = await super.getProvisioning(opts);
if (
@ -194,12 +207,51 @@ export class StripeProvisioner extends BasicProvisioner {
!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);
}
async orgOwnerChanged(
org: OrgInfo,
prevOwner: { email: string; id: string },
newOwner: { email: string; id: string }
): Promise<void> {
await super.orgOwnerChanged(org, prevOwner, newOwner);
const [prevOwnerProv, newOwnerProv] = await Promise.all([
this.getProvisioning(prevOwner),
this.getProvisioning(newOwner),
]);
const { tier } = this._getSubscriptionInfo(prevOwnerProv.account.metaData.customer);
if ([Tier.Business, Tier.Team, Tier.Family].includes(tier)) {
await this._setTier(prevOwnerProv, Tier.Premium);
}
await Promise.all([this._syncBilling(prevOwnerProv), this._syncBilling(newOwnerProv)]);
}
private async _setTier(provisioning: Provisioning, tier: Tier): Promise<void> {
const { subscription, price } = this._getSubscriptionInfo(provisioning.account.metaData.customer);
if (subscription) {
const premium = this._getProduct(tier)!;
const newPrice = price?.recurring?.interval === "month" ? premium.priceMonthly : premium.priceAnnual;
await this._stripe.subscriptions.update(subscription.id, {
cancel_at_period_end: false,
proration_behavior: "create_prorations",
items: [
{
id: subscription.items.data[0].id,
price: newPrice!.id,
},
],
});
}
}
private _getProduct(tier: Tier) {
return [...this._products.values()].find((entry) => entry.tier === tier);
}
@ -265,8 +317,6 @@ export class StripeProvisioner extends BasicProvisioner {
// Create a new customer
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),
@ -1250,7 +1300,6 @@ export class StripeProvisioner extends BasicProvisioner {
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,
@ -1265,8 +1314,6 @@ export class StripeProvisioner extends BasicProvisioner {
return;
}
console.log("handle stripe event", event.type);
let customer: Stripe.Customer | Stripe.DeletedCustomer | undefined = undefined;
switch (event.type) {
@ -1281,13 +1328,6 @@ export class StripeProvisioner extends BasicProvisioner {
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,