Update account management features and improve test configurations

Multiple updates are made to refine the account management features, including updating the 'Your Teams' text to show the number of teams, and modifying the form data validation process in the 'deletePersonalAccountAction' service. Additionally, improvements have been made in test configurations including updating the test timeout settings, taking screenshots when a test fails, and adjusting the location for saving Playwright reports.
This commit is contained in:
giancarlo
2024-04-12 20:52:35 +08:00
parent 24a6190f51
commit 2bad506d75
15 changed files with 93 additions and 51 deletions

View File

@@ -95,5 +95,5 @@ jobs:
if: always() if: always()
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: apps/e2e/playwright-report/
retention-days: 7 retention-days: 7

View File

@@ -25,6 +25,9 @@ export default defineConfig({
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000', baseURL: 'http://localhost:3000',
// take a screenshot when a test fails
screenshot: "on",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
}, },

View File

@@ -55,7 +55,9 @@ test.describe('Account Deletion', () => {
await account.setup(); await account.setup();
await account.deleteAccount(); await account.deleteAccount();
await page.waitForURL('http://localhost:3000'); await page.waitForURL('http://localhost:3000', {
timeout: 5000,
});
expect(page.url()).toEqual('http://localhost:3000/'); expect(page.url()).toEqual('http://localhost:3000/');
}); });

View File

