diff --git a/backend/src/common/types.ts b/backend/src/common/types.ts index a21cab1b8..bea1a304a 100644 --- a/backend/src/common/types.ts +++ b/backend/src/common/types.ts @@ -138,6 +138,9 @@ export interface FullUser extends User { subscriptionsId: string | null; currency: Currency | null; plan: Plan | null; + planOwner: string | null; + planOwnerEmail: string | null; + planAdmins: string[] | null; domain: string | null; ownPlan: Plan | null; ownSubscriptionsId: string | null; diff --git a/backend/src/db/actions/subscriptions.ts b/backend/src/db/actions/subscriptions.ts index e886a631c..304a8d36f 100644 --- a/backend/src/db/actions/subscriptions.ts +++ b/backend/src/db/actions/subscriptions.ts @@ -2,6 +2,7 @@ import { SubscriptionRepository, UserRepository } from '../repositories'; import { Plan, Currency } from '../../common'; import { SubscriptionEntity, UserEntity, UserView } from '../entities'; import { transaction } from './transaction'; +import { In } from 'typeorm'; export async function activateSubscription( userId: string, @@ -50,7 +51,7 @@ export async function cancelSubscription( }); } -export async function getActiveSubscription( +export async function getActiveSubscriptionWhereUserIsOwner( userId: string ): Promise { return await transaction(async (manager) => { @@ -75,6 +76,36 @@ export async function getActiveSubscription( }); } +export async function getActiveSubscriptionWhereUserIsAdmin( + userId: string, + email: string | null +): Promise { + return await transaction(async (manager) => { + const subscriptionRepository = manager.getCustomRepository( + SubscriptionRepository + ); + + const ids = await subscriptionRepository.query( + ` +select s.id from subscriptions s +where s.active = true +and (s.owner_id = $1 or s.admins @> $2) +order by s.updated desc + `, + [userId, `{${email}}`] + ); + + const subscriptions = await subscriptionRepository.find({ + where: { id: In(ids.map((id: { id: string }) => id.id)) }, + }); + + if (subscriptions.length === 0) { + return null; + } + return subscriptions[0]; + }); +} + export async function saveSubscription( subscription: SubscriptionEntity ): Promise { diff --git a/backend/src/db/entities/Subscription.ts b/backend/src/db/entities/Subscription.ts index c700ea08e..070330f52 100644 --- a/backend/src/db/entities/Subscription.ts +++ b/backend/src/db/entities/Subscription.ts @@ -25,6 +25,8 @@ export default class SubscriptionEntity { public domain: string | null; @Column('text', { array: true, default: '{}' }) public members: string[]; + @Column('text', { array: true, default: '{}' }) + public admins: string[]; @CreateDateColumn({ type: 'timestamp with time zone', select: false }) public created: Date | undefined; @UpdateDateColumn({ type: 'timestamp with time zone', select: false }) @@ -36,5 +38,6 @@ export default class SubscriptionEntity { this.plan = plan; this.domain = null; this.members = []; + this.admins = []; } } diff --git a/backend/src/db/entities/UserView.ts b/backend/src/db/entities/UserView.ts index 651d4bd71..d10cec1d6 100644 --- a/backend/src/db/entities/UserView.ts +++ b/backend/src/db/entities/UserView.ts @@ -3,7 +3,7 @@ import { AccountType, FullUser, Currency, Plan } from '../../common'; @ViewEntity({ expression: ` -select + select u.id, i.id as identity_id, u.name, @@ -16,18 +16,26 @@ select u.email, case when i.account_type = 'anonymous' and i.password is null then false else true end as "can_delete_session", u.trial, - s.id as "own_subscriptions_id", - s.plan as "own_plan", - coalesce(s.id, s2.id, s3.id) as "subscriptions_id", - coalesce(s.active, s2.active, s3.active, false) as "pro", - coalesce(s.plan, s2.plan, s3.plan) as "plan", - coalesce(s.domain, s2.domain, s3.domain) as "domain" + 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 s on s.owner_id = u.id and s.active is true -left join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true +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 `, }) export default class UserView { @@ -56,6 +64,12 @@ export default class UserView { @ViewColumn() public ownPlan: Plan | null; @ViewColumn() + public planOwner: string | null; + @ViewColumn() + public planOwnerEmail: string | null; + @ViewColumn() + public planAdmins: string[] | null; + @ViewColumn() public subscriptionsId: string | null; @ViewColumn() public plan: Plan | null; @@ -83,6 +97,9 @@ export default class UserView { this.canDeleteSession = false; this.currency = null; this.ownPlan = null; + this.planOwner = null; + this.planOwnerEmail = null; + this.planAdmins = null; this.ownSubscriptionsId = null; this.plan = null; this.domain = null; @@ -105,6 +122,9 @@ export default class UserView { stripeId: this.stripeId, currency: this.currency, plan: this.plan, + planOwner: this.planOwner, + planOwnerEmail: this.planOwnerEmail, + planAdmins: this.planAdmins, domain: this.domain, ownPlan: this.ownPlan, ownSubscriptionsId: this.ownSubscriptionsId, diff --git a/backend/src/db/migrations/1669661894373-PlanOwners.ts b/backend/src/db/migrations/1669661894373-PlanOwners.ts new file mode 100644 index 000000000..e8f7c15c1 --- /dev/null +++ b/backend/src/db/migrations/1669661894373-PlanOwners.ts @@ -0,0 +1,75 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class PlanOwner1669661894373 implements MigrationInterface { + name = 'planOwner1669661894373' + + public async up(queryRunner: QueryRunner): Promise { + 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", + coalesce(s1.plan, s2.plan, s3.plan) as "plan", + coalesce(s1.domain, s2.domain, s3.domain) as "domain", + coalesce(o1.name, o2.name, o2.name) as "plan_owner", + coalesce(o1.email, o2.email, o2.email) as "plan_owner_email" +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 lower(u.email) = any(lower(s2.members::text)::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 + `); + 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\",\n coalesce(s1.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain) as \"domain\",\n coalesce(o1.name, o2.name, o2.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o2.email) as \"plan_owner_email\"\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 lower(u.email) = any(lower(s2.members::text)::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"]); + } + + public async down(queryRunner: QueryRunner): Promise { + 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, + s.id as "own_subscriptions_id", + s.plan as "own_plan", + coalesce(s.id, s2.id, s3.id) as "subscriptions_id", + coalesce(s.active, s2.active, s3.active, false) as "pro", + coalesce(s.plan, s2.plan, s3.plan) as "plan", + coalesce(s.domain, s2.domain, s3.domain) as "domain" +from users_identities i + +join users u on u.id = i.user_id +left join subscriptions s on s.owner_id = u.id and s.active is true +left join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true +left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true`); + 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 s.id as \"own_subscriptions_id\",\n s.plan as \"own_plan\",\n coalesce(s.id, s2.id, s3.id) as \"subscriptions_id\",\n coalesce(s.active, s2.active, s3.active, false) as \"pro\",\n coalesce(s.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s.domain, s2.domain, s3.domain) as \"domain\"\nfrom users_identities i\n\njoin users u on u.id = i.user_id\nleft join subscriptions s on s.owner_id = u.id and s.active is true\nleft join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true"]); + } + +} diff --git a/backend/src/db/migrations/1669662836558-SubscriptionAdmins.ts b/backend/src/db/migrations/1669662836558-SubscriptionAdmins.ts new file mode 100644 index 000000000..204df95dd --- /dev/null +++ b/backend/src/db/migrations/1669662836558-SubscriptionAdmins.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SubscriptionAdmins1669662836558 implements MigrationInterface { + name = 'SubscriptionAdmins1669662836558'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "subscriptions" ADD "admins" text array NOT NULL DEFAULT '{}'` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "subscriptions" DROP COLUMN "admins"`); + } +} diff --git a/backend/src/db/migrations/1669663848592-AdminsView.ts b/backend/src/db/migrations/1669663848592-AdminsView.ts new file mode 100644 index 000000000..728233697 --- /dev/null +++ b/backend/src/db/migrations/1669663848592-AdminsView.ts @@ -0,0 +1,81 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class AdminsView1669663848592 implements MigrationInterface { + name = 'AdminsView1669663848592' + + public async up(queryRunner: QueryRunner): Promise { + 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", + coalesce(s1.plan, s2.plan, s3.plan) as "plan", + coalesce(s1.domain, s2.domain, s3.domain) as "domain", + coalesce(o1.name, o2.name, o3.name) as "plan_owner", + coalesce(o1.email, o2.email, o3.email) as "plan_owner_email", + coalesce(s1.admins, s2.admins, s3.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 lower(u.email) = any(lower(s2.members::text)::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 + `); + 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\",\n coalesce(s1.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain) as \"domain\",\n coalesce(o1.name, o2.name, o3.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o3.email) as \"plan_owner_email\",\n coalesce(s1.admins, s2.admins, s3.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 lower(u.email) = any(lower(s2.members::text)::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"]); + } + + public async down(queryRunner: QueryRunner): Promise { + 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", + coalesce(s1.plan, s2.plan, s3.plan) as "plan", + coalesce(s1.domain, s2.domain, s3.domain) as "domain", + coalesce(o1.name, o2.name, o2.name) as "plan_owner", + coalesce(o1.email, o2.email, o2.email) as "plan_owner_email" +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 lower(u.email) = any(lower(s2.members::text)::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`); + 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\",\n coalesce(s1.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain) as \"domain\",\n coalesce(o1.name, o2.name, o2.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o2.email) as \"plan_owner_email\"\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 lower(u.email) = any(lower(s2.members::text)::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"]); + } + +} diff --git a/backend/src/db/migrations/1669721743363-AddAdminsToUserView.ts b/backend/src/db/migrations/1669721743363-AddAdminsToUserView.ts new file mode 100644 index 000000000..02c0ffc3a --- /dev/null +++ b/backend/src/db/migrations/1669721743363-AddAdminsToUserView.ts @@ -0,0 +1,84 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class AddAdminsToUserView1669721743363 implements MigrationInterface { + name = 'AddAdminsToUserView1669721743363' + + public async up(queryRunner: QueryRunner): Promise { + 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, s4.active, false) as "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, s4.active, false) as \"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"]); + } + + public async down(queryRunner: QueryRunner): Promise { + 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", + coalesce(s1.plan, s2.plan, s3.plan) as "plan", + coalesce(s1.domain, s2.domain, s3.domain) as "domain", + coalesce(o1.name, o2.name, o3.name) as "plan_owner", + coalesce(o1.email, o2.email, o3.email) as "plan_owner_email", + coalesce(s1.admins, s2.admins, s3.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 lower(u.email) = any(lower(s2.members::text)::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`); + 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\",\n coalesce(s1.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s1.domain, s2.domain, s3.domain) as \"domain\",\n coalesce(o1.name, o2.name, o3.name) as \"plan_owner\",\n coalesce(o1.email, o2.email, o3.email) as \"plan_owner_email\",\n coalesce(s1.admins, s2.admins, s3.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 lower(u.email) = any(lower(s2.members::text)::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"]); + } + +} diff --git a/backend/src/db/migrations/1669722435110-AddAdminsToUserView2.ts b/backend/src/db/migrations/1669722435110-AddAdminsToUserView2.ts new file mode 100644 index 000000000..29b537508 --- /dev/null +++ b/backend/src/db/migrations/1669722435110-AddAdminsToUserView2.ts @@ -0,0 +1,86 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class AddAdminsToUserView21669722435110 implements MigrationInterface { + name = 'AddAdminsToUserView21669722435110' + + public async up(queryRunner: QueryRunner): Promise { + 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"]); + } + + public async down(queryRunner: QueryRunner): Promise { + 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, s4.active, false) as "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, s4.active, false) as \"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"]); + } + +} diff --git a/backend/src/stripe/router.ts b/backend/src/stripe/router.ts index ee7ed0566..3981ac396 100644 --- a/backend/src/stripe/router.ts +++ b/backend/src/stripe/router.ts @@ -16,9 +16,10 @@ import isValidDomain from '../security/is-valid-domain'; import { cancelSubscription, activateSubscription, - getActiveSubscription, + getActiveSubscriptionWhereUserIsOwner, saveSubscription, startTrial, + getActiveSubscriptionWhereUserIsAdmin, } from '../db/actions/subscriptions'; const stripe = new Stripe(config.STRIPE_SECRET, { @@ -248,9 +249,13 @@ function stripeRouter(): Router { }); router.get('/members', async (req, res) => { + // possibly move this const identity = await getIdentityFromRequest(req); if (identity) { - const subscription = await getActiveSubscription(identity.user.id); + const subscription = await getActiveSubscriptionWhereUserIsAdmin( + identity.user.id, + identity.user.email + ); if (subscription && subscription.plan === 'team') { return res.status(200).send(subscription.members); } @@ -259,9 +264,13 @@ function stripeRouter(): Router { }); router.patch('/members', async (req, res) => { + // possibly move this const identity = await getIdentityFromRequest(req); if (identity) { - const subscription = await getActiveSubscription(identity.user.id); + const subscription = await getActiveSubscriptionWhereUserIsAdmin( + identity.user.id, + identity.user.email + ); if (subscription && subscription.plan === 'team') { subscription.members = req.body as string[]; await saveSubscription(subscription); @@ -271,6 +280,22 @@ function stripeRouter(): Router { res.status(401).send(); }); + router.patch('/admins', async (req, res) => { + // possibly move this + const identity = await getIdentityFromRequest(req); + if (identity) { + const subscription = await getActiveSubscriptionWhereUserIsOwner( + identity.user.id + ); + if (subscription && subscription.plan === 'team') { + subscription.admins = req.body as string[]; + await saveSubscription(subscription); + return res.status(200).send(); + } + } + res.status(401).send(); + }); + router.get('/domain/:domain', async (req, res) => { const domain = req.params.domain; return res.status(200).send(isValidDomain(domain)); diff --git a/frontend/src/common/types.ts b/frontend/src/common/types.ts index a21cab1b8..bea1a304a 100644 --- a/frontend/src/common/types.ts +++ b/frontend/src/common/types.ts @@ -138,6 +138,9 @@ export interface FullUser extends User { subscriptionsId: string | null; currency: Currency | null; plan: Plan | null; + planOwner: string | null; + planOwnerEmail: string | null; + planAdmins: string[] | null; domain: string | null; ownPlan: Plan | null; ownSubscriptionsId: string | null; diff --git a/frontend/src/components/TagInput/Tag.tsx b/frontend/src/components/TagInput/Tag.tsx index 0d33a019a..95902a4a1 100644 --- a/frontend/src/components/TagInput/Tag.tsx +++ b/frontend/src/components/TagInput/Tag.tsx @@ -2,9 +2,14 @@ import { Chip } from '@mui/material'; type TagProps = { value: string; - onDelete: (value: string) => void; + onDelete?: (value: string) => void; }; export default function Tag({ value, onDelete }: TagProps) { - return onDelete(value)} />; + return ( + onDelete(value) : undefined} + /> + ); } diff --git a/frontend/src/testing/index.tsx b/frontend/src/testing/index.tsx index e418a2cef..3d2003037 100644 --- a/frontend/src/testing/index.tsx +++ b/frontend/src/testing/index.tsx @@ -54,6 +54,9 @@ export default function Inner({ children }: PropsWithChildren<{}>) { subscriptionsId: null, currency: null, plan: null, + planOwner: null, + planOwnerEmail: null, + planAdmins: null, domain: null, ownPlan: null, ownSubscriptionsId: null, @@ -76,6 +79,9 @@ export default function Inner({ children }: PropsWithChildren<{}>) { subscriptionsId: null, currency: null, plan: null, + planOwner: null, + planOwnerEmail: null, + planAdmins: null, domain: null, ownPlan: null, ownSubscriptionsId: null, diff --git a/frontend/src/translations/locales/ar-SA.json b/frontend/src/translations/locales/ar-SA.json index ef1a51655..07f3a1966 100644 --- a/frontend/src/translations/locales/ar-SA.json +++ b/frontend/src/translations/locales/ar-SA.json @@ -284,8 +284,11 @@ "plan": { "header": "خطتك", "plan": "خطة", + "admins": "المدراء", "youAreOwner": "أنت صاحب هذه الخطة، من خلال الاشتراك أدناه.", - "youAreMember": "أنت على هذه الخطة من خلال اشتراك شخص آخر." + "youAreMember": "أنت على هذه الخطة من خلال اشتراك شخص آخر.", + "ownedBy": "تملكها من قبل", + "notPro": "أنت لست مستخدم برو ، على الرغم من كونك مسؤول للخطة. أضف نفسك إلى قائمة حسابات برو في القسم أدناه." }, "subscription": { "header": "اشتراكك", @@ -294,6 +297,12 @@ "title": "فريقك", "limitReached": "لقد وصلت إلى الحد الأقصى للاشتراك الخاص بك (المستخدمون{{limit}} ، بما في ذلك نفسك). الرجاء إزالة الأعضاء، أو الترقية إلى حساب شركة غير محدود.", "info": "إضافة رسائل البريد الإلكتروني أدناه لمنح حسابات Pro حتى {{limit}} من الزملاء الآخرين. اضغط Enter بعد كل عنوان بريد إلكتروني." + }, + "adminsEditor": { + "added": "{{email}} هو الآن المسؤول عن هذا الاشتراك.", + "removed": "{{email}} لم يعد مدير هذا الاشتراك.", + "title": "المدراء", + "description": "يمكن للمسؤولين إضافة مستخدمين إلى أو إزالة المستخدمين من الاشتراك. لا يتم منح المسؤولين تلقائياً حسابات الاحتراف." } }, "trial": { diff --git a/frontend/src/translations/locales/de-DE.json b/frontend/src/translations/locales/de-DE.json index ee0769644..bb814a6df 100644 --- a/frontend/src/translations/locales/de-DE.json +++ b/frontend/src/translations/locales/de-DE.json @@ -62,7 +62,7 @@ "votingCategory": "Abstimmung", "votingCategorySub": "Setze die Abstimmregeln", "postCategory": "Beitragseinstellungen", - "postCategorySub": "Stellen Sie ein, wie Nutzer mit Beiträgen interagieren können", + "postCategorySub": "Stelle ein, wie Nutzer mit Beiträgen interagieren können", "customTemplateCategory": "Spaltenkonfiguration", "customTemplateCategorySub": "Setzen Sie die Anzahl an Spalten und deren Eigenschaften", "startButton": "Spiel starten!", @@ -80,7 +80,7 @@ "allowActions": "Erlaube Maßnahmen", "allowActionsHelp": "Bestimmt ob Maßnahmen hinzugefügt werden können", "allowAuthorVisible": "Zeige Autor", - "allowAuthorVisibleHelp": "Zeigt den Autor eines Beitrags an.", + "allowAuthorVisibleHelp": "Zeigt den Autor eines Posts an.", "newPostsFirst": "Neue Beiträge zuerst hinzufügen", "newPostsFirstHelp": "Neue Beiträge werden oben in der Spalte hinzugefügt", "allowGiphy": "Giphy erlauben", @@ -89,7 +89,7 @@ "allowGroupingHelp": "Erstelle Gruppen, um Beiträge zusammenzufassen", "allowReordering": "Neu sortieren erlauben", "allowReorderingHelp": "Erlaubt die Beiträge per Drag-and-Drop neu zu sortieren", - "blurCards": "Karten unkenntlich machen", + "blurCards": "Unscharfe Karten", "blurCardsHelp": "Karteninhalte werden verschwommen dargestellt, bis der Moderator den Inhalt aufdeckt", "template": "Vorlage", "templateHelp": "Nutze ein vordefiniertes Spaltenset", @@ -284,8 +284,11 @@ "plan": { "header": "Ihr Plan", "plan": "Plan", + "admins": "Administratoren", "youAreOwner": "Sie sind der Besitzer dieses Pakets, durch das untenstehende Abonnement.", - "youAreMember": "Du bist auf diesem Plan durch ein anderes Abonnement." + "youAreMember": "Du bist auf diesem Plan durch ein anderes Abonnement.", + "ownedBy": "Besitzt von", + "notPro": "Sie sind kein Pro-Nutzer, obwohl Sie ein Administrator für den Plan sind. Fügen Sie sich in die Liste der Pro-Konten in dem untenstehenden Abschnitt ein." }, "subscription": { "header": "Ihr Abonnement", @@ -294,6 +297,12 @@ "title": "Ihr Team", "limitReached": "Sie haben das Limit Ihres Abonnements erreicht ({{limit}} Benutzer, einschließlich sich selbst). Bitte entfernen Sie Mitglieder, oder upgraden Sie auf ein unbegrenztes Firmenkonto.", "info": "Fügen Sie unten E-Mails hinzu, um bis zu {{limit}} anderen Kollegen Konten zu gewähren. Drücken Sie Enter nach jeder E-Mail-Adresse." + }, + "adminsEditor": { + "added": "{{email}} ist jetzt Administrator dieses Abonnements.", + "removed": "{{email}} ist kein Administrator dieses Abonnements.", + "title": "Administratoren", + "description": "Administratoren können Benutzer hinzufügen oder aus dem Abonnement entfernen. Administratoren werden nicht automatisch Pro Konten gewährt." } }, "trial": { @@ -433,4 +442,4 @@ "Chat": { "writeAMessage": "Hier eine Nachricht schreiben..." } -} +} \ No newline at end of file diff --git a/frontend/src/translations/locales/en-GB.json b/frontend/src/translations/locales/en-GB.json index c601813c3..075a2d08a 100644 --- a/frontend/src/translations/locales/en-GB.json +++ b/frontend/src/translations/locales/en-GB.json @@ -284,8 +284,11 @@ "plan": { "header": "Your Plan", "plan": "Plan", + "admins": "Administrators", "youAreOwner": "You are the owner of this plan, through the subscription below.", - "youAreMember": "You are on this plan through somebody else's subscription." + "youAreMember": "You are on this plan through somebody else's subscription.", + "ownedBy": "Owned By", + "notPro": "You are not a Pro user, despite being an administrator for the plan. Add yourself to the list of Pro accounts in the section below." }, "subscription": { "header": "Your Subscription", @@ -294,6 +297,12 @@ "title": "Your Team", "limitReached": "You reached the limit of your subscription ({{limit}} users, including yourself). Please remove members, or upgrade to an unlimited Company account.", "info": "Add emails below to grant Pro accounts to up to {{limit}} other colleagues. Press Enter after each email address." + }, + "adminsEditor": { + "added": "{{email}} is now an administrator of this subscription.", + "removed": "{{email}} is no longer an administrator of this subscription.", + "title": "Administrators", + "description": "Administrators can add users to or remove users from the subscription. Administrators are not automatically granted Pro accounts." } }, "trial": { diff --git a/frontend/src/translations/locales/es-ES.json b/frontend/src/translations/locales/es-ES.json index de5146bde..d7ee046a8 100644 --- a/frontend/src/translations/locales/es-ES.json +++ b/frontend/src/translations/locales/es-ES.json @@ -284,8 +284,11 @@ "plan": { "header": "Su plan", "plan": "Plano", + "admins": "Administradores", "youAreOwner": "Usted es el propietario de este plan, a través de la suscripción a continuación.", - "youAreMember": "Estás en este plan a través de la suscripción de otra persona." + "youAreMember": "Estás en este plan a través de la suscripción de otra persona.", + "ownedBy": "Poseído por", + "notPro": "Usted no es un usuario Pro, a pesar de ser un administrador del plan. Añádese a la lista de cuentas Pro en la sección de abajo." }, "subscription": { "header": "Tu suscripción", @@ -294,6 +297,12 @@ "title": "Tu equipo", "limitReached": "Has alcanzado el límite de tu suscripción ({{limit}} usuarios, incluido tú mismo). Por favor, elimina miembros o actualiza a una cuenta de empresa ilimitada.", "info": "Añada correos electrónicos a continuación para conceder cuentas Pro hasta {{limit}} otras opciones. Pulse Intro después de cada dirección de correo electrónico." + }, + "adminsEditor": { + "added": "{{email}} es ahora un administrador de esta suscripción.", + "removed": "{{email}} ya no es administrador de esta suscripción.", + "title": "Administradores", + "description": "Los administradores pueden agregar usuarios o eliminar usuarios de la suscripción. A los administradores no se les concede automáticamente cuentas Pro." } }, "trial": { diff --git a/frontend/src/translations/locales/fr-FR.json b/frontend/src/translations/locales/fr-FR.json index 6a245378b..3ec8923eb 100644 --- a/frontend/src/translations/locales/fr-FR.json +++ b/frontend/src/translations/locales/fr-FR.json @@ -20,7 +20,7 @@ }, "Home": { "welcome": "Bienvenue, {{name}}", - "howDoesThatWork": "Comment cela fonctionne-t-il?" + "howDoesThatWork": "Comment cela fonctionne-t-il ?" }, "PreviousGame": { "createdBy": "Créé par", @@ -284,8 +284,11 @@ "plan": { "header": "Votre Accès", "plan": "Accès", + "admins": "Administrateurs", "youAreOwner": "Vous êtes l'administrateur de cet abonnement. Vous pouvez le gérer via la section ci-dessous.", - "youAreMember": "Vous devez votre accès Pro grâce à l'abonnement d'un tiers." + "youAreMember": "Vous devez votre accès Pro grâce à l'abonnement d'un tiers.", + "ownedBy": "Détenu par", + "notPro": "Vous n'êtes pas un utilisateur Pro, bien que vous soyez administrateur du forfait. Ajoutez-vous à la liste des comptes Pro dans la section ci-dessous." }, "subscription": { "header": "Votre Abonnement", @@ -294,6 +297,12 @@ "title": "Votre Équipe", "limitReached": "Vous avez atteint le nombre maximum de membres ({{limit}}) permis par votre abonnement. Vous pouvez passer à l'abonnement Unlimited pour un nombre de collaborateur illimité.", "info": "Ajouter des adresses emails ci-dessous pour donner un accès Pro à vos collaborateurs (dans la limite de {{limit}} collaborateurs). Appuyez sur Entrée après chaque email." + }, + "adminsEditor": { + "added": "{{email}} est maintenant administrateur de cet abonnement.", + "removed": "{{email}} n'est plus administrateur de cet abonnement.", + "title": "Administrateurs", + "description": "Les administrateurs peuvent ajouter ou supprimer des utilisateurs de l'abonnement. Les administrateurs n'obtiennent pas de manière automatique une licence Pro." } }, "trial": { @@ -354,7 +363,7 @@ }, "subscribe": { "title": "Payer", - "description": "Vous serez redirigé vers notre partenaire Stripe. Aucune coordonnées bancaire n'est stockée par Retrospected.", + "description": "Vous serez redirigé vers notre partenaire Stripe. Aucunes coordonnées bancaires ne sont stockées par Retrospected.", "cannotRegisterWithAnon": "Vous devez être connecté avec un compte OAuth ou Classique (mot de passe) avant de continuer.", "checkout": "Payer" } @@ -362,7 +371,7 @@ "SubscribeModal": { "title": "Abonnement Pro", "header": "Retrospected Pro", - "description": "Protégez les données de votre entreprise en vous abonnant à Retrospected Pro. Avec Retrospected Pro, bénéficiez des fonctionalités suivantes:", + "description": "Protégez les données de votre entreprise en vous abonnant à Retrospected Pro. Avec Retrospected Pro, bénéficiez des fonctionnalités suivantes :", "features": { "encryptedSession": { "title": "Session Cryptées", @@ -378,28 +387,28 @@ } }, "subscribeButton": "S'abonner", - "payButton": "Selectionner", + "payButton": "Sélectionner", "cancelButton": "Annuler", "startTrial": "Essai 30 jours" }, "Products": { - "team": "Parfait pour une équipe, vous pouvez sélectioner jusqu'à 20 collègues qui recevront un compte Retrospected Pro.", - "unlimited": "Si vous avez besoin de plus, l'abonnement \"Pro Unlimited\" vous donnera un nombre de compte Pro illimité", - "self-hosted": "Installez Retrospected sur vos serveurs. Gardez le contrôle total de vos données, et profitez des mise-à-jours illimitées.", + "team": "Parfait pour une équipe, vous pouvez sélectionner jusqu'à 20 collègues qui recevront un compte Retrospected Pro.", + "unlimited": "Si vous avez besoin de plus, l'abonnement \"Pro Unlimited\" vous donnera un nombre de comptes Pro illimité.", + "self-hosted": "Installez Retrospected sur vos serveurs. Gardez le contrôle total de vos données et profitez des mises à jour illimitées.", "users": "{{users}} utilisateurs", "unlimited_seats": "Illimité", "month": "mois", "year": "an", - "wantToPayYearly": "Je souhaite payer annuellement, et obtenir un mois gratuit par an !" + "wantToPayYearly": "Je souhaite payer annuellement et obtenir un mois gratuit par an !" }, "Encryption": { "createEncryptedSession": "Session cryptée", - "sessionNotEncrypted": "Cette session n'est pas cryptée", - "sessionEncryptedHaveKeyTooltip": "Cette session est chiffrée, et la clef est stockée dans votre navigateur. Vous pouvez ouvrir cette session sans avoir à en donner le mot de passe.", - "sessionEncryptedNoKeyTooltip": "Cette session est chiffrée, et la clef n'a pas été trouvée dans votre navigateur. Il vous sera demandé de donner le mot de passe lors de l'ouverture de cette session.", - "sessionEncryptedWrongKeyTooltip": "Cette session est chiffrée, et la clef qui est stockée dans votre navigateur n'est pas la bonne.", + "sessionNotEncrypted": "Cette session n'est pas cryptée.", + "sessionEncryptedHaveKeyTooltip": "Cette session est chiffrée et la clef est stockée dans votre navigateur. Vous pouvez ouvrir cette session sans avoir à en donner le mot de passe.", + "sessionEncryptedNoKeyTooltip": "Cette session est chiffrée et la clef n'a pas été trouvée dans votre navigateur. Il vous sera demandé de donner le mot de passe lors de l'ouverture de cette session.", + "sessionEncryptedWrongKeyTooltip": "Cette session est chiffrée et la clef qui est stockée dans votre navigateur n'est pas la bonne.", "newEncryptedSessionWarningTitle": "Cette session est chiffrée localement", - "newEncryptedSessionWarningContent": "Il est très important de sauvegarder l'URL complète de cette page (qui contient la clef de chiffrement). Le cas échéant, vous pouvez également sauvegarder la clef elle-même ({{key}}). Si vous perdez cette clef, il vous sera impossible de récupérer vos données.", + "newEncryptedSessionWarningContent": "Il est très important de sauvegarder l'URL complète de cette page (qui contient la clef de chiffrement). Le cas échéant, vous pouvez également sauvegarder la clef elle-même : {{key}}. Si vous perdez cette clef, il vous sera impossible de récupérer vos données.", "sessionEncryptionError": "Cette session est cryptée, et vous ne semblez pas avoir la clef de chiffrement dans votre navigateur. Merci d'utiliser le lien complet qui vous a été donné lors de la création de cette session.", "passwordModalTitle": "Session chiffrée - Saisie du mot de passe", "passwordModelIncorrect": "Le mot de passe (ou clef de chiffrement) est incorrect." @@ -409,13 +418,13 @@ "unlockSuccessNotification": "La session a été correctement rendue publique.", "lockButton": "Rendre privée", "unlockButton": "Rendre publique", - "lockDescription": "Vous êtes sur le point de privatiser la session. Seuls les utilisateurs ayant déjà accédé à cette session (dont la liste s'affiche ci-dessous) pourront accéder à cette session une fois vérouillée.", + "lockDescription": "Vous êtes sur le point de privatiser la session. Seuls les utilisateurs ayant déjà accédé à cette session (dont la liste s'affiche ci-dessous) pourront accéder à cette session une fois verrouillée.", "cancelButton": "Annuler", "sessionLockedTitle": "Cette session est privée.", "sessionLockedDescription": "Demandez à votre modérateur de la rendre publique pour que vous puissiez la rejoindre. Ensuite, rafraichissez la page.", - "sessionNonProTitle": "Cette session n'est accessible qu'aux utilisateurs Pro.", + "sessionNonProTitle": "Cette session n'est accessible qu'aux utilisateurs Pro", "sessionNonProDescription": "Cette session n'est ouverte qu'aux détenteurs d'un compte Retrospected Pro. Vous pouvez demander au modérateur ou au gérant de l'abonnement Pro de vous donner un accès.", - "sessionIsPublic": "Cette session est publique, et accessible à tout le monde.", + "sessionIsPublic": "Cette session est publique et accessible à tout le monde.", "sessionIsPrivate": "Cette session est privée, et vous y avez accès.", "sessionIsPrivateNoAccess": "Cette session est privée, mais vous n'y avez pas accès." }, @@ -423,12 +432,12 @@ "allowanceReachedTitle": "Vous avez atteint la limite de posts gratuits", "allowanceReachedDescription": "Inscrivez-vous à Retrospected Pro pour passer en illimité", "nearEndAllowanceTitle": "Vous approchez la limite de posts gratuits", - "nearEndAllowanceDescription": "Vous avez environ {{quota}} posts restants.", + "nearEndAllowanceDescription": "Vous avez environ {{quota}} posts restants", "onTrialTitle": "Retrospected Pro - Période d'essai", "remainingTrialSentence": "Vous avez {{remaining}} restant sur votre période d'essai.", "trialEndedTitle": "Votre période d'essai Retrospected Pro est terminée", "trialEndedSentence": "Abonnez-vous aujourd'hui pour continuer à bénéficier des avantages de la version Pro.", - "subscribeNow": "Je m'abonne" + "subscribeNow": "Je m'abonne !" }, "Chat": { "writeAMessage": "Écrivez un message ici..." diff --git a/frontend/src/translations/locales/hu-HU.json b/frontend/src/translations/locales/hu-HU.json index 596b6f9b3..0f82f950b 100644 --- a/frontend/src/translations/locales/hu-HU.json +++ b/frontend/src/translations/locales/hu-HU.json @@ -284,8 +284,11 @@ "plan": { "header": "Az Ön terve", "plan": "Terv", + "admins": "", "youAreOwner": "Az alábbi előfizetésen keresztül Ön ennek a csomagnak a tulajdonosa.", - "youAreMember": "Valaki más előfizetésén keresztül részt vesz ebben a tervben." + "youAreMember": "Valaki más előfizetésén keresztül részt vesz ebben a tervben.", + "ownedBy": "", + "notPro": "" }, "subscription": { "header": "Az Ön előfizetése", @@ -294,6 +297,12 @@ "title": "Csapatod", "limitReached": "Elérted az előfizetésed korlátját ({{limit}} felhasználó, téged is beleértve). Kérjük, távolítsa el a tagokat, vagy frissítsen korlátlan számú vállalati fiókra.", "info": "Adjon hozzá e-mail-címeket alább, hogy Pro-fiókot biztosítson akár {{limit}} másik kollégának. Minden e-mail cím után nyomja meg az Enter billentyűt." + }, + "adminsEditor": { + "added": "", + "removed": "", + "title": "", + "description": "" } }, "trial": { diff --git a/frontend/src/translations/locales/it-IT.json b/frontend/src/translations/locales/it-IT.json index 778eeaaca..2bd0308ea 100644 --- a/frontend/src/translations/locales/it-IT.json +++ b/frontend/src/translations/locales/it-IT.json @@ -284,8 +284,11 @@ "plan": { "header": "Il Tuo Piano", "plan": "Piano", + "admins": "Amministratori", "youAreOwner": "Sei il proprietario di questo piano, tramite l'abbonamento qui sotto.", - "youAreMember": "Sei su questo piano attraverso l'abbonamento di qualcun altro." + "youAreMember": "Sei su questo piano attraverso l'abbonamento di qualcun altro.", + "ownedBy": "Posseduta Da", + "notPro": "Non sei un utente Pro, pur essendo un amministratore per il piano. Aggiungiti alla lista degli account Pro nella sezione sottostante." }, "subscription": { "header": "Il Tuo Abbonamento", @@ -294,6 +297,12 @@ "title": "La Tua Squadra", "limitReached": "Hai raggiunto il limite del tuo abbonamento ({{limit}} utenti, incluso te stesso). Rimuovi i membri o aggiorna a un account illimitato dell'azienda.", "info": "Aggiungi email qui sotto per concedere account Pro fino a {{limit}} altri colleghi. Premi Invio dopo ogni indirizzo email." + }, + "adminsEditor": { + "added": "{{email}} è ora un amministratore di questo abbonamento.", + "removed": "{{email}} non è più un amministratore di questo abbonamento.", + "title": "Amministratori", + "description": "Gli amministratori possono aggiungere utenti o rimuovere utenti dalla sottoscrizione. Gli amministratori non ricevono automaticamente gli account Pro." } }, "trial": { diff --git a/frontend/src/translations/locales/ja-JP.json b/frontend/src/translations/locales/ja-JP.json index 0ebe74b85..6b585ec8e 100644 --- a/frontend/src/translations/locales/ja-JP.json +++ b/frontend/src/translations/locales/ja-JP.json @@ -284,8 +284,11 @@ "plan": { "header": "あなたのプラン", "plan": "プラン", + "admins": "管理者", "youAreOwner": "あなたは以下のサブスクリプションを通じて、このプランの所有者です。", - "youAreMember": "あなたは他人のサブスクリプションを通じてこのプランにいます。" + "youAreMember": "あなたは他人のサブスクリプションを通じてこのプランにいます。", + "ownedBy": "所有者:", + "notPro": "あなたはプロユーザーではありません。プランの管理者であるにもかかわらず、以下のセクションのProアカウントのリストに自分自身を追加してください。" }, "subscription": { "header": "あなたのサブスクリプション", @@ -294,6 +297,12 @@ "title": "あなたのチーム", "limitReached": "サブスクリプションの上限に達しました(自分を含む{{limit}} ユーザー)。メンバーを削除するか、無制限の会社アカウントにアップグレードしてください。", "info": "Proアカウントに他の {{limit}} 人の同僚を許可するには、以下のメールを追加してください。各メールアドレスの後にEnterを押します。" + }, + "adminsEditor": { + "added": "{{email}} はこのサブスクリプションの管理者になりました。", + "removed": "{{email}} はもうこのサブスクリプションの管理者ではありません。", + "title": "管理者", + "description": "管理者は、サブスクリプションにユーザーを追加または削除できます。管理者は自動的にProアカウントを許可されません。" } }, "trial": { diff --git a/frontend/src/translations/locales/nl-NL.json b/frontend/src/translations/locales/nl-NL.json index 72c4d0b80..d594b6142 100644 --- a/frontend/src/translations/locales/nl-NL.json +++ b/frontend/src/translations/locales/nl-NL.json @@ -284,8 +284,11 @@ "plan": { "header": "Uw abonnement", "plan": "Abonnement", + "admins": "Beheerders", "youAreOwner": "U bent de eigenaar van dit abonnement, via het onderstaande abonnement.", - "youAreMember": "U bent op dit plan via iemands abonnement." + "youAreMember": "U bent op dit plan via iemands abonnement.", + "ownedBy": "Eigendom van", + "notPro": "U bent geen Pro-gebruiker, ondanks dat u beheerder bent voor het abonnement. Voeg uzelf toe aan de lijst met Pro-accounts in de onderstaande sectie." }, "subscription": { "header": "Uw abonnement", @@ -294,6 +297,12 @@ "title": "Jouw team", "limitReached": "Je hebt de limiet van je abonnement bereikt ({{limit}} gebruikers, waaronder jezelf). Verwijder de leden, of upgrade naar een onbeperkt bedrijfsaccount.", "info": "Voeg hieronder e-mails toe om Pro-accounts tot {{limit}} andere collega's toe te kennen. Druk op Enter na elk e-mailadres." + }, + "adminsEditor": { + "added": "{{email}} is nu administrateur van dit abonnement.", + "removed": "{{email}} is geen administrateur meer voor dit abonnement.", + "title": "Beheerders", + "description": "Beheerders kunnen gebruikers toevoegen aan of verwijderen van gebruikers van het abonnement. Beheerders krijgen niet automatisch Pro accounts." } }, "trial": { diff --git a/frontend/src/translations/locales/pl-PL.json b/frontend/src/translations/locales/pl-PL.json index aeee66aa2..9530821d2 100644 --- a/frontend/src/translations/locales/pl-PL.json +++ b/frontend/src/translations/locales/pl-PL.json @@ -284,8 +284,11 @@ "plan": { "header": "Twój plan", "plan": "Plan", + "admins": "Administratorzy", "youAreOwner": "Jesteś właścicielem tego planu poprzez poniższą subskrypcję.", - "youAreMember": "Jesteś na tym planie poprzez subskrypcję kogoś innego." + "youAreMember": "Jesteś na tym planie poprzez subskrypcję kogoś innego.", + "ownedBy": "Posiadane przez", + "notPro": "Nie jesteś użytkownikiem Pro, mimo że jesteś administratorem planu. Dodaj siebie do listy kont Pro w sekcji poniżej." }, "subscription": { "header": "Twoja subskrypcja", @@ -294,6 +297,12 @@ "title": "Twój zespół", "limitReached": "Osiągnąłeś limit subskrypcji ({{limit}} użytkowników, w tym siebie). Proszę usunąć członków lub zaktualizować konto do nieograniczonej liczby firm.", "info": "Dodaj e-maile poniżej, aby przyznać konta Pro maksymalnie {{limit}} innym współpracownikom. Naciśnij Enter po każdym adresie e-mail." + }, + "adminsEditor": { + "added": "{{email}} jest teraz administratorem tej subskrypcji.", + "removed": "{{email}} nie jest już administratorem tej subskrypcji.", + "title": "Administratorzy", + "description": "Administratorzy mogą dodawać użytkowników lub usuwać użytkowników z subskrypcji. Administratorzy nie otrzymują automatycznie konta Pro." } }, "trial": { diff --git a/frontend/src/translations/locales/pt-BR.json b/frontend/src/translations/locales/pt-BR.json index 7d5213c31..add836b24 100644 --- a/frontend/src/translations/locales/pt-BR.json +++ b/frontend/src/translations/locales/pt-BR.json @@ -284,8 +284,11 @@ "plan": { "header": "Seu plano", "plan": "Planejamento", + "admins": "Administradores", "youAreOwner": "Você é o proprietário deste plano, através da assinatura abaixo.", - "youAreMember": "Você está nesse plano por meio da assinatura de outra pessoa." + "youAreMember": "Você está nesse plano por meio da assinatura de outra pessoa.", + "ownedBy": "Possuído por", + "notPro": "Você não é um usuário Pro, apesar de ser um administrador do plano. Adicione você mesmo à lista de contas Pro na seção abaixo." }, "subscription": { "header": "Sua assinatura", @@ -294,6 +297,12 @@ "title": "Seu time", "limitReached": "Você atingiu o limite de sua assinatura ({{limit}} usuários, incluindo você mesmo). Por favor, remova os membros ou faça o upgrade para uma conta ilimitada da empresa.", "info": "Adicione e-mails abaixo para conceder contas Pro para até {{limit}} outros colegas. Pressione Enter após cada endereço de e-mail." + }, + "adminsEditor": { + "added": "{{email}} agora é um administrador desta assinatura.", + "removed": "{{email}} não é mais um administrador dessa assinatura.", + "title": "Administradores", + "description": "Administradores podem adicionar usuários ou remover usuários da assinatura. Administradores não são concedidas contas Pro automaticamente." } }, "trial": { diff --git a/frontend/src/translations/locales/pt-PT.json b/frontend/src/translations/locales/pt-PT.json index 395514978..ac6e15a13 100644 --- a/frontend/src/translations/locales/pt-PT.json +++ b/frontend/src/translations/locales/pt-PT.json @@ -284,8 +284,11 @@ "plan": { "header": "Seu plano", "plan": "Planejamento", + "admins": "Administradores", "youAreOwner": "Você é o proprietário deste plano, através da assinatura abaixo.", - "youAreMember": "Você está nesse plano por meio da assinatura de outra pessoa." + "youAreMember": "Você está nesse plano por meio da assinatura de outra pessoa.", + "ownedBy": "Possuído por", + "notPro": "Você não é um usuário Pro, apesar de ser um administrador do plano. Adicione você mesmo à lista de contas Pro na seção abaixo." }, "subscription": { "header": "Sua assinatura", @@ -294,6 +297,12 @@ "title": "Seu time", "limitReached": "Você atingiu o limite de sua assinatura ({{limit}} usuários, incluindo você mesmo). Por favor, remova os membros ou faça o upgrade para uma conta ilimitada da empresa.", "info": "Adicione e-mails abaixo para conceder contas Pro para até {{limit}} outros colegas. Pressione Enter após cada endereço de e-mail." + }, + "adminsEditor": { + "added": "{{email}} agora é um administrador desta assinatura.", + "removed": "{{email}} não é mais um administrador dessa assinatura.", + "title": "Administradores", + "description": "Administradores podem adicionar usuários ou remover usuários da assinatura. Administradores não são concedidas contas Pro automaticamente." } }, "trial": { diff --git a/frontend/src/translations/locales/uk-UA.json b/frontend/src/translations/locales/uk-UA.json index 52d115a02..e64f492f8 100644 --- a/frontend/src/translations/locales/uk-UA.json +++ b/frontend/src/translations/locales/uk-UA.json @@ -284,8 +284,11 @@ "plan": { "header": "Ваш План", "plan": "План", + "admins": "Адміністратори", "youAreOwner": "Ви власник цього плану через нижченаведену підписку.", - "youAreMember": "Ви використовуєте цей тарифний план з чужої підписки." + "youAreMember": "Ви використовуєте цей тарифний план з чужої підписки.", + "ownedBy": "Власник", + "notPro": "Ви не є користувачем Pro, незважаючи на те, що є адміністратором тарифу. Додайте себе в список Pro рахунків у розділі нижче." }, "subscription": { "header": "Ваша підписка", @@ -294,6 +297,12 @@ "title": "Ваша команда", "limitReached": "Ви досягли ліміту своєї передплати ({{limit}} користувачів, включаючи себе). Будь ласка, вилучіть або оновіть обліковий запис компанії.", "info": "Додайте листи нижче, щоб надати Pro аккаунтам до {{limit}} інших колег. Натисніть Enter після кожної адреси електронної пошти." + }, + "adminsEditor": { + "added": "{{email}} тепер є адміністратором цієї підписки.", + "removed": "{{email}} більше не є адміністратором цієї підписки.", + "title": "Адміністратори", + "description": "Адміністратори можуть додавати користувачів або видаляти користувачів із підписки. Адміністратори не отримують автоматично Pro-акаунти." } }, "trial": { diff --git a/frontend/src/translations/locales/zh-CN.json b/frontend/src/translations/locales/zh-CN.json index db45034f5..5b5154268 100644 --- a/frontend/src/translations/locales/zh-CN.json +++ b/frontend/src/translations/locales/zh-CN.json @@ -284,8 +284,11 @@ "plan": { "header": "您的计划", "plan": "计划", + "admins": "管理员", "youAreOwner": "您是此计划的所有者,通过下面的订阅。", - "youAreMember": "您正在通过其他人的订阅加入此计划。" + "youAreMember": "您正在通过其他人的订阅加入此计划。", + "ownedBy": "拥有者", + "notPro": "您不是专业版用户,尽管您是计划的管理员。请将您自己添加到下面一节中的专业版账户列表。" }, "subscription": { "header": "您的订阅", @@ -294,6 +297,12 @@ "title": "您的团队", "limitReached": "您的订阅已达到极限({{limit}} 用户,包括您自己)。请删除会员,或升级到无限公司帐户。", "info": "在下面添加电子邮件,给予最多 {{limit}} 位其他同事专业版帐户。在每个电子邮件地址后按回车键。" + }, + "adminsEditor": { + "added": "{{email}} 现在是此订阅的管理员。", + "removed": "{{email}} 不再是此订阅的管理员。", + "title": "管理员", + "description": "管理员可以将用户添加到或从订阅中删除用户。管理员不会自动获得专业版账户。" } }, "trial": { diff --git a/frontend/src/translations/locales/zh-TW.json b/frontend/src/translations/locales/zh-TW.json index 9c95065b6..1d7cb2b4c 100644 --- a/frontend/src/translations/locales/zh-TW.json +++ b/frontend/src/translations/locales/zh-TW.json @@ -284,8 +284,11 @@ "plan": { "header": "你的計劃", "plan": "計劃", + "admins": "", "youAreOwner": "通過以下訂閱,您是該計劃的所有者。", - "youAreMember": "您通過其他人的訂閱參與此計劃。" + "youAreMember": "您通過其他人的訂閱參與此計劃。", + "ownedBy": "", + "notPro": "" }, "subscription": { "header": "您的訂閱", @@ -294,6 +297,12 @@ "title": "你的團隊", "limitReached": "您已達到訂閱限制({{limit}} 個用戶,包括您自己)。請刪除成員,或升級到無限制的公司帳戶。", "info": "在下方添加電子郵件以將 Pro 帳戶授予最多 {{limit}} 位其他同事。在每個電子郵件地址後按 Enter。" + }, + "adminsEditor": { + "added": "", + "removed": "", + "title": "", + "description": "" } }, "trial": { diff --git a/frontend/src/translations/readme.md b/frontend/src/translations/readme.md index 86b28c8da..ab77a3d7e 100644 --- a/frontend/src/translations/readme.md +++ b/frontend/src/translations/readme.md @@ -20,4 +20,16 @@ On the home directory (`~/`): ## Push a new source -- `crowdin push sources` \ No newline at end of file +- `crowdin push sources` + + +# Workflow + +- Login to [Crowdin](https://crowdin.com/project/retrospected) +- Optional: if the token is expired, change it in `~/.crowdin.yml` (and get it [here](https://crowdin.com/settings#api-key)) +- Upload the latest sources (the English translation): `crowdin push sources` +- Upload the translations (in case they were modified somewhere else): `crowdin push translations` +- Then on the website, choose `Pre-translation` > `Translate via MT`, then select `Crowdin Translate` and `Target Languages`, `Files`. +- This should have translated most languages. For the remaining ones (Hungarian and Chinese Trad): do the same with Google Translate +- Manually check French translations and approve them: On the French line, click on the arrow on the right and do `Proof Read`. +- Download the translated files: `crowdin download` diff --git a/frontend/src/views/account/AccountPage.tsx b/frontend/src/views/account/AccountPage.tsx index 38260a4ca..c6cfb4d42 100644 --- a/frontend/src/views/account/AccountPage.tsx +++ b/frontend/src/views/account/AccountPage.tsx @@ -15,17 +15,20 @@ import useFormatDate from '../../hooks/useFormatDate'; import { DeleteModal } from './delete/DeleteModal'; import useModal from '../../hooks/useModal'; import EditableLabel from 'components/EditableLabel'; -import { useCallback, useContext } from 'react'; -import { updateUserName } from './api'; +import { useCallback, useContext, useEffect, useState } from 'react'; +import { updateAdmins, updateUserName } from './api'; import UserContext from 'auth/Context'; import { useSnackbar } from 'notistack'; import LanguagePicker from 'components/LanguagePicker'; import { useLanguage } from 'translations'; import useBackendCapabilities from 'global/useBackendCapabilities'; +import AdminsEditor from './AdminEditor'; +import Tag from 'components/TagInput/Tag'; function AccountPage() { const url = usePortalUrl(); const user = useUser(); + const [admins, setAdmins] = useState(null); const [language, setLanguage] = useLanguage(); const { setUser } = useContext(UserContext); const isTrial = useIsTrial(); @@ -37,6 +40,10 @@ function AccountPage() { useModal(); const capabilities = useBackendCapabilities(); + useEffect(() => { + setAdmins(user?.planAdmins || null); + }, [user]); + const handleEditName = useCallback( async (name: string) => { const trimmed = name.trim(); @@ -52,15 +59,27 @@ function AccountPage() { [setUser, enqueueSnackbar, t] ); + const handleEditAdmins = useCallback((admins: string[]) => { + setAdmins(admins); + updateAdmins(admins); + }, []); + const ownsThePlan = user && !!user.ownSubscriptionsId && user.ownSubscriptionsId === user.subscriptionsId; + const onSomebodysPlan = user && !!user.subscriptionsId && user.ownSubscriptionsId !== user.subscriptionsId; + const isPlanAdmin = + user && + user.email && + user.planAdmins && + user.planAdmins.includes(user.email); + if (!user) { return null; } @@ -133,6 +152,21 @@ function AccountPage() { {user.plan} + + {t('AccountPage.plan.admins')} + + {[user.planOwnerEmail, ...(user.planAdmins || [])] + .filter(Boolean) + .map((email, i) => ( + + ))} + + + + {!user.pro ? ( + {t('AccountPage.plan.notPro')} + ) : null} + {user.domain ? ( {t('SubscribePage.domain.title')} @@ -140,9 +174,19 @@ function AccountPage() { ) : null} {onSomebodysPlan && ( - - {t('AccountPage.plan.youAreMember')} - + <> + {user.planOwner && user.planOwnerEmail ? ( + + {t('AccountPage.plan.ownedBy')} + + {user.planOwner} ({user.planOwnerEmail}) + + + ) : null} + + {t('AccountPage.plan.youAreMember')} + + )} {ownsThePlan && ( {t('AccountPage.plan.youAreOwner')} @@ -150,12 +194,28 @@ function AccountPage() { ) : null} - {ownsThePlan && user && user.plan && user.plan === 'team' ? ( + {(ownsThePlan || isPlanAdmin) && + user && + user.plan && + user.plan === 'team' ? (
) : null} + {admins !== null && + ownsThePlan && + user && + user.plan && + user.plan === 'team' ? ( +
+ + {t('AccountPage.subscription.adminsEditor.description')} + + +
+ ) : null} + {ownsThePlan && !isTrial ? (
{url ? ( diff --git a/frontend/src/views/account/AdminEditor.tsx b/frontend/src/views/account/AdminEditor.tsx new file mode 100644 index 000000000..8c6b7f19d --- /dev/null +++ b/frontend/src/views/account/AdminEditor.tsx @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; +import { validate } from 'isemail'; +import styled from '@emotion/styled'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import TagInput from '../../components/TagInput'; + +type AdminsEditorProps = { + admins: string[]; + onChange: (admins: string[]) => void; +}; + +function isValidEmail(email: string): boolean { + return validate(email); +} + +function doesNotExist(members: string[] | null, email: string): boolean { + return !members || !members.includes(email); +} + +function AdminsEditor({ admins, onChange }: AdminsEditorProps) { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + + const handleAdd = useCallback( + (value: string) => { + if (admins && !admins.includes(value)) { + onChange([...admins, value]); + enqueueSnackbar( + t('AccountPage.subscription.adminsEditor.added', { email: value }), + { + variant: 'success', + } + ); + } + }, + [admins, onChange, enqueueSnackbar, t] + ); + const handleRemove = useCallback( + (value: string) => { + if (admins && admins.includes(value)) { + onChange(admins.filter((a) => a !== value)); + enqueueSnackbar( + t('AccountPage.subscription.adminsEditor.removed', { email: value }), + { + variant: 'success', + } + ); + } + }, + [admins, onChange, enqueueSnackbar, t] + ); + const handleValidate = useCallback( + async (value: string) => { + return isValidEmail(value) && doesNotExist(admins, value); + }, + [admins] + ); + + if (admins === null) { + return null; + } + + return ( + + + + ); +} +const Container = styled.div` + margin-top: 20px; +`; + +export default AdminsEditor; diff --git a/frontend/src/views/account/api.ts b/frontend/src/views/account/api.ts index 5e5cc24bf..cb3a7b11b 100644 --- a/frontend/src/views/account/api.ts +++ b/frontend/src/views/account/api.ts @@ -21,6 +21,10 @@ export async function updateMembers(members: string[]): Promise { await fetchPatch(`/api/stripe/members`, members); } +export async function updateAdmins(admins: string[]): Promise { + await fetchPatch(`/api/stripe/admins`, admins); +} + export async function updateUserName(name: string): Promise { const updated = await fetchPostGet( `/api/me/username`,