Remove team account related services and actions

Removed services and actions related to team account deletion as well as updated paths within other dependent files, better reflecting their new locations. Also, added a new service titled 'AccountBillingService' for handling billing-related operations and restructured the form layout and handled translation in 'team-account-danger-zone' component.
This commit is contained in:
giancarlo
2024-03-28 15:27:56 +08:00
parent 3ac4d3b00d
commit 041efb89fb
77 changed files with 1998 additions and 1553 deletions

View File

@@ -24,15 +24,15 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@kit/billing": "*",
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/shared": "*",
"@kit/stripe": "*",
"@kit/supabase": "*",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@kit/billing": "^0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "^0.1.0",
"@kit/stripe": "^0.1.0",
"@kit/supabase": "^0.1.0",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "^0.1.0",
"@supabase/supabase-js": "^2.40.0",
"lucide-react": "^0.363.0",
"zod": "^3.22.4"

View File

@@ -1,3 +1,4 @@
export * from './services/billing-gateway/billing-gateway.service';
export * from './services/billing-gateway/billing-gateway-provider-factory';
export * from './services/billing-event-handler/billing-gateway-provider-factory';
export * from './server/services/billing-gateway/billing-gateway.service';
export * from './server/services/billing-gateway/billing-gateway-provider-factory';
export * from './server/services/billing-event-handler/billing-gateway-provider-factory';
export * from './server/services/account-billing.service';

View File

@@ -0,0 +1,68 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { BillingGatewayService } from './billing-gateway/billing-gateway.service';
export class AccountBillingService {
private readonly namespace = 'accounts.billing';
constructor(private readonly client: SupabaseClient<Database>) {}
async cancelAllAccountSubscriptions({
accountId,
userId,
}: {
accountId: string;
userId: string;
}) {
Logger.info(
{
userId,
accountId,
name: this.namespace,
},
'Cancelling all subscriptions for account...',
);
const { data: subscriptions } = await this.client
.from('subscriptions')
.select('*')
.eq('account_id', accountId);
const cancellationRequests = [];
Logger.info(
{
userId,
subscriptions: subscriptions?.length ?? 0,
name: this.namespace,
},
'Cancelling all account subscriptions...',
);
for (const subscription of subscriptions ?? []) {
const gateway = new BillingGatewayService(subscription.billing_provider);
cancellationRequests.push(
gateway.cancelSubscription({
subscriptionId: subscription.id,
invoiceNow: true,
}),
);
}
// execute all cancellation requests
await Promise.all(cancellationRequests);
Logger.info(
{
userId,
subscriptions: subscriptions?.length ?? 0,
name: this.namespace,
},
'Subscriptions cancelled successfully',
);
}
}

View File

@@ -21,12 +21,12 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/supabase": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"lucide-react": "^0.363.0",
"zod": "^3.22.4"
},

View File