@@ -38,7 +38,9 @@ export class TeamAccountsPageObject {
await this.page.fill('[data-test="create-team-form"] input', teamName); await this.page.fill('[data-test="create-team-form"] input', teamName);
await this.page.click('[data-test="create-team-form"] button:last-child'); await this.page.click('[data-test="create-team-form"] button:last-child');
await this.page.waitForURL(`http://localhost:3000/home/${slug}`); await this.page.waitForURL(`http://localhost:3000/home/${slug}`, {
timeout: 5000,
});
} }
async updateName(name: string) { async updateName(name: string) {
@@ -56,7 +58,7 @@ export class TeamAccountsPageObject {
} }
createTeamName() { createTeamName() {
const random = (Math.random() * 1000000000).toFixed(0); const random = (Math.random() * 10).toFixed(0);
const teamName = `Team-Name-${random}`; const teamName = `Team-Name-${random}`;
const slug = `team-name-${random}`; const slug = `team-name-${random}`;

View File

@@ -35,7 +35,9 @@ test.describe('Account Deletion', () => {
await teamAccounts.deleteAccount(params.teamName); await teamAccounts.deleteAccount(params.teamName);
await page.waitForURL('http://localhost:3000/home'); await page.waitForURL('http://localhost:3000/home', {
timeout: 5000,
});
expect(page.url()).toEqual('http://localhost:3000/home'); expect(page.url()).toEqual('http://localhost:3000/home');

View File

@@ -18,7 +18,7 @@
"billing": { "billing": {
"pageTitle": "Billing" "pageTitle": "Billing"
}, },
"yourTeams": "Your Teams", "yourTeams": "Your Teams ({{teamsCount}})",
"createTeam": "Create a Team", "createTeam": "Create a Team",
"personalAccount": "Personal Account", "personalAccount": "Personal Account",
"searchAccount": "Search Account...", "searchAccount": "Search Account...",

View File

@@ -16,8 +16,6 @@ create extension if not exists "unaccent";
-- Create a private Makerkit schema -- Create a private Makerkit schema
create schema if not exists kit; create schema if not exists kit;
grant USAGE on schema kit to authenticated, authenticated;
-- We remove all default privileges from public schema on functions to -- We remove all default privileges from public schema on functions to
-- prevent public access to them -- prevent public access to them
alter default privileges revoke execute on functions from public; alter default privileges revoke execute on functions from public;
@@ -1595,6 +1593,8 @@ grant execute on function kit.slugify(text) to service_role, authenticated;
create or replace function kit.set_slug_from_account_name() create or replace function kit.set_slug_from_account_name()
returns trigger returns trigger
language plpgsql language plpgsql
security definer
set search_path = public
as $$ as $$
declare declare
sql_string varchar; sql_string varchar;
@@ -1616,7 +1616,7 @@ begin
end if; end if;
sql_string = format('select count(1) cnt from accounts where slug = ''' || tmp_slug || sql_string = format('select count(1) cnt from public.accounts where slug = ''' || tmp_slug ||
'''; '); '''; ');
for tmp_row in execute (sql_string) for tmp_row in execute (sql_string)

View File

@@ -23,6 +23,7 @@
"supabase:web:start": "pnpm --filter web supabase:start", "supabase:web:start": "pnpm --filter web supabase:start",
"supabase:web:stop": "pnpm --filter web supabase:stop", "supabase:web:stop": "pnpm --filter web supabase:stop",
"supabase:web:typegen": "pnpm --filter web supabase:typegen", "supabase:web:typegen": "pnpm --filter web supabase:typegen",
"supabase:web:reset": "pnpm --filter web supabase:reset",
"stripe:listen": "pnpm --filter '@kit/stripe' start" "stripe:listen": "pnpm --filter '@kit/stripe' start"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",

View File

@@ -18,6 +18,7 @@ import {
} from '@kit/ui/command'; } from '@kit/ui/command';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
@@ -177,7 +178,14 @@ export function AccountSelector({
<If condition={features.enableTeamAccounts}> <If condition={features.enableTeamAccounts}>
<If condition={accounts.length > 0}> <If condition={accounts.length > 0}>
<CommandGroup heading={<Trans i18nKey={'teams:yourTeams'} />}> <CommandGroup
heading={
<Trans
i18nKey={'teams:yourTeams'}
values={{ teamsCount: accounts.length }}
/>
}
>
{(accounts ?? []).map((account) => ( {(accounts ?? []).map((account) => (
<CommandItem <CommandItem
data-test={'account-selector-team-' + account.value} data-test={'account-selector-team-' + account.value}
@@ -218,13 +226,14 @@ export function AccountSelector({
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
</If>
</If>
</CommandList>
</Command>
<CommandSeparator /> <Separator />
</If>
</If>
<If condition={features.enableTeamCreation}> <If condition={features.enableTeamCreation}>
<CommandGroup>
<Button <Button
data-test={'create-team-account-trigger'} data-test={'create-team-account-trigger'}
variant="ghost" variant="ghost"
@@ -240,10 +249,7 @@ export function AccountSelector({
<Trans i18nKey={'teams:createTeam'} /> <Trans i18nKey={'teams:createTeam'} />
</span> </span>
</Button> </Button>
</CommandGroup>
</If> </If>
</CommandList>
</Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -5,7 +5,6 @@ import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
@@ -23,6 +22,7 @@ import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
import { deletePersonalAccountAction } from '../../server/personal-accounts-server-actions'; import { deletePersonalAccountAction } from '../../server/personal-accounts-server-actions';
export function AccountDangerZone() { export function AccountDangerZone() {
@@ -71,11 +71,7 @@ function DeleteAccountModal() {
function DeleteAccountForm() { function DeleteAccountForm() {
const form = useForm({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(DeletePersonalAccountSchema),
z.object({
confirmation: z.string().refine((value) => value === 'DELETE'),
}),
),
defaultValues: { defaultValues: {
confirmation: '', confirmation: '',
}, },
@@ -140,11 +136,10 @@ function DeleteAccountSubmitButton() {
return ( return (
<Button <Button
data-test={'confirm-delete-account-button'}
type={'submit'} type={'submit'}
disabled={pending} disabled={pending}
data-test={'confirm-delete-account-button'}
name={'action'} name={'action'}
value={'delete'}
variant={'destructive'} variant={'destructive'}
> >
<Trans i18nKey={'account:deleteAccount'} /> <Trans i18nKey={'account:deleteAccount'} />

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const DeletePersonalAccountSchema = z.object({
confirmation: z.string().refine((value) => value === 'DELETE'),
});

View File

@@ -1,5 +1,6 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache';
import { RedirectType, redirect } from 'next/navigation'; import { RedirectType, redirect } from 'next/navigation';
import { z } from 'zod'; import { z } from 'zod';
@@ -8,6 +9,7 @@ import { getLogger } from '@kit/shared/logger';
import { requireUser } from '@kit/supabase/require-user'; import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { DeletePersonalAccountSchema } from '../schema/delete-personal-account.schema';
import { DeletePersonalAccountService } from './services/delete-personal-account.service'; import { DeletePersonalAccountService } from './services/delete-personal-account.service';
const emailSettings = getEmailSettingsFromEnvironment(); const emailSettings = getEmailSettingsFromEnvironment();
@@ -21,10 +23,13 @@ export async function refreshAuthSession() {
} }
export async function deletePersonalAccountAction(formData: FormData) { export async function deletePersonalAccountAction(formData: FormData) {
const confirmation = formData.get('confirmation'); // validate the form data
const { success } = DeletePersonalAccountSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (confirmation !== 'DELETE') { if (!success) {
throw new Error('Confirmation required to delete account'); throw new Error('Invalid form data');
} }
const client = getSupabaseServerActionClient(); const client = getSupabaseServerActionClient();
@@ -32,7 +37,13 @@ export async function deletePersonalAccountAction(formData: FormData) {
if (auth.error) { if (auth.error) {
const logger = await getLogger(); const logger = await getLogger();
logger.error(`User is not authenticated. Redirecting to login page`);
logger.error(
{
error: auth.error,
},
`User is not authenticated. Redirecting to login page.`,
);
redirect(auth.redirectTo); redirect(auth.redirectTo);
} }
@@ -55,6 +66,8 @@ export async function deletePersonalAccountAction(formData: FormData) {
// sign out the user after deleting their account // sign out the user after deleting their account
await client.auth.signOut(); await client.auth.signOut();
revalidatePath('/', 'layout');
// redirect to the home page // redirect to the home page
redirect('/', RedirectType.replace); redirect('/', RedirectType.replace);
} }

View File

@@ -35,11 +35,15 @@ export class DeletePersonalAccountService {
productName: string; productName: string;
}; };
}) { }) {
const userId = params.userId;
const logger = await getLogger(); const logger = await getLogger();
const userId = params.userId;
const ctx = { userId, name: this.namespace }; const ctx = { userId, name: this.namespace };
logger.info(ctx, 'User requested deletion. Processing...'); logger.info(
ctx,
'User requested to delete their personal account. Processing...',
);
// execute the deletion of the user // execute the deletion of the user
try { try {
@@ -50,12 +54,12 @@ export class DeletePersonalAccountService {
...ctx, ...ctx,
error, error,
}, },
'Error deleting user', 'Encountered an error deleting user',
); );
throw new Error('Error deleting user'); throw new Error('Error deleting user');
} }
logger.info(ctx, 'User deleted successfully'); logger.info(ctx, 'User successfully deleted!');
} }
} }

View File

@@ -4,6 +4,8 @@ import { useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -28,6 +30,7 @@ export const UpdateTeamAccountNameForm = (props: {
path: string; path: string;
}) => { }) => {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const { t } = useTranslation('teams');
const form = useForm({ const form = useForm({
resolver: zodResolver(TeamNameFormSchema), resolver: zodResolver(TeamNameFormSchema),
@@ -43,12 +46,18 @@ export const UpdateTeamAccountNameForm = (props: {
data-test={'update-team-account-name-form'} data-test={'update-team-account-name-form'}
className={'flex flex-col space-y-4'} className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit((data) => {
startTransition(async () => { startTransition(() => {
await updateTeamAccountName({ const promise = updateTeamAccountName({
slug: props.account.slug, slug: props.account.slug,
name: data.name, name: data.name,
path: props.path, path: props.path,
}); });
toast.promise(promise, {
loading: t('updateTeamLoadingMessage'),
success: t('updateTeamSuccessMessage'),
error: t('updateTeamErrorMessage'),
});
}); });
})} })}
> >

View File

@@ -6,7 +6,7 @@ import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
export class DeleteTeamAccountService { export class DeleteTeamAccountService {
private readonly namespace = 'accounts.delete'; private readonly namespace = 'accounts.delete-team-account';
/** /**
* Deletes a team account. Permissions are not checked here, as they are * Deletes a team account. Permissions are not checked here, as they are