Update dependencies and enhance admin account page

This commit updates various dependencies in pnpm-lock file and introduces enhancements to the admin account page. This includes adding several new functionality like 'Delete User', 'Ban User', 'Impersonate User' and 'Delete Account'. Various UI components are also added for these features.
This commit is contained in:
giancarlo
2024-04-09 15:26:31 +08:00
parent e7f2660032
commit 19c16cfb44
15 changed files with 1150 additions and 244 deletions

View File

@@ -1,10 +1,29 @@
import { Database } from '@kit/supabase/database';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { PageBody, PageHeader } from '@kit/ui/page';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { Separator } from '@kit/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import { AdminBanUserDialog } from './admin-ban-user-dialog';
import { AdminDeleteAccountDialog } from './admin-delete-account-dialog';
import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
import { AdminMembersTable } from './admin-members-table';
import { AdminMembershipsTable } from './admin-memberships-table';
import { AdminReactivateUserDialog } from './admin-reactivate-user-dialog';
type Db = Database['public']['Tables'];
type Account = Db['accounts']['Row'];
@@ -21,21 +40,71 @@ export function AdminAccountPage(props: {
}
async function PersonalAccountPage(props: { account: Account }) {
const client = getSupabaseServerComponentClient({
admin: true,
});
const memberships = await getMemberships(props.account.id);
const { data, error } = await client.auth.admin.getUserById(props.account.id);
if (!data || error) {
throw new Error(`User not found`);
}
const isBanned =
'banned_until' in data.user && data.user.banned_until !== 'none';
return (
<>
<PageHeader
title={props.account.name}
description={`Manage ${props.account.name}'s account details and settings.`}
/>
title={
<div className={'flex items-center space-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<span>{props.account.name}</span>
<Badge variant={'outline'}>Personal Account</Badge>
</div>
}
>
<div className={'flex space-x-2'}>
<AdminImpersonateUserDialog userId={props.account.id}>
<Button variant={'ghost'}>Impersonate</Button>
</AdminImpersonateUserDialog>
<If condition={isBanned}>
<AdminReactivateUserDialog userId={props.account.id}>
<Button variant={'ghost'}>Reactivate</Button>
</AdminReactivateUserDialog>
</If>
<If condition={!isBanned}>
<AdminBanUserDialog userId={props.account.id}>
<Button variant={'ghost'}>Ban</Button>
</AdminBanUserDialog>
</If>
<AdminDeleteUserDialog userId={props.account.id}>
<Button variant={'destructive'}>Delete</Button>
</AdminDeleteUserDialog>
</div>
</PageHeader>
<PageBody>
<div className={'divider-divider-x flex flex-col space-y-4'}>
<Heading level={4}>Memberships</Heading>
<div className={'flex flex-col space-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div>
<AdminMembershipsTable memberships={memberships} />
<div className={'divider-divider-x flex flex-col space-y-2.5'}>
<Heading level={6}>
This user is a member of the following teams:
</Heading>
<div>
<AdminMembershipsTable memberships={memberships} />
</div>
</div>
</div>
</PageBody>
@@ -51,17 +120,180 @@ async function TeamAccountPage(props: {
return (
<>
<PageHeader
title={props.account.name}
description={`Manage ${props.account.name}'s account details and settings.`}
/>
title={
<div className={'flex items-center space-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<span>{props.account.name}</span>
<Badge variant={'outline'}>Team Account</Badge>
</div>
}
>
<AdminDeleteAccountDialog accountId={props.account.id}>
<Button variant={'destructive'}>Delete</Button>
</AdminDeleteAccountDialog>
</PageHeader>
<PageBody>
<AdminMembersTable members={members} />
<div className={'flex flex-col space-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<Separator />
<div className={'flex flex-col space-y-2.5'}>
<Heading level={6}>This team has the following members:</Heading>
<AdminMembersTable members={members} />
</div>
</div>
</PageBody>
</>
);
}
async function SubscriptionsTable(props: { accountId: string }) {
const client = getSupabaseServerComponentClient({
admin: true,
});
const { data: subscription, error } = await client
.from('subscriptions')
.select('*, subscription_items !inner (*)')
.eq('account_id', props.accountId)
.maybeSingle();
if (error) {
return (
<Alert variant={'destructive'}>
<AlertTitle>There was an error loading subscription.</AlertTitle>
<AlertDescription>
Please check the logs for more information or try again later.
</AlertDescription>
</Alert>
);
}
return (
<div className={'flex flex-col space-y-2.5'}>
<Heading level={6}>Subscription</Heading>
<If
condition={subscription}
fallback={<>This account does not have an active subscription.</>}
>
{(subscription) => {
return (
<div className={'flex flex-col space-y-4'}>
<Table>
<TableHeader>
<TableHead>Subscription ID</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Customer ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Period Starts At</TableHead>
<TableHead>Ends At</TableHead>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<span>{subscription.id}</span>
</TableCell>
<TableCell>
<span>{subscription.billing_provider}</span>
</TableCell>
<TableCell>
<span>{subscription.billing_customer_id}</span>
</TableCell>
<TableCell>
<span>{subscription.status}</span>
</TableCell>
<TableCell>
<span>{subscription.created_at}</span>
</TableCell>
<TableCell>
<span>{subscription.period_starts_at}</span>
</TableCell>
<TableCell>
<span>{subscription.period_ends_at}</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Table>
<TableHeader>
<TableHead>Product ID</TableHead>
<TableHead>Variant ID</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Type</TableHead>
</TableHeader>
<TableBody>
{subscription.subscription_items.map((item) => {
return (
<TableRow key={item.variant_id}>
<TableCell>
<span>{item.product_id}</span>
</TableCell>
<TableCell>
<span>{item.variant_id}</span>
</TableCell>
<TableCell>
<span>{item.quantity}</span>
</TableCell>
<TableCell>
<span>{item.price_amount}</span>
</TableCell>
<TableCell>
<span>{item.interval}</span>
</TableCell>
<TableCell>
<span>{item.type}</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}}
</If>
</div>
);
}
async function getMemberships(userId: string) {
const client = getSupabaseServerComponentClient({
admin: true,

View File

@@ -33,6 +33,10 @@ import {
SelectValue,
} from '@kit/ui/select';
import { AdminDeleteAccountDialog } from './admin-delete-account-dialog';
import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
type Account = Database['public']['Tables']['accounts']['Row'];
const FiltersSchema = z.object({
@@ -194,6 +198,7 @@ function getColumns(): ColumnDef<Account>[] {
header: '',
cell: ({ row }) => {
const isPersonalAccount = row.original.is_personal_account;
const userId = row.original.id;
return (
<DropdownMenu>
@@ -208,18 +213,35 @@ function getColumns(): ColumnDef<Account>[] {
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>
<Link href={`/admin/accounts/${row.original.id}`}>View</Link>
<Link
className={'h-full w-full'}
href={`/admin/accounts/${userId}`}
>
View
</Link>
</DropdownMenuItem>
<If condition={isPersonalAccount}>
<DropdownMenuItem className={'text-orange-800'}>
Ban
</DropdownMenuItem>
<AdminImpersonateUserDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Impersonate User
</DropdownMenuItem>
</AdminImpersonateUserDialog>
<AdminDeleteUserDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete Personal Account
</DropdownMenuItem>
</AdminDeleteUserDialog>
</If>
<DropdownMenuItem className={'text-destructive'}>
Delete
</DropdownMenuItem>
<If condition={!isPersonalAccount}>
<AdminDeleteAccountDialog accountId={row.original.id}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete Team Account
</DropdownMenuItem>
</AdminDeleteAccountDialog>
</If>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,100 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { banUser } from '../lib/server/admin-server-actions';
import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminBanUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Ban User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to ban this user?
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit(banUser)}
>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'} variant={'destructive'}>
Ban User
</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { deleteAccount } from '../lib/server/admin-server-actions';
import { DeleteAccountSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteAccountDialog(
props: React.PropsWithChildren<{
accountId: string;
}>,
) {
const form = useForm({
resolver: zodResolver(DeleteAccountSchema),
defaultValues: {
accountId: props.accountId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this account? All the data
associated with this account will be permanently deleted. Any active
subscriptions will be canceled.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit(deleteAccount)}
>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
pattern={'CONFIRM'}
required
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this? This action cannot be
undone.
</FormDescription>
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'} variant={'destructive'}>
Delete
</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { deleteUser } from '../lib/server/admin-server-actions';
import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this user? All the data associated
with this user will be permanently deleted. Any active subscriptions
will be canceled.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit(deleteUser)}
>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this? This action cannot be
undone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'} variant={'destructive'}>
Delete
</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { impersonateUser } from '../lib/server/admin-server-actions';
import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminImpersonateUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
const [tokens, setTokens] = useState<{
accessToken: string;
refreshToken: string;
}>();
if (tokens) {
return (
<>
<ImpersonateUserAuthSetter tokens={tokens} />
<LoadingOverlay>Setting up your session...</LoadingOverlay>
</>
);
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Impersonate User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to impersonate this user? You will be logged
in as this user. To stop impersonating, log out.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit(async (data) => {
const tokens = await impersonateUser(data);
setTokens(tokens);
})}
>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to impersonate this user?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'}>Impersonate User</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}
function ImpersonateUserAuthSetter({
tokens,
}: React.PropsWithChildren<{
tokens: {
accessToken: string;
refreshToken: string;
};
}>) {
useSetSession(tokens);
return <LoadingOverlay>Setting up your session...</LoadingOverlay>;
}
function useSetSession(tokens: { accessToken: string; refreshToken: string }) {
const supabase = useSupabase();
const router = useRouter();
return useQuery({
queryKey: ['impersonate-user', tokens.accessToken, tokens.refreshToken],
queryFn: async () => {
await supabase.auth.setSession({
refresh_token: tokens.refreshToken,
access_token: tokens.accessToken,
});
router.push('/home');
},
});
}

View File

@@ -0,0 +1,96 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { reactivateUser } from '../lib/server/admin-server-actions';
import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminReactivateUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reactivate User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reactivate this user?
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit(reactivateUser)}
>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'}>Reactivate User</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}