@@ -16,10 +16,10 @@
"@react-email/components": "0.0.15"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0"
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*"
},
"eslintConfig": {
"root": true,

View File

@@ -18,8 +18,8 @@ import {
} from '@react-email/components';
interface Props {
organizationName: string;
organizationLogo?: string;
teamName: string;
teamLogo?: string;
inviter: string | undefined;
invitedUserEmail: string;
link: string;
@@ -38,7 +38,7 @@ export function renderInviteEmail(props: Props) {
<Body className="mx-auto my-auto bg-gray-50 font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded-lg border border-solid border-[#eaeaea] bg-white p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">
Join <strong>{props.organizationName}</strong> on{' '}
Join <strong>{props.teamName}</strong> on{' '}
<strong>{props.productName}</strong>
</Heading>
<Text className="text-[14px] leading-[24px] text-black">
@@ -46,16 +46,16 @@ export function renderInviteEmail(props: Props) {
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>{props.inviter}</strong> has invited you to the{' '}
<strong>{props.organizationName}</strong> team on{' '}
<strong>{props.teamName}</strong> team on{' '}
<strong>{props.productName}</strong>.
</Text>
{props.organizationLogo && (
{props.teamLogo && (
<Section>
<Row>
<Column align="center">
<Img
className="rounded-full"
src={props.organizationLogo}
src={props.teamLogo}
width="64"
height="64"
/>
@@ -68,7 +68,7 @@ export function renderInviteEmail(props: Props) {
className="rounded bg-[#000000] px-[20px] py-[12px] text-center text-[12px] font-semibold text-white no-underline"
href={props.link}
>
Join {props.organizationName}
Join {props.teamName}
</Button>
</Section>
<Text className="text-[14px] leading-[24px] text-black">

View File

@@ -15,20 +15,23 @@
"./hooks/*": "./src/hooks/*.ts"
},
"devDependencies": {
"@kit/billing-gateway": "*",
"@kit/email-templates": "*",
"@kit/mailers": "*",
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/shared": "*",
"@kit/supabase": "*",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@hookform/resolvers": "^3.3.4",
"@kit/billing-gateway": "^0.1.0",
"@kit/email-templates": "^0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/mailers": "^0.1.0",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "^0.1.0",
"@kit/supabase": "^0.1.0",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "^0.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@tanstack/react-query": "5.28.6",
"lucide-react": "^0.363.0",
"react-hook-form": "^7.51.2",
"react-i18next": "^14.1.0",
"sonner": "^1.4.41",
"zod": "^3.22.4"
},
"peerDependencies": {
@@ -36,7 +39,8 @@
"@kit/supabase": "0.1.0",
"@kit/ui": "0.1.0",
"@radix-ui/react-icons": "^1.3.0",
"lucide-react": "^0.363.0"
"lucide-react": "^0.363.0",
"sonner": "^1.4.41"
},
"prettier": "@kit/prettier-config",
"eslintConfig": {

View File

@@ -17,6 +17,7 @@ import {
} from '@kit/ui/command';
import { If } from '@kit/ui/if';
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { CreateTeamAccountDialog } from '../../../team-accounts/src/components/create-team-account-dialog';
@@ -29,8 +30,8 @@ interface AccountSelectorProps {
}>;
features: {
enableOrganizationAccounts: boolean;
enableOrganizationCreation: boolean;
enableTeamAccounts: boolean;
enableTeamCreation: boolean;
};
selectedAccount?: string;
@@ -46,8 +47,8 @@ export function AccountSelector({
selectedAccount,
onAccountChange,
features = {
enableOrganizationAccounts: true,
enableOrganizationCreation: true,
enableTeamAccounts: true,
enableTeamCreation: true,
},
collapsed = false,
}: React.PropsWithChildren<AccountSelectorProps>) {
@@ -75,6 +76,10 @@ export function AccountSelector({
const selected = accounts.find((account) => account.value === value);
if (!features.enableTeamAccounts) {
return null;
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
@@ -150,9 +155,9 @@ export function AccountSelector({
<CommandSeparator />
<If condition={features.enableOrganizationAccounts}>
<If condition={features.enableTeamAccounts}>
<If condition={accounts.length > 0}>
<CommandGroup heading={'Your Organizations'}>
<CommandGroup heading={<Trans i18nKey={'teams:yourTeams'} />}>
{(accounts ?? []).map((account) => (
<CommandItem
key={account.value}
@@ -185,7 +190,7 @@ export function AccountSelector({
</If>
</If>
<If condition={features.enableOrganizationCreation}>
<If condition={features.enableTeamCreation}>
<CommandGroup>
<Button
size={'sm'}
@@ -198,7 +203,9 @@ export function AccountSelector({
>
<Plus className="mr-2 h-4 w-4" />
<span>Create Organization</span>
<span>
<Trans i18nKey={'teams:createTeam'} />
</span>
</Button>
</CommandGroup>
</If>
@@ -207,7 +214,7 @@ export function AccountSelector({
</PopoverContent>
</Popover>
<If condition={features.enableOrganizationCreation}>
<If condition={features.enableTeamCreation}>
<CreateTeamAccountDialog
isOpen={isCreatingAccount}
setIsOpen={setIsCreatingAccount}

View File

@@ -18,6 +18,7 @@ import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
@@ -166,18 +167,20 @@ function ConfirmUnenrollFactorModal(
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogAction
className={'w-full'}
type={'button'}
disabled={unEnroll.isPending}
onClick={() => onUnenrollRequested(props.factorId)}
>
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction
className={'w-full'}
type={'button'}
disabled={unEnroll.isPending}
onClick={() => onUnenrollRequested(props.factorId)}
>
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);

View File

@@ -212,20 +212,22 @@ function MultiFactorAuthSetupForm({
name={'verificationCode'}
/>
<Button
disabled={!verificationCodeForm.formState.isValid}
type={'submit'}
>
{state.loading ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:enableMfaFactor'} />
)}
</Button>
<div className={'flex space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button
disabled={!verificationCodeForm.formState.isValid}
type={'submit'}
>
{state.loading ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:enableMfaFactor'} />
)}
</Button>
</div>
</div>
</form>
</Form>
@@ -362,13 +364,15 @@ function FactorNameForm(
}}
/>
<Button type={'submit'}>
<Trans i18nKey={'account:factorNameSubmitLabel'} />
</Button>
<div className={'flex space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button type={'submit'}>
<Trans i18nKey={'account:factorNameSubmitLabel'} />
</Button>
</div>
</div>
</form>
</Form>

View File

@@ -1,5 +1,7 @@
'use client';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import {
usePersonalAccountData,
useRevalidatePersonalAccountDataQuery,
@@ -8,7 +10,11 @@ import { UpdateAccountDetailsForm } from './update-account-details-form';
export function UpdateAccountDetailsFormContainer() {
const user = usePersonalAccountData();
const invalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
if (user.isLoading) {
return <LoadingOverlay fullPage={false} />;
}
if (!user.data) {
return null;
@@ -18,7 +24,7 @@ export function UpdateAccountDetailsFormContainer() {
<UpdateAccountDetailsForm
displayName={user.data.name ?? ''}
userId={user.data.id}
onUpdate={invalidateUserDataQuery}
onUpdate={revalidateUserDataQuery}
/>
);
}

View File

@@ -8,7 +8,7 @@ import { Logger } from '@kit/shared/logger';
import { requireAuth } from '@kit/supabase/require-auth';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { PersonalAccountsService } from './services/personal-accounts.service';
import { DeletePersonalAccountService } from './services/delete-personal-account.service';
const emailSettings = getEmailSettingsFromEnvironment();
@@ -41,7 +41,7 @@ export async function deletePersonalAccountAction(formData: FormData) {
const userEmail = session.data.user.email ?? null;
// create a new instance of the personal accounts service
const service = new PersonalAccountsService(client);
const service = new DeletePersonalAccountService();
// delete the user's account and cancel all subscriptions
await service.deletePersonalAccount({

View File

@@ -1,27 +1,28 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { BillingGatewayService } from '@kit/billing-gateway';
import { AccountBillingService } from '@kit/billing-gateway';
import { Mailer } from '@kit/mailers';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
/**
* @name PersonalAccountsService
* @name DeletePersonalAccountService
* @description Service for managing accounts in the application
* @param Database - The Supabase database type to use
* @example
* const client = getSupabaseClient();
* const accountsService = new AccountsService(client);
* const accountsService = new DeletePersonalAccountService();
*/
export class PersonalAccountsService {
private namespace = 'account';
constructor(private readonly client: SupabaseClient<Database>) {}
export class DeletePersonalAccountService {
private namespace = 'accounts.delete';
/**
* @name deletePersonalAccount
* Delete personal account of a user.
* This will delete the user from the authentication provider and cancel all subscriptions.
*
* Permissions are not checked here, as they are checked in the server action.
* USE WITH CAUTION. THE USER MUST HAVE THE NECESSARY PERMISSIONS.
*/
async deletePersonalAccount(params: {
adminClient: SupabaseClient<Database>;
@@ -39,6 +40,11 @@ export class PersonalAccountsService {
'User requested deletion. Processing...',
);
// Cancel all user subscriptions
const billingService = new AccountBillingService(params.adminClient);
await billingService.cancelAllAccountSubscriptions(params.userId);
// execute the deletion of the user
try {
await params.adminClient.auth.admin.deleteUser(params.userId);
@@ -55,17 +61,6 @@ export class PersonalAccountsService {
throw new Error('Error deleting user');
}
// Cancel all user subscriptions
try {
await this.cancelAllUserSubscriptions(params.userId);
} catch (error) {
Logger.error({
userId: params.userId,
error,
name: this.namespace,
});
}
// Send account deletion email
if (params.userEmail) {
try {
@@ -117,53 +112,4 @@ export class PersonalAccountsService {
html,
});
}
private async cancelAllUserSubscriptions(userId: string) {
Logger.info(
{
userId,
name: this.namespace,
},
'Cancelling all subscriptions for user...',
);
const { data: subscriptions } = await this.client
.from('subscriptions')
.select('*')
.eq('account_id', userId);
const cancellationRequests = [];
Logger.info(
{
userId,
subscriptions: subscriptions?.length ?? 0,
name: this.namespace,
},
'Cancelling subscriptions...',
);
for (const subscription of subscriptions ?? []) {
const gateway = new BillingGatewayService(subscription.billing_provider);
cancellationRequests.push(
gateway.cancelSubscription({
subscriptionId: subscription.id,
invoiceNow: true,
}),
);
}
// execute all cancellation requests
await Promise.all(cancellationRequests);
Logger.info(
{
userId,
subscriptions: subscriptions?.length ?? 0,
name: this.namespace,
},
'Subscriptions cancelled successfully',
);
}
}

View File

@@ -13,13 +13,13 @@
"@kit/ui": "0.1.0"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@kit/supabase": "*",
"@supabase/supabase-js": "2.40.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/supabase": "^0.1.0",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "^0.1.0",
"@supabase/supabase-js": "^2.40.0",
"lucide-react": "^0.363.0"
},
"exports": {

View File

@@ -16,13 +16,14 @@
"./mfa": "./src/mfa.ts"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/shared": "0.1.0",
"@kit/supabase": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "0.1.0",
"@hookform/resolvers": "^3.3.4",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.0",
"@tanstack/react-query": "5.28.6",
"react-i18next": "^14.1.0",

View File

@@ -12,18 +12,21 @@
"./components": "./src/components/index.ts"
},
"devDependencies": {
"@kit/accounts": "*",
"@kit/email-templates": "*",
"@kit/eslint-config": "0.2.0",
"@kit/mailers": "*",
"@kit/prettier-config": "0.1.0",
"@kit/shared": "*",
"@kit/supabase": "*",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@hookform/resolvers": "^3.3.4",
"lucide-react": "^0.363.0"
"@kit/accounts": "^0.1.0",
"@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "^0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/mailers": "^0.1.0",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "^0.1.0",
"@kit/supabase": "^0.1.0",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "^0.1.0",
"@tanstack/react-query": "5.28.6",
"lucide-react": "^0.363.0",
"react-i18next": "^14.1.0"
},
"peerDependencies": {
"@kit/accounts": "0.1.0",

View File

@@ -1,17 +0,0 @@
'use server';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { DeleteTeamAccountSchema } from '../schema/delete-team-account.schema';
import { DeleteTeamAccountService } from '../services/delete-team-account.service';
export async function deleteTeamAccountAction(formData: FormData) {
const body = Object.fromEntries(formData.entries());
const params = DeleteTeamAccountSchema.parse(body);
const client = getSupabaseServerActionClient();
const service = new DeleteTeamAccountService(client);
await service.deleteTeamAccount(params);
return { success: true };
}

View File

@@ -6,7 +6,13 @@ import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Dialog, DialogContent, DialogTitle } from '@kit/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
@@ -20,8 +26,8 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { createOrganizationAccountAction } from '../actions/create-team-account-server-actions';
import { CreateTeamSchema } from '../schema/create-team.schema';
import { createOrganizationAccountAction } from '../server/actions/create-team-account-server-actions';
export function CreateTeamAccountDialog(
props: React.PropsWithChildren<{
@@ -31,18 +37,27 @@ export function CreateTeamAccountDialog(
) {
return (
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
<DialogContent>
<DialogTitle>
<Trans i18nKey={'teams:createOrganizationModalHeading'} />
</DialogTitle>
<DialogContent
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:createTeamModalHeading'} />
</DialogTitle>
<CreateOrganizationAccountForm />
<DialogDescription>
<Trans i18nKey={'teams:createTeamModalDescription'} />
</DialogDescription>
</DialogHeader>
<CreateOrganizationAccountForm onClose={() => props.setIsOpen(false)} />
</DialogContent>
</Dialog>
);
}
function CreateOrganizationAccountForm() {
function CreateOrganizationAccountForm(props: { onClose: () => void }) {
const [error, setError] = useState<boolean>();
const [pending, startTransition] = useTransition();
@@ -77,12 +92,12 @@ function CreateOrganizationAccountForm() {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:organizationNameLabel'} />
<Trans i18nKey={'teams:teamNameLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'create-organization-name-input'}
data-test={'create-team-name-input'}
required
minLength={2}
maxLength={50}
@@ -92,7 +107,7 @@ function CreateOrganizationAccountForm() {
</FormControl>
<FormDescription>
Your organization name should be unique and descriptive.
<Trans i18nKey={'teams:teamNameDescription'} />
</FormDescription>
<FormMessage />
@@ -101,12 +116,20 @@ function CreateOrganizationAccountForm() {
}}
/>
<Button
data-test={'confirm-create-organization-button'}
disabled={pending}
>
<Trans i18nKey={'teams:createOrganizationSubmitLabel'} />
</Button>
<div className={'flex justify-end space-x-2'}>
<Button
variant={'outline'}
disabled={pending}
type={'button'}
onClick={props.onClose}
>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button data-test={'confirm-create-team-button'} disabled={pending}>
<Trans i18nKey={'teams:createTeamSubmitLabel'} />
</Button>
</div>
</div>
</form>
</Form>
@@ -117,11 +140,11 @@ function CreateOrganizationErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:createOrganizationErrorHeading'} />
<Trans i18nKey={'teams:createTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:createOrganizationErrorMessage'} />
<Trans i18nKey={'teams:createTeamErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,5 +1,5 @@
export * from './members/account-members-table';
export * from './update-organization-form';
export * from './members/invite-members-dialog-container';
export * from './team-account-danger-zone';
export * from './settings/team-account-danger-zone';
export * from './invitations/account-invitations-table';
export * from './settings/team-account-settings-container';

View File

@@ -18,7 +18,7 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { RoleBadge } from '../role-badge';
import { RoleBadge } from '../members/role-badge';
import { DeleteInvitationDialog } from './delete-invitation-dialog';
import { UpdateInvitationDialog } from './update-invitation-dialog';

View File

@@ -12,7 +12,7 @@ import {
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { deleteInvitationAction } from '../../actions/account-invitations-server-actions';
import { deleteInvitationAction } from '../../server/actions/team-invitations-server-actions';
export const DeleteInvitationDialog: React.FC<{
isOpen: boolean;
@@ -24,7 +24,7 @@ export const DeleteInvitationDialog: React.FC<{
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="organization:deleteInvitationDialogTitle" />
<Trans i18nKey="team:deleteInvitationDialogTitle" />
</DialogTitle>
<DialogDescription>

View File

@@ -25,9 +25,9 @@ import {
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { updateInvitationAction } from '../../actions/account-invitations-server-actions';
import { UpdateRoleSchema } from '../../schema/update-role-schema';
import { MembershipRoleSelector } from '../membership-role-selector';
import { updateInvitationAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from '../members/membership-role-selector';
type Role = Database['public']['Enums']['account_role'];

View File

@@ -18,8 +18,8 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { RoleBadge } from '../role-badge';
import { RemoveMemberDialog } from './remove-member-dialog';
import { RoleBadge } from './role-badge';
import { TransferOwnershipDialog } from './transfer-ownership-dialog';
import { UpdateMemberRoleDialog } from './update-member-role-dialog';

View File

@@ -33,9 +33,9 @@ import {
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import { createInvitationsAction } from '../../actions/account-invitations-server-actions';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { MembershipRoleSelector } from '../membership-role-selector';
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
@@ -59,8 +59,7 @@ export function InviteMembersDialogContainer({
<DialogTitle>Invite Members to Organization</DialogTitle>
<DialogDescription>
Invite members to your organization by entering their email and
role.
Invite members to your team by entering their email and role.
</DialogDescription>
</DialogHeader>
@@ -89,7 +88,7 @@ function InviteMembersForm({
onSubmit: (data: { invitations: InviteModel[] }) => void;
pending: boolean;
}) {
const { t } = useTranslation('organization');
const { t } = useTranslation('team');
const form = useForm({
resolver: zodResolver(InviteMembersSchema),

View File

@@ -12,7 +12,7 @@ import {
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { removeMemberFromAccountAction } from '../../actions/account-members-server-actions';
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
export const RemoveMemberDialog: React.FC<{
isOpen: boolean;
@@ -25,11 +25,11 @@ export const RemoveMemberDialog: React.FC<{
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="organization:removeMemberModalHeading" />
<Trans i18nKey="team:removeMemberModalHeading" />
</DialogTitle>
<DialogDescription>
Remove this member from the organization.
Remove this member from the team.
</DialogDescription>
</DialogHeader>

View File

@@ -27,8 +27,8 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { transferOwnershipAction } from '../../actions/account-members-server-actions';
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
export const TransferOwnershipDialog: React.FC<{
isOpen: boolean;
@@ -42,11 +42,11 @@ export const TransferOwnershipDialog: React.FC<{
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="organization:transferOwnership" />
<Trans i18nKey="team:transferOwnership" />
</DialogTitle>
<DialogDescription>
Transfer ownership of the organization to another member.
Transfer ownership of the team to another member.
</DialogDescription>
</DialogHeader>

View File

@@ -25,9 +25,9 @@ import {
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { updateMemberRoleAction } from '../../actions/account-members-server-actions';
import { UpdateRoleSchema } from '../../schema/update-role-schema';
import { MembershipRoleSelector } from '../membership-role-selector';
import { updateMemberRoleAction } from '../../server/actions/team-members-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
type Role = Database['public']['Enums']['account_role'];

View File

@@ -0,0 +1,336 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { ErrorBoundary } from '@kit/ui/error-boundary';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { deleteTeamAccountAction } from '../../server/actions/delete-team-account-server-actions';
import { leaveTeamAccountAction } from '../../server/actions/leave-team-account-server-actions';
export function TeamAccountDangerZone({
account,
userIsPrimaryOwner,
}: React.PropsWithChildren<{
account: {
name: string;
id: string;
};
userIsPrimaryOwner: boolean;
}>) {
if (userIsPrimaryOwner) {
return <DeleteTeamContainer account={account} />;
}
return <LeaveTeamContainer account={account} />;
}
function DeleteTeamContainer(props: {
account: {
name: string;
id: string;
};
}) {
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'font-medium'}>
<Trans i18nKey={'teams:deleteTeam'} />
</span>
<p className={'text-sm text-gray-500'}>
<Trans
i18nKey={'teams:deleteTeamDescription'}
values={{
teamName: props.account.name,
}}
/>
</p>
</div>
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
data-test={'delete-team-button'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteTeam'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'teams:deletingTeam'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'teams:deletingTeamDescription'}
values={{
teamName: props.account.name,
}}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<DeleteTeamConfirmationForm
name={props.account.name}
id={props.account.id}
/>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
}
function DeleteTeamConfirmationForm({
name,
id,
}: {
name: string;
id: string;
}) {
const form = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
resolver: zodResolver(
z.object({
name: z.string().refine((value) => value === name, {
message: 'Name does not match',
path: ['name'],
}),
}),
),
defaultValues: {
name: '',
},
});
return (
<ErrorBoundary fallback={<DeleteTeamErrorAlert />}>
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
action={deleteTeamAccountAction}
>
<div className={'flex flex-col space-y-2'}>
<div
className={
'border-2 border-red-500 p-4 text-sm text-red-500' +
' flex flex-col space-y-2'
}
>
<div>
<Trans
i18nKey={'teams:deleteTeamDisclaimer'}
values={{
teamName: name,
}}
/>
</div>
<div className={'text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</div>
</div>
<input type="hidden" value={id} name={'accountId'} />
<FormField
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameInputLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'delete-team-input-field'}
required
type={'text'}
className={'w-full'}
placeholder={''}
pattern={name}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:deleteTeamInputField'} />
</FormDescription>
<FormMessage />
</FormItem>
)}
name={'confirm'}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<DeleteTeamSubmitButton />
</AlertDialogFooter>
</form>
</Form>
</ErrorBoundary>
);
}
function DeleteTeamSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-delete-team-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteTeam'} />
</Button>
);
}
function LeaveTeamContainer(props: {
account: {
name: string;
id: string;
};
}) {
return (
<div className={'flex flex-col space-y-4'}>
<p>
<Trans
i18nKey={'teams:leaveTeamDescription'}
values={{
teamName: props.account.name,
}}
/>
</p>
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
data-test={'leave-team-button'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:leaveTeam'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'teams:leavingTeamModalHeading'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'teams:leavingTeamModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<ErrorBoundary fallback={<LeaveTeamErrorAlert />}>
<form action={leaveTeamAccountAction}>
<input type={'hidden'} value={props.account.id} name={'id'} />
<div className={'my-2 flex flex-col space-y-4'}>
<Trans
i18nKey={'teams:leaveTeamDisclaimer'}
values={{
teamName: props.account?.name,
}}
/>
</div>
</form>
</ErrorBoundary>
</AlertDialogContent>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<LeaveTeamSubmitButton />
</AlertDialogFooter>
</AlertDialog>
</div>
</div>
);
}
function LeaveTeamSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-leave-organization-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:leaveOrganization'} />
</Button>
);
}
function LeaveTeamErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:leaveTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
);
}
function DeleteTeamErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { TeamAccountDangerZone } from './team-account-danger-zone';
import { UpdateTeamAccountImage } from './update-team-account-image-container';
import { UpdateTeamAccountNameForm } from './update-team-account-name-form';
export function TeamAccountSettingsContainer(props: {
account: {
name: string;
slug: string;
id: string;
pictureUrl: string | null;
primaryOwnerUserId: string;
};
userId: string;
paths: {
teamAccountSettings: string;
};
}) {
return (
<div className={'flex flex-col space-y-8'}>
<Card>
<CardHeader>
<CardTitle>Team Logo</CardTitle>
<CardDescription>
Update your team's logo to make it easier to identify
</CardDescription>
</CardHeader>
<CardContent>
<UpdateTeamAccountImage account={props.account} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Team Account Settings</CardTitle>
<CardDescription>Manage your team account settings</CardDescription>
</CardHeader>
<CardContent>
<UpdateTeamAccountNameForm
path={props.paths.teamAccountSettings}
account={props.account}
/>
</CardContent>
</Card>
<Card className={'border-destructive border-2'}>
<CardHeader>
<CardTitle>Danger Zone</CardTitle>
<CardDescription>
Please be careful when making changes in this section as they are
irreversible.
</CardDescription>
</CardHeader>
<CardContent>
<TeamAccountDangerZone
userIsPrimaryOwner={
props.userId === props.account.primaryOwnerUserId
}
account={props.account}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader';
import { Trans } from '@kit/ui/trans';
const AVATARS_BUCKET = 'account_image';
export function UpdateTeamAccountImage(props: {
account: {
id: string;
name: string;
pictureUrl: string | null;
};
}) {
const client = useSupabase();
const { t } = useTranslation('teams');
const createToaster = useCallback(
(promise: () => Promise<unknown>) => {
return toast.promise(promise, {
success: t(`updateTeamSuccessMessage`),
error: t(`updateTeamErrorMessage`),
loading: t(`updateTeamLoadingMessage`),
});
},
[t],
);
const onValueChange = useCallback(
(file: File | null) => {
const removeExistingStorageFile = () => {
if (props.account.pictureUrl) {
return (
deleteProfilePhoto(client, props.account.pictureUrl) ??
Promise.resolve()
);
}
return Promise.resolve();
};
if (file) {
const promise = () =>
removeExistingStorageFile().then(() =>
uploadUserProfilePhoto(client, file, props.account.id).then(
(pictureUrl) => {
return client
.from('accounts')
.update({
picture_url: pictureUrl,
})
.eq('id', props.account.id)
.throwOnError();
},
),
);
createToaster(promise);
} else {
const promise = () =>
removeExistingStorageFile().then(() => {
return client
.from('accounts')
.update({
picture_url: null,
})
.eq('id', props.account.id)
.throwOnError();
});
createToaster(promise);
}
},
[client, createToaster, props],
);
return (
<ImageUploader
value={props.account.pictureUrl}
onValueChange={onValueChange}
>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm'}>
<Trans i18nKey={'account:profilePictureHeading'} />
</span>
<span className={'text-xs'}>
<Trans i18nKey={'account:profilePictureSubheading'} />
</span>
</div>
</ImageUploader>
);
}
function deleteProfilePhoto(client: SupabaseClient, url: string) {
const bucket = client.storage.from(AVATARS_BUCKET);
const fileName = url.split('/').pop()?.split('?')[0];
if (!fileName) {
return;
}
return bucket.remove([fileName]);
}
async function uploadUserProfilePhoto(
client: SupabaseClient,
photoFile: File,
userId: string,
) {
const bytes = await photoFile.arrayBuffer();
const bucket = client.storage.from(AVATARS_BUCKET);
const extension = photoFile.name.split('.').pop();
const fileName = await getAvatarFileName(userId, extension);
const result = await bucket.upload(fileName, bytes);
if (!result.error) {
return bucket.getPublicUrl(fileName).data.publicUrl;
}
throw result.error;
}
async function getAvatarFileName(
userId: string,
extension: string | undefined,
) {
const { nanoid } = await import('nanoid');
const uniqueId = nanoid(16);
return `${userId}.${extension}?v=${uniqueId}`;
}

View File

@@ -1,14 +1,11 @@
'use client';
import { useCallback } from 'react';
import { useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { z } from 'zod';
import { useUpdateAccountData } from '@kit/accounts/hooks/use-update-account';
import { Button } from '@kit/ui/button';
import {
Form,
@@ -20,44 +17,42 @@ import {
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { updateTeamAccountName } from '../../server/actions/team-details-server-actions';
const Schema = z.object({
name: z.string().min(1).max(255),
});
export const UpdateOrganizationForm = (props: {
accountId: string;
accountName: string;
export const UpdateTeamAccountNameForm = (props: {
account: {
name: string;
slug: string;
};
path: string;
}) => {
const updateAccountData = useUpdateAccountData(props.accountId);
const { t } = useTranslation('organization');
const [pending, startTransition] = useTransition();
const form = useForm({
resolver: zodResolver(Schema),
defaultValues: {
name: props.accountName,
name: props.account.name,
},
});
const updateOrganizationData = useCallback(
(data: { name: string }) => {
const promise = updateAccountData.mutateAsync(data);
toast.promise(promise, {
loading: t(`updateOrganizationLoadingMessage`),
success: t(`updateOrganizationSuccessMessage`),
error: t(`updateOrganizationErrorMessage`),
});
},
[t, updateAccountData],
);
return (
<div className={'space-y-8'}>
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => {
updateOrganizationData(data);
startTransition(async () => {
await updateTeamAccountName({
slug: props.account.slug,
name: data.name,
path: props.path,
});
});
})}
>
<FormField
@@ -66,12 +61,12 @@ export const UpdateOrganizationForm = (props: {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:organizationNameInputLabel'} />
<Trans i18nKey={'teams:teamNameInputLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'organization-name-input'}
data-test={'team-name-input'}
required
placeholder={''}
{...field}
@@ -80,15 +75,15 @@ export const UpdateOrganizationForm = (props: {
</FormItem>
);
}}
></FormField>
/>
<div>
<Button
className={'w-full md:w-auto'}
data-test={'update-organization-submit-button'}
disabled={updateAccountData.isPending}
data-test={'update-team-submit-button'}
disabled={pending}
>
<Trans i18nKey={'teams:updateOrganizationSubmitLabel'} />
<Trans i18nKey={'teams:updateTeamSubmitLabel'} />
</Button>
</div>
</form>

View File

@@ -1,262 +0,0 @@
'use client';
import { useFormStatus } from 'react-dom';
import { Database } from '@kit/supabase/database';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { ErrorBoundary } from '@kit/ui/error-boundary';
import { Heading } from '@kit/ui/heading';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
import { deleteTeamAccountAction } from '../actions/delete-team-account-server-actions';
import { leaveTeamAccountAction } from '../actions/leave-team-account-server-actions';
type AccountData =
Database['public']['Functions']['organization_account_workspace']['Returns'][0];
export function TeamAccountDangerZone({
account,
userId,
}: React.PropsWithChildren<{
account: AccountData;
userId: string;
}>) {
const isPrimaryOwner = userId === account.primary_owner_user_id;
if (isPrimaryOwner) {
return <DeleteOrganizationContainer account={account} />;
}
return <LeaveOrganizationContainer account={account} />;
}
function DeleteOrganizationContainer(props: { account: AccountData }) {
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<Heading level={6}>
<Trans i18nKey={'teams:deleteOrganization'} />
</Heading>
<p className={'text-sm text-gray-500'}>
<Trans
i18nKey={'teams:deleteOrganizationDescription'}
values={{
organizationName: props.account.name,
}}
/>
</p>
</div>
<div>
<Dialog>
<DialogTrigger>
<Button
data-test={'delete-organization-button'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteOrganization'} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:deletingOrganization'} />
</DialogTitle>
</DialogHeader>
<DeleteOrganizationForm
name={props.account.name}
id={props.account.id}
/>
</DialogContent>
</Dialog>
</div>
</div>
);
}
function DeleteOrganizationForm({ name, id }: { name: string; id: string }) {
return (
<ErrorBoundary fallback={<DeleteOrganizationErrorAlert />}>
<form
className={'flex flex-col space-y-4'}
action={deleteTeamAccountAction}
>
<div className={'flex flex-col space-y-2'}>
<div
className={
'border-2 border-red-500 p-4 text-sm text-red-500' +
' flex flex-col space-y-2'
}
>
<div>
<Trans
i18nKey={'teams:deleteOrganizationDisclaimer'}
values={{
organizationName: name,
}}
/>
</div>
<div className={'text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
</div>
</div>
<input type="hidden" value={id} name={'id'} />
<Label>
<Trans i18nKey={'teams:organizationNameInputLabel'} />
<Input
name={'name'}
data-test={'delete-organization-input-field'}
required
type={'text'}
className={'w-full'}
placeholder={''}
pattern={name}
/>
<span className={'text-xs'}>
<Trans i18nKey={'teams:deleteOrganizationInputField'} />
</span>
</Label>
</div>
<div className={'flex justify-end space-x-2.5'}>
<DeleteOrganizationSubmitButton />
</div>
</form>
</ErrorBoundary>
);
}
function DeleteOrganizationSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-delete-organization-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteOrganization'} />
</Button>
);
}
function LeaveOrganizationContainer(props: { account: AccountData }) {
return (
<div className={'flex flex-col space-y-4'}>
<p>
<Trans
i18nKey={'teams:leaveOrganizationDescription'}
values={{
organizationName: props.account.name,
}}
/>
</p>
<div>
<Dialog>
<DialogTrigger>
<Button
data-test={'leave-organization-button'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:leaveOrganization'} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:leavingOrganizationModalHeading'} />
</DialogTitle>
</DialogHeader>
<ErrorBoundary fallback={<LeaveOrganizationErrorAlert />}>
<form action={leaveTeamAccountAction}>
<input type={'hidden'} value={props.account.id} name={'id'} />
<div className={'flex flex-col space-y-4'}>
<div>
<div>
<Trans
i18nKey={'teams:leaveOrganizationDisclaimer'}
values={{
organizationName: props.account?.name,
}}
/>
</div>
</div>
<div className={'flex justify-end space-x-2.5'}>
<LeaveOrganizationSubmitButton />
</div>
</div>
</form>
</ErrorBoundary>
</DialogContent>
</Dialog>
</div>
</div>
);
}
function LeaveOrganizationSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-leave-organization-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:leaveOrganization'} />
</Button>
);
}
function LeaveOrganizationErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:leaveOrganizationErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
);
}
function DeleteOrganizationErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteOrganizationErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -8,7 +8,7 @@ import { Logger } from '@kit/shared/logger';
import { requireAuth } from '@kit/supabase/require-auth';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { CreateTeamSchema } from '../schema/create-team.schema';
import { CreateTeamSchema } from '../../schema/create-team.schema';
import { CreateTeamAccountService } from '../services/create-team-account.service';
const TEAM_ACCOUNTS_HOME_PATH = z
@@ -45,10 +45,10 @@ export async function createOrganizationAccountAction(
error: createAccountResponse.error,
name: 'accounts',
},
`Error creating organization account`,
`Error creating team account`,
);
throw new Error('Error creating organization account');
throw new Error('Error creating team account');
}
const accountHomePath =

View File

@@ -0,0 +1,60 @@
'use server';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { requireAuth } from '@kit/supabase/require-auth';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { DeleteTeamAccountSchema } from '../../schema/delete-team-account.schema';
import { DeleteTeamAccountService } from '../services/delete-team-account.service';
export async function deleteTeamAccountAction(formData: FormData) {
const params = DeleteTeamAccountSchema.parse(
Object.fromEntries(formData.entries()),
);
const client = getSupabaseServerActionClient();
// Check if the user has the necessary permissions to delete the team account
await assertUserPermissionsToDeleteTeamAccount(client, params.accountId);
// Get the Supabase client and create a new service instance.
const service = new DeleteTeamAccountService();
// Delete the team account and all associated data.
await service.deleteTeamAccount(
getSupabaseServerActionClient({
admin: true,
}),
params,
);
return redirect('/home');
}
async function assertUserPermissionsToDeleteTeamAccount(
client: SupabaseClient<Database>,
accountId: string,
) {
const auth = await requireAuth(client);
if (auth.error ?? !auth.data.user.id) {
throw new Error('Authentication required');
}
const userId = auth.data.user.id;
const { data, error } = await client
.from('accounts')
.select('id')
.eq('primary_owner_user_id', userId)
.eq('is_personal_account', false)
.eq('id', accountId);
if (error ?? !data) {
throw new Error('Account not found');
}
}

View File

@@ -2,7 +2,7 @@
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { LeaveTeamAccountSchema } from '../schema/leave-team-account.schema';
import { LeaveTeamAccountSchema } from '../../schema/leave-team-account.schema';
import { LeaveAccountService } from '../services/leave-account.service';
export async function leaveTeamAccountAction(formData: FormData) {

View File

@@ -0,0 +1,39 @@
'use server';
import { redirect } from 'next/navigation';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
export async function updateTeamAccountName(params: {
name: string;
slug: string;
path: string;
}) {
const client = getSupabaseServerComponentClient();
const { error, data } = await client
.from('accounts')
.update({
name: params.name,
slug: params.slug,
})
.match({
slug: params.slug,
})
.select('slug')
.single();
if (error) {
throw error;
}
const newSlug = data.slug;
if (newSlug) {
const path = params.path.replace('[account]', newSlug);
redirect(path);
}
return { success: true };
}

View File

@@ -9,9 +9,9 @@ import { z } from 'zod';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { DeleteInvitationSchema } from '../schema/delete-invitation.schema';
import { InviteMembersSchema } from '../schema/invite-members.schema';
import { UpdateInvitationSchema } from '../schema/update-invitation-schema';
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation-schema';
import { AccountInvitationsService } from '../services/account-invitations.service';
/**

View File

@@ -7,9 +7,9 @@ import { Mailer } from '@kit/mailers';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { DeleteInvitationSchema } from '../schema/delete-invitation.schema';
import { InviteMembersSchema } from '../schema/invite-members.schema';
import { UpdateInvitationSchema } from '../schema/update-invitation-schema';
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation-schema';
const invitePath = process.env.INVITATION_PAGE_PATH;
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
@@ -149,16 +149,14 @@ export class AccountInvitationsService {
for (const invitation of responseInvitations) {
const promise = async () => {
try {
const { renderInviteEmail } = await import(
'../../../../email-templates'
);
const { renderInviteEmail } = await import('@kit/email-templates');
const html = renderInviteEmail({
link: this.getInvitationLink(invitation.invite_token),
invitedUserEmail: invitation.email,
inviter: user.email,
productName: env.productName,
organizationName: accountResponse.data.name,
teamName: accountResponse.data.name,
});
await mailer.sendEmail({

View File

@@ -11,7 +11,7 @@ export class CreateTeamAccountService {
createNewOrganizationAccount(params: { name: string; userId: string }) {
Logger.info(
{ ...params, namespace: this.namespace },
`Creating new organization account...`,
`Creating new team account...`,
);
return this.client.rpc('create_account', {

View File

@@ -0,0 +1,73 @@
import { SupabaseClient } from '@supabase/supabase-js';
import 'server-only';
import { AccountBillingService } from '@kit/billing-gateway';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
export class DeleteTeamAccountService {
private readonly namespace = 'accounts.delete';
/**
* Deletes a team account. Permissions are not checked here, as they are
* checked in the server action.
*
* USE WITH CAUTION. THE USER MUST HAVE THE NECESSARY PERMISSIONS.
*
* @param adminClient
* @param params
*/
async deleteTeamAccount(
adminClient: SupabaseClient<Database>,
params: { accountId: string },
) {
Logger.info(
{
name: this.namespace,
accountId: params.accountId,
},
`Requested team account deletion. Processing...`,
);
Logger.info(
{
name: this.namespace,
accountId: params.accountId,
},
`Deleting all account subscriptions...`,
);
// First - we want to cancel all Stripe active subscriptions
const billingService = new AccountBillingService(adminClient);
await billingService.cancelAllAccountSubscriptions(params.accountId);
// now we can use the admin client to delete the account.
const { error } = await adminClient
.from('accounts')
.delete()
.eq('id', params.accountId);
if (error) {
Logger.error(
{
name: this.namespace,
accountId: params.accountId,
error,
},
'Failed to delete team account',
);
throw new Error('Failed to delete team account');
}
Logger.info(
{
name: this.namespace,
accountId: params.accountId,
},
'Successfully deleted team account',
);
}
}

View File

@@ -1,14 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js';
import 'server-only';
import { Database } from '@kit/supabase/database';
export class DeleteTeamAccountService {
constructor(private readonly client: SupabaseClient<Database>) {}
async deleteTeamAccount(params: { accountId: string }) {
// TODO
// implement this method
}
}

View File

@@ -16,11 +16,11 @@
"./provider": "./src/I18nProvider.tsx"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/shared": "^0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:^",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"i18next": "^23.10.1",
"i18next-browser-languagedetector": "7.2.0",
"i18next-resources-to-backend": "^1.2.0",

View File

@@ -16,10 +16,10 @@
"nodemailer": "^6.9.13"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/nodemailer": "6.4.14"
},
"eslintConfig": {

View File

@@ -20,10 +20,10 @@
"pino": "^8.19.0"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0"
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*"
},
"eslintConfig": {
"root": true,

View File

@@ -22,18 +22,18 @@
},
"dependencies": {
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.0.10",
"stripe": "^14.21.0"
"@stripe/stripe-js": "^3.1.0",
"stripe": "^14.22.0"
},
"devDependencies": {
"@kit/billing": "*",
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/shared": "*",
"@kit/supabase": "*",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*"
"@kit/billing": "^0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "^0.1.0",
"@kit/supabase": "^0.1.0",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "^0.1.0"
},
"eslintConfig": {
"root": true,

View File

@@ -24,10 +24,10 @@
},
"devDependencies": {
"@epic-web/invariant": "^1.0.0",
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.40.0",
"@tanstack/react-query": "5.28.6"

View File

@@ -28,10 +28,10 @@
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "1.0.7",
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
"cmdk": "1.0.0",
"input-otp": "1.2.3",
"react-top-loading-bar": "2.3.1",
"tailwind-merge": "^2.2.0"
"tailwind-merge": "^2.2.2"
},
"peerDependencies": {
"@radix-ui/react-icons": "1.3.0",
@@ -45,26 +45,26 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@radix-ui/react-icons": "^1.3.0",
"@tanstack/react-table": "^8.11.3",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@tanstack/react-table": "^8.15.0",
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.22",
"class-variance-authority": "^0.7.0",
"date-fns": "^3.2.0",
"eslint": "^8.56.0",
"date-fns": "^3.6.0",
"eslint": "^8.57.0",
"lucide-react": "^0.363.0",
"prettier": "^3.2.4",
"prettier": "^3.2.5",
"react-day-picker": "^8.10.0",
"react-hook-form": "^7.51.2",
"react-i18next": "^14.1.0",
"sonner": "^1.4.41",
"tailwindcss": "3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.3.3",
"typescript": "^5.4.3",
"zod": "^3.22.4"
},
"eslintConfig": {