Skip to content

Commit

Permalink
Warn Pro users if no members (#530)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinejaussoin authored May 21, 2023
1 parent 6005659 commit 2ef0dd2
Show file tree
Hide file tree
Showing 27 changed files with 349 additions and 65 deletions.
2 changes: 1 addition & 1 deletion backend/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,12 @@ export interface FullUser extends User {
canDeleteSession: boolean;
stripeId: string | null;
pro: boolean;
subscriptionsId: string | null;
currency: Currency | null;
plan: Plan | null;
planOwner: string | null;
planOwnerEmail: string | null;
planAdmins: string[] | null;
planMembers: string[] | null;
domain: string | null;
ownPlan: Plan | null;
ownSubscriptionsId: string | null;
Expand Down
12 changes: 6 additions & 6 deletions backend/src/db/entities/UserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AccountType, FullUser, Currency, Plan } from '../../common/index.js';

@ViewEntity({
expression: `
select
select
u.id,
i.id as identity_id,
u.name,
Expand All @@ -18,13 +18,13 @@ import { AccountType, FullUser, Currency, Plan } from '../../common/index.js';
u.trial,
s1.id as "own_subscriptions_id",
s1.plan as "own_plan",
coalesce(s1.id, s2.id, s3.id) as "subscriptions_id",
coalesce(s1.active, s2.active, s3.active, false) as "pro", /* s4 should not be taken into account for Pro */
coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as "plan",
coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as "domain",
coalesce(o1.name, o2.name, o3.name, o4.name) as "plan_owner",
coalesce(o1.email, o2.email, o3.email, o4.email) as "plan_owner_email",
coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as "plan_admins"
coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as "plan_admins",
coalesce(s1.members, s2.members, s3.members, s4.members) as "plan_members"
from users_identities i
join users u on u.id = i.user_id
Expand Down Expand Up @@ -70,7 +70,7 @@ export default class UserView {
@ViewColumn()
public planAdmins: string[] | null;
@ViewColumn()
public subscriptionsId: string | null;
public planMembers: string[] | null;
@ViewColumn()
public plan: Plan | null;
@ViewColumn()
Expand All @@ -91,7 +91,6 @@ export default class UserView {
this.username = null;
this.photo = null;
this.stripeId = null;
this.subscriptionsId = null;
this.pro = false;
this.email = null;
this.canDeleteSession = false;
Expand All @@ -100,6 +99,7 @@ export default class UserView {
this.planOwner = null;
this.planOwnerEmail = null;
this.planAdmins = null;
this.planMembers = null;
this.ownSubscriptionsId = null;
this.plan = null;
this.domain = null;
Expand All @@ -115,7 +115,6 @@ export default class UserView {
email: this.email,
canDeleteSession: this.canDeleteSession,
pro: this.pro,
subscriptionsId: this.subscriptionsId,
accountType: this.accountType,
language: this.language,
username: this.username,
Expand All @@ -125,6 +124,7 @@ export default class UserView {
planOwner: this.planOwner,
planOwnerEmail: this.planOwnerEmail,
planAdmins: this.planAdmins,
planMembers: this.planMembers,
domain: this.domain,
ownPlan: this.ownPlan,
ownSubscriptionsId: this.ownSubscriptionsId,
Expand Down
87 changes: 87 additions & 0 deletions backend/src/db/migrations/1684682842078-AddMembersToView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddMembersToView1684682842078 implements MigrationInterface {
name = 'AddMembersToView1684682842078'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, ["VIEW","user_view","public"]);
await queryRunner.query(`DROP VIEW "user_view"`);
await queryRunner.query(`CREATE VIEW "user_view" AS
select
u.id,
i.id as identity_id,
u.name,
i.account_type,
i.username,
u.currency,
u.stripe_id,
i.photo,
u.language,
u.email,
case when i.account_type = 'anonymous' and i.password is null then false else true end as "can_delete_session",
u.trial,
s1.id as "own_subscriptions_id",
s1.plan as "own_plan",
coalesce(s1.id, s2.id, s3.id) as "subscriptions_id",
coalesce(s1.active, s2.active, s3.active, false) as "pro", /* s4 should not be taken into account for Pro */
coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as "plan",
coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as "domain",
coalesce(o1.name, o2.name, o3.name, o4.name) as "plan_owner",
coalesce(o1.email, o2.email, o3.email, o4.email) as "plan_owner_email",
coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as "plan_admins",
coalesce(s1.members, s2.members, s3.members, s4.members) as "plan_members"
from users_identities i
join users u on u.id = i.user_id
left join subscriptions s1 on s1.owner_id = u.id and s1.active is true
left join users o1 on o1.id = s1.owner_id
left join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true
left join users o2 on o2.id = s2.owner_id
left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true
left join users o3 on o3.id = s3.owner_id
left join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true
left join users o4 on o4.id = s4.owner_id
`);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, ["public","VIEW","user_view","select \n u.id,\n i.id as identity_id,\n u.name,\n i.account_type,\n i.username,\n u.currency,\n u.stripe_id,\n i.photo,\n u.language,\n u.email,\n case when i.account_type = 'anonymous' and i.password is null then false else true end as \"can_delete_session\",\n u.trial,\n s1.id as \"own_subscriptions_id\",\n s1.plan as \"own_plan\",\n coalesce(s1.id, s2.id, s3.id) as \"subscriptions_id\",\n coalesce(s1.active, s2.active, s3.active, false) as \"pro\", /* s4 should not be taken into account for Pro */\n coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as \"domain\",\n coalesce(o1.name, o2.name, o3.name, o4.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o3.email, o4.email) as \"plan_owner_email\",\n coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as \"plan_admins\",\n coalesce(s1.members, s2.members, s3.members, s4.members) as \"plan_members\"\nfrom users_identities i\n\njoin users u on u.id = i.user_id\nleft join subscriptions s1 on s1.owner_id = u.id and s1.active is true\nleft join users o1 on o1.id = s1.owner_id\nleft join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true\nleft join users o2 on o2.id = s2.owner_id\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true\nleft join users o3 on o3.id = s3.owner_id\nleft join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true\nleft join users o4 on o4.id = s4.owner_id"]);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, ["VIEW","user_view","public"]);
await queryRunner.query(`DROP VIEW "user_view"`);
await queryRunner.query(`CREATE VIEW "user_view" AS select
u.id,
i.id as identity_id,
u.name,
i.account_type,
i.username,
u.currency,
u.stripe_id,
i.photo,
u.language,
u.email,
case when i.account_type = 'anonymous' and i.password is null then false else true end as "can_delete_session",
u.trial,
s1.id as "own_subscriptions_id",
s1.plan as "own_plan",
coalesce(s1.id, s2.id, s3.id) as "subscriptions_id",
coalesce(s1.active, s2.active, s3.active, false) as "pro", /* s4 should not be taken into account for Pro */
coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as "plan",
coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as "domain",
coalesce(o1.name, o2.name, o3.name, o4.name) as "plan_owner",
coalesce(o1.email, o2.email, o3.email, o4.email) as "plan_owner_email",
coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as "plan_admins"
from users_identities i
join users u on u.id = i.user_id
left join subscriptions s1 on s1.owner_id = u.id and s1.active is true
left join users o1 on o1.id = s1.owner_id
left join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true
left join users o2 on o2.id = s2.owner_id
left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true
left join users o3 on o3.id = s3.owner_id
left join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true
left join users o4 on o4.id = s4.owner_id`);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, ["public","VIEW","user_view","select \n u.id,\n i.id as identity_id,\n u.name,\n i.account_type,\n i.username,\n u.currency,\n u.stripe_id,\n i.photo,\n u.language,\n u.email,\n case when i.account_type = 'anonymous' and i.password is null then false else true end as \"can_delete_session\",\n u.trial,\n s1.id as \"own_subscriptions_id\",\n s1.plan as \"own_plan\",\n coalesce(s1.id, s2.id, s3.id) as \"subscriptions_id\",\n coalesce(s1.active, s2.active, s3.active, false) as \"pro\", /* s4 should not be taken into account for Pro */\n coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as \"domain\",\n coalesce(o1.name, o2.name, o3.name, o4.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o3.email, o4.email) as \"plan_owner_email\",\n coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as \"plan_admins\"\nfrom users_identities i\n\njoin users u on u.id = i.user_id\nleft join subscriptions s1 on s1.owner_id = u.id and s1.active is true\nleft join users o1 on o1.id = s1.owner_id\nleft join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true\nleft join users o2 on o2.id = s2.owner_id\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true\nleft join users o3 on o3.id = s3.owner_id\nleft join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true\nleft join users o4 on o4.id = s4.owner_id"]);
}

}
87 changes: 87 additions & 0 deletions backend/src/db/migrations/1684684514088-DontLeakSubId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class DontLeakSubId1684684514088 implements MigrationInterface {
name = 'DontLeakSubId1684684514088'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, ["VIEW","user_view","public"]);
await queryRunner.query(`DROP VIEW "user_view"`);
await queryRunner.query(`CREATE VIEW "user_view" AS
select
u.id,
i.id as identity_id,
u.name,
i.account_type,
i.username,
u.currency,
u.stripe_id,
i.photo,
u.language,
u.email,
case when i.account_type = 'anonymous' and i.password is null then false else true end as "can_delete_session",
u.trial,
s1.id as "own_subscriptions_id",
s1.plan as "own_plan",
coalesce(s1.active, s2.active, s3.active, false) as "pro", /* s4 should not be taken into account for Pro */
coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as "plan",
coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as "domain",
coalesce(o1.name, o2.name, o3.name, o4.name) as "plan_owner",
coalesce(o1.email, o2.email, o3.email, o4.email) as "plan_owner_email",
coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as "plan_admins",
coalesce(s1.members, s2.members, s3.members, s4.members) as "plan_members"
from users_identities i
join users u on u.id = i.user_id
left join subscriptions s1 on s1.owner_id = u.id and s1.active is true
left join users o1 on o1.id = s1.owner_id
left join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true
left join users o2 on o2.id = s2.owner_id
left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true
left join users o3 on o3.id = s3.owner_id
left join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true
left join users o4 on o4.id = s4.owner_id
`);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, ["public","VIEW","user_view","select \n u.id,\n i.id as identity_id,\n u.name,\n i.account_type,\n i.username,\n u.currency,\n u.stripe_id,\n i.photo,\n u.language,\n u.email,\n case when i.account_type = 'anonymous' and i.password is null then false else true end as \"can_delete_session\",\n u.trial,\n s1.id as \"own_subscriptions_id\",\n s1.plan as \"own_plan\",\n coalesce(s1.active, s2.active, s3.active, false) as \"pro\", /* s4 should not be taken into account for Pro */\n coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as \"domain\",\n coalesce(o1.name, o2.name, o3.name, o4.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o3.email, o4.email) as \"plan_owner_email\",\n coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as \"plan_admins\",\n coalesce(s1.members, s2.members, s3.members, s4.members) as \"plan_members\"\nfrom users_identities i\n\njoin users u on u.id = i.user_id\nleft join subscriptions s1 on s1.owner_id = u.id and s1.active is true\nleft join users o1 on o1.id = s1.owner_id\nleft join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true\nleft join users o2 on o2.id = s2.owner_id\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true\nleft join users o3 on o3.id = s3.owner_id\nleft join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true\nleft join users o4 on o4.id = s4.owner_id"]);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, ["VIEW","user_view","public"]);
await queryRunner.query(`DROP VIEW "user_view"`);
await queryRunner.query(`CREATE VIEW "user_view" AS select
u.id,
i.id as identity_id,
u.name,
i.account_type,
i.username,
u.currency,
u.stripe_id,
i.photo,
u.language,
u.email,
case when i.account_type = 'anonymous' and i.password is null then false else true end as "can_delete_session",
u.trial,
s1.id as "own_subscriptions_id",
s1.plan as "own_plan",
coalesce(s1.id, s2.id, s3.id) as "subscriptions_id",
coalesce(s1.active, s2.active, s3.active, false) as "pro", /* s4 should not be taken into account for Pro */
coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as "plan",
coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as "domain",
coalesce(o1.name, o2.name, o3.name, o4.name) as "plan_owner",
coalesce(o1.email, o2.email, o3.email, o4.email) as "plan_owner_email",
coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as "plan_admins",
coalesce(s1.members, s2.members, s3.members, s4.members) as "plan_members"
from users_identities i
join users u on u.id = i.user_id
left join subscriptions s1 on s1.owner_id = u.id and s1.active is true
left join users o1 on o1.id = s1.owner_id
left join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true
left join users o2 on o2.id = s2.owner_id
left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true
left join users o3 on o3.id = s3.owner_id
left join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true
left join users o4 on o4.id = s4.owner_id`);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, ["public","VIEW","user_view","select \n u.id,\n i.id as identity_id,\n u.name,\n i.account_type,\n i.username,\n u.currency,\n u.stripe_id,\n i.photo,\n u.language,\n u.email,\n case when i.account_type = 'anonymous' and i.password is null then false else true end as \"can_delete_session\",\n u.trial,\n s1.id as \"own_subscriptions_id\",\n s1.plan as \"own_plan\",\n coalesce(s1.id, s2.id, s3.id) as \"subscriptions_id\",\n coalesce(s1.active, s2.active, s3.active, false) as \"pro\", /* s4 should not be taken into account for Pro */\n coalesce(s1.plan, s2.plan, s3.plan, s4.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain, s4.domain) as \"domain\",\n coalesce(o1.name, o2.name, o3.name, o4.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o3.email, o4.email) as \"plan_owner_email\",\n coalesce(s1.admins, s2.admins, s3.admins, s4.admins) as \"plan_admins\",\n coalesce(s1.members, s2.members, s3.members, s4.members) as \"plan_members\"\nfrom users_identities i\n\njoin users u on u.id = i.user_id\nleft join subscriptions s1 on s1.owner_id = u.id and s1.active is true\nleft join users o1 on o1.id = s1.owner_id\nleft join subscriptions s2 on s2.members @> ARRAY[u.email::text] and s2.active is true\nleft join users o2 on o2.id = s2.owner_id\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true\nleft join users o3 on o3.id = s3.owner_id\nleft join subscriptions s4 on s4.admins @> ARRAY[u.email::text] and s4.active is true\nleft join users o4 on o4.id = s4.owner_id"]);
}

}
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@
"lint": "eslint 'src/**/*.{ts,tsx}' --max-warnings=0",
"test": "vitest",
"ci-test": "CI=true yarn test",
"analyze": "source-map-explorer build/static/js/*"
"analyze": "source-map-explorer build/static/js/*",
"watch": "tsc --watch --noEmit"
},
"eslintConfig": {
"extends": "react-app"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,12 @@ export interface FullUser extends User {
canDeleteSession: boolean;
stripeId: string | null;
pro: boolean;
subscriptionsId: string | null;
currency: Currency | null;
plan: Plan | null;
planOwner: string | null;
planOwnerEmail: string | null;
planAdmins: string[] | null;
planMembers: string[] | null;
domain: string | null;
ownPlan: Plan | null;
ownSubscriptionsId: string | null;
Expand Down
38 changes: 32 additions & 6 deletions frontend/src/components/ClosableAlert.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,56 @@
import { Alert, AlertColor, AlertTitle } from '@mui/material';
import { useState } from 'react';
import { useState, useCallback } from 'react';

type ClosableAlertProps = {
title?: React.ReactNode;
children: React.ReactNode;
severity: AlertColor;
closable?: boolean;
id?: string;
persisted?: boolean;
};

export default function ClosableAlert({
title,
children,
severity,
closable,
id,
persisted,
}: ClosableAlertProps) {
const [open, setOpen] = useState(true);
const [open, setOpen] = useState(loadInitial(id));

const handleClose = useCallback(() => {
if (!closable) return;
setOpen(false);
if (persisted && id) {
localStorage.setItem(computeKey(id), 'done');
}
}, [id, persisted, closable]);

if (persisted && !id) {
throw Error('ClosableAlert with persisted=true must have an id');
}

if (!open) return null;

return (
<Alert
severity={severity}
onClose={closable ? () => setOpen(false) : undefined}
>
<Alert severity={severity} onClose={handleClose}>
<AlertTitle>{title}</AlertTitle>
{children}
</Alert>
);
}

function computeKey(id: string) {
return 'closable-alert-' + id;
}

function loadInitial(id?: string) {
if (!id) return true;
const initial = localStorage.getItem(computeKey(id));
if (initial) {
return initial !== 'done';
}
return true;
}
2 changes: 1 addition & 1 deletion frontend/src/testing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ const user: FullUser = {
email: '[email protected]',
pro: false,
stripeId: null,
subscriptionsId: null,
currency: null,
plan: null,
planOwner: null,
planOwnerEmail: null,
planAdmins: null,
planMembers: null,
domain: null,
ownPlan: null,
ownSubscriptionsId: null,
Expand Down
Loading

0 comments on commit 2ef0dd2

Please sign in to comment.