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:
@@ -11,6 +11,7 @@
|
||||
"prettier": "@kit/prettier-config",
|
||||
"peerDependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@makerkit/data-loader-supabase-core": "0.0.5",
|
||||
@@ -22,6 +23,7 @@
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/next": "*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/supabase": "workspace:^",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
100
packages/features/admin/src/components/admin-ban-user-dialog.tsx
Normal file
100
packages/features/admin/src/components/admin-ban-user-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
127
packages/features/admin/src/lib/server/admin-server-actions.ts
Normal file
127
packages/features/admin/src/lib/server/admin-server-actions.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { enhanceAdminAction } from './enhance-admin-action';
|
||||
import {
|
||||
BanUserSchema,
|
||||
DeleteAccountSchema,
|
||||
DeleteUserSchema,
|
||||
ImpersonateUserSchema,
|
||||
ReactivateUserSchema,
|
||||
} from './schema/admin-actions.schema';
|
||||
import { AdminAccountsService } from './services/admin-accounts.service';
|
||||
import { AdminAuthUserService } from './services/admin-auth-user.service';
|
||||
|
||||
/**
|
||||
* @name banUser
|
||||
* @description Ban a user from the system.
|
||||
*/
|
||||
export const banUser = enhanceAdminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
|
||||
await service.banUser(userId);
|
||||
|
||||
revalidateAdmin();
|
||||
},
|
||||
{
|
||||
schema: BanUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name reactivateUser
|
||||
* @description Reactivate a user in the system.
|
||||
*/
|
||||
export const reactivateUser = enhanceAdminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
|
||||
await service.reactivateUser(userId);
|
||||
|
||||
revalidateAdmin();
|
||||
},
|
||||
{
|
||||
schema: ReactivateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name impersonateUser
|
||||
* @description Impersonate a user in the system.
|
||||
*/
|
||||
export const impersonateUser = enhanceAdminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
|
||||
return await service.impersonateUser(userId);
|
||||
},
|
||||
{
|
||||
schema: ImpersonateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteUser
|
||||
* @description Delete a user from the system.
|
||||
*/
|
||||
export const deleteUser = enhanceAdminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
|
||||
await service.deleteUser(userId);
|
||||
|
||||
revalidateAdmin();
|
||||
},
|
||||
{
|
||||
schema: DeleteUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteAccount
|
||||
* @description Delete an account from the system.
|
||||
*/
|
||||
export const deleteAccount = enhanceAdminAction(
|
||||
enhanceAction(
|
||||
async ({ accountId }) => {
|
||||
const service = getAdminAccountsService();
|
||||
|
||||
await service.deleteAccount(accountId);
|
||||
|
||||
revalidateAdmin();
|
||||
},
|
||||
{
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function getAdminAuthService() {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const adminClient = getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
return new AdminAuthUserService(client, adminClient);
|
||||
}
|
||||
|
||||
function getAdminAccountsService() {
|
||||
const adminClient = getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
return new AdminAccountsService(adminClient);
|
||||
}
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath('/admin', 'layout');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { isSuperAdmin } from './is-super-admin';
|
||||
|
||||
export function enhanceAdminAction<Args, Response>(
|
||||
fn: (params: Args) => Response,
|
||||
) {
|
||||
return async (params: Args) => {
|
||||
const isAdmin = await isSuperAdmin(getSupabaseServerActionClient());
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return fn(params);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const confirmationSchema = z.object({
|
||||
confirmation: z.custom((value) => value === 'CONFIRM'),
|
||||
});
|
||||
|
||||
const UserIdSchema = confirmationSchema.extend({
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const BanUserSchema = UserIdSchema;
|
||||
export const ReactivateUserSchema = UserIdSchema;
|
||||
export const ImpersonateUserSchema = UserIdSchema;
|
||||
export const DeleteUserSchema = UserIdSchema;
|
||||
|
||||
export const DeleteAccountSchema = confirmationSchema.extend({
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export class AdminAccountsService {
|
||||
constructor(private adminClient: SupabaseClient<Database>) {}
|
||||
|
||||
async deleteAccount(accountId: string) {
|
||||
const { error } = await this.adminClient
|
||||
.from('accounts')
|
||||
.delete()
|
||||
.eq('id', accountId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name AdminAuthUserService
|
||||
* @description Service for performing admin actions on users in the system.
|
||||
* This service only interacts with the Supabase Auth Admin API.
|
||||
*/
|
||||
export class AdminAuthUserService {
|
||||
constructor(
|
||||
private readonly client: SupabaseClient<Database>,
|
||||
private readonly adminClient: SupabaseClient<Database>,
|
||||
) {}
|
||||
|
||||
async assertUserIsNotCurrentSuperAdmin(targetUserId: string) {
|
||||
const { data: user } = await this.client.auth.getUser();
|
||||
const currentUserId = user.user?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new Error(
|
||||
`You cannot perform a destructive action on your own account as a Super Admin`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
const deleteUserResponse =
|
||||
await this.adminClient.auth.admin.deleteUser(userId);
|
||||
|
||||
if (deleteUserResponse.error) {
|
||||
throw new Error(`Error deleting user record or auth record.`);
|
||||
}
|
||||
}
|
||||
|
||||
async banUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
return this.setBanDuration(userId, `876600h`);
|
||||
}
|
||||
|
||||
async reactivateUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
return this.setBanDuration(userId, `none`);
|
||||
}
|
||||
|
||||
async impersonateUser(userId: string) {
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await this.adminClient.auth.admin.getUserById(userId);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
const email = user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`User has no email. Cannot impersonate`);
|
||||
}
|
||||
|
||||
const { error: linkError, data } =
|
||||
await this.adminClient.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email,
|
||||
options: {
|
||||
redirectTo: `/`,
|
||||
},
|
||||
});
|
||||
|
||||
if (linkError ?? !data) {
|
||||
throw new Error(`Error generating magic link`);
|
||||
}
|
||||
|
||||
const response = await fetch(data.properties?.action_link, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
const location = response.headers.get('Location');
|
||||
|
||||
if (!location) {
|
||||
throw new Error(`Error generating magic link. Location header not found`);
|
||||
}
|
||||
|
||||
const hash = new URL(location).hash.substring(1);
|
||||
const query = new URLSearchParams(hash);
|
||||
const accessToken = query.get('access_token');
|
||||
const refreshToken = query.get('refresh_token');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error(
|
||||
`Error generating magic link. Tokens not found in URL hash.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async setBanDuration(userId: string, banDuration: string) {
|
||||
await this.adminClient.auth.admin.updateUserById(userId, {
|
||||
ban_duration: banDuration,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
248
pnpm-lock.yaml
generated
248
pnpm-lock.yaml
generated
@@ -191,7 +191,7 @@ importers:
|
||||
version: 18.2.22
|
||||
autoprefixer:
|
||||
specifier: ^10.4.19
|
||||
version: 10.4.19(postcss@8.4.38)
|
||||
version: 10.4.19(postcss@8.4.33)
|
||||
dotenv-cli:
|
||||
specifier: ^7.4.1
|
||||
version: 7.4.1
|
||||
@@ -544,6 +544,9 @@ importers:
|
||||
'@kit/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../tooling/eslint
|
||||
'@kit/next':
|
||||
specifier: '*'
|
||||
version: link:../../next
|
||||
'@kit/prettier-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../tooling/prettier
|
||||
@@ -2032,16 +2035,6 @@ packages:
|
||||
eslint: 8.57.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
/@eslint-community/eslint-utils@4.4.0(eslint@9.0.0):
|
||||
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
dependencies:
|
||||
eslint: 9.0.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
dev: false
|
||||
|
||||
/@eslint-community/regexpp@4.10.0:
|
||||
resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==}
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
@@ -2062,32 +2055,10 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/@eslint/eslintrc@3.0.2:
|
||||
resolution: {integrity: sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
debug: 4.3.4
|
||||
espree: 10.0.1
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.1
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.2
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/@eslint/js@8.57.0:
|
||||
resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
/@eslint/js@9.0.0:
|
||||
resolution: {integrity: sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
dev: false
|
||||
|
||||
/@fal-works/esbuild-plugin-global-externals@2.1.2:
|
||||
resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==}
|
||||
dev: false
|
||||
@@ -2161,17 +2132,6 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/@humanwhocodes/config-array@0.12.3:
|
||||
resolution: {integrity: sha512-jsNnTBlMWuTpDkeE3on7+dWJi0D6fdDfeANj/w7MpS8ztROCoLvIO2nG0CcFj+E4k8j4QrSTh4Oryi3i2G669g==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.3.4
|
||||
minimatch: 3.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/@humanwhocodes/module-importer@1.0.1:
|
||||
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
|
||||
engines: {node: '>=12.22'}
|
||||
@@ -2179,10 +2139,6 @@ packages:
|
||||
/@humanwhocodes/object-schema@2.0.2:
|
||||
resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==}
|
||||
|
||||
/@humanwhocodes/object-schema@2.0.3:
|
||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||
dev: false
|
||||
|
||||
/@ianvs/prettier-plugin-sort-imports@4.2.1(prettier@3.2.5):
|
||||
resolution: {integrity: sha512-NKN1LVFWUDGDGr3vt+6Ey3qPeN/163uR1pOPAlkWpgvAqgxQ6kSdUf1F0it8aHUtKRUzEGcK38Wxd07O61d7+Q==}
|
||||
peerDependencies:
|
||||
@@ -6718,23 +6674,6 @@ packages:
|
||||
picocolors: 1.0.0
|
||||
postcss: 8.4.33
|
||||
postcss-value-parser: 4.2.0
|
||||
dev: false
|
||||
|
||||
/autoprefixer@10.4.19(postcss@8.4.38):
|
||||
resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
dependencies:
|
||||
browserslist: 4.23.0
|
||||
caniuse-lite: 1.0.30001600
|
||||
fraction.js: 4.3.7
|
||||
normalize-range: 0.1.2
|
||||
picocolors: 1.0.0
|
||||
postcss: 8.4.38
|
||||
postcss-value-parser: 4.2.0
|
||||
dev: true
|
||||
|
||||
/available-typed-arrays@1.0.7:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
@@ -7930,13 +7869,13 @@ packages:
|
||||
source-map: 0.6.1
|
||||
dev: false
|
||||
|
||||
/eslint-config-prettier@9.0.0(eslint@9.0.0):
|
||||
/eslint-config-prettier@9.0.0(eslint@8.57.0):
|
||||
resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
eslint: '>=7.0.0'
|
||||
dependencies:
|
||||
eslint: 9.0.0
|
||||
eslint: 8.57.0
|
||||
dev: false
|
||||
|
||||
/eslint-config-prettier@9.1.0(eslint@8.57.0):
|
||||
@@ -7948,13 +7887,13 @@ packages:
|
||||
eslint: 8.57.0
|
||||
dev: false
|
||||
|
||||
/eslint-config-turbo@1.10.12(eslint@9.0.0):
|
||||
/eslint-config-turbo@1.10.12(eslint@8.57.0):
|
||||
resolution: {integrity: sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==}
|
||||
peerDependencies:
|
||||
eslint: '>6.6.0'
|
||||
dependencies:
|
||||
eslint: 9.0.0
|
||||
eslint-plugin-turbo: 1.10.12(eslint@9.0.0)
|
||||
eslint: 8.57.0
|
||||
eslint-plugin-turbo: 1.10.12(eslint@8.57.0)
|
||||
dev: false
|
||||
|
||||
/eslint-config-turbo@1.13.0(eslint@8.57.0):
|
||||
@@ -8076,13 +8015,13 @@ packages:
|
||||
string.prototype.matchall: 4.0.11
|
||||
dev: false
|
||||
|
||||
/eslint-plugin-turbo@1.10.12(eslint@9.0.0):
|
||||
/eslint-plugin-turbo@1.10.12(eslint@8.57.0):
|
||||
resolution: {integrity: sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==}
|
||||
peerDependencies:
|
||||
eslint: '>6.6.0'
|
||||
dependencies:
|
||||
dotenv: 16.0.3
|
||||
eslint: 9.0.0
|
||||
eslint: 8.57.0
|
||||
dev: false
|
||||
|
||||
/eslint-plugin-turbo@1.13.0(eslint@8.57.0):
|
||||
@@ -8109,23 +8048,10 @@ packages:
|
||||
esrecurse: 4.3.0
|
||||
estraverse: 5.3.0
|
||||
|
||||
/eslint-scope@8.0.1:
|
||||
resolution: {integrity: sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
dependencies:
|
||||
esrecurse: 4.3.0
|
||||
estraverse: 5.3.0
|
||||
dev: false
|
||||
|
||||
/eslint-visitor-keys@3.4.3:
|
||||
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
/eslint-visitor-keys@4.0.0:
|
||||
resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
dev: false
|
||||
|
||||
/eslint@8.57.0:
|
||||
resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -8172,58 +8098,6 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/eslint@9.0.0:
|
||||
resolution: {integrity: sha512-IMryZ5SudxzQvuod6rUdIUz29qFItWx281VhtFVc2Psy/ZhlCeD/5DT6lBIJ4H3G+iamGJoTln1v+QSuPw0p7Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.0(eslint@9.0.0)
|
||||
'@eslint-community/regexpp': 4.10.0
|
||||
'@eslint/eslintrc': 3.0.2
|
||||
'@eslint/js': 9.0.0
|
||||
'@humanwhocodes/config-array': 0.12.3
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@nodelib/fs.walk': 1.2.8
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.3
|
||||
debug: 4.3.4
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.0.1
|
||||
eslint-visitor-keys: 4.0.0
|
||||
espree: 10.0.1
|
||||
esquery: 1.5.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
find-up: 5.0.0
|
||||
glob-parent: 6.0.2
|
||||
graphemer: 1.4.0
|
||||
ignore: 5.3.1
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
is-path-inside: 3.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.3
|
||||
strip-ansi: 6.0.1
|
||||
text-table: 0.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/espree@10.0.1:
|
||||
resolution: {integrity: sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
dependencies:
|
||||
acorn: 8.11.3
|
||||
acorn-jsx: 5.3.2(acorn@8.11.3)
|
||||
eslint-visitor-keys: 4.0.0
|
||||
dev: false
|
||||
|
||||
/espree@9.6.1:
|
||||
resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -8427,13 +8301,6 @@ packages:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
/file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
dev: false
|
||||
|
||||
/fill-range@7.0.1:
|
||||
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8463,14 +8330,6 @@ packages:
|
||||
keyv: 4.5.4
|
||||
rimraf: 3.0.2
|
||||
|
||||
/flat-cache@4.0.1:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
dependencies:
|
||||
flatted: 3.3.1
|
||||
keyv: 4.5.4
|
||||
dev: false
|
||||
|
||||
/flat@6.0.1:
|
||||
resolution: {integrity: sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -8742,11 +8601,6 @@ packages:
|
||||
dependencies:
|
||||
type-fest: 0.20.2
|
||||
|
||||
/globals@14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
dev: false
|
||||
|
||||
/globalthis@1.0.3:
|
||||
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9897,7 +9751,7 @@ packages:
|
||||
dependencies:
|
||||
marked: 7.0.4
|
||||
react: 18.2.0
|
||||
react-email: 2.1.1(eslint@9.0.0)
|
||||
react-email: 2.1.1(eslint@8.57.0)
|
||||
dev: false
|
||||
|
||||
/md-to-react-email@5.0.2(react@18.2.0):
|
||||
@@ -11198,18 +11052,6 @@ packages:
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.8
|
||||
|
||||
/postcss-import@15.1.0(postcss@8.4.35):
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
dependencies:
|
||||
postcss: 8.4.35
|
||||
postcss-value-parser: 4.2.0
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.8
|
||||
dev: false
|
||||
|
||||
/postcss-js@4.0.1(postcss@8.4.33):
|
||||
resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
|
||||
engines: {node: ^12 || ^14 || >= 16}
|
||||
@@ -11219,16 +11061,6 @@ packages:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.4.33
|
||||
|
||||
/postcss-js@4.0.1(postcss@8.4.35):
|
||||
resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
|
||||
engines: {node: ^12 || ^14 || >= 16}
|
||||
peerDependencies:
|
||||
postcss: ^8.4.21
|
||||
dependencies:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.4.35
|
||||
dev: false
|
||||
|
||||
/postcss-load-config@4.0.2(postcss@8.4.33):
|
||||
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -11245,23 +11077,6 @@ packages:
|
||||
postcss: 8.4.33
|
||||
yaml: 2.4.1
|
||||
|
||||
/postcss-load-config@4.0.2(postcss@8.4.35):
|
||||
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
|
||||
engines: {node: '>= 14'}
|
||||
peerDependencies:
|
||||
postcss: '>=8.0.9'
|
||||
ts-node: '>=9.0.0'
|
||||
peerDependenciesMeta:
|
||||
postcss:
|
||||
optional: true
|
||||
ts-node:
|
||||
optional: true
|
||||
dependencies:
|
||||
lilconfig: 3.1.1
|
||||
postcss: 8.4.35
|
||||
yaml: 2.4.1
|
||||
dev: false
|
||||
|
||||
/postcss-nested@6.0.1(postcss@8.4.33):
|
||||
resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
@@ -11271,16 +11086,6 @@ packages:
|
||||
postcss: 8.4.33
|
||||
postcss-selector-parser: 6.0.16
|
||||
|
||||
/postcss-nested@6.0.1(postcss@8.4.35):
|
||||
resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.2.14
|
||||
dependencies:
|
||||
postcss: 8.4.35
|
||||
postcss-selector-parser: 6.0.16
|
||||
dev: false
|
||||
|
||||
/postcss-selector-parser@6.0.16:
|
||||
resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -11310,20 +11115,11 @@ packages:
|
||||
/postcss@8.4.35:
|
||||
resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
picocolors: 1.0.0
|
||||
source-map-js: 1.0.2
|
||||
dev: false
|
||||
|
||||
/postcss@8.4.38:
|
||||
resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
picocolors: 1.0.0
|
||||
source-map-js: 1.2.0
|
||||
dev: true
|
||||
dev: false
|
||||
|
||||
/prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
@@ -11539,7 +11335,7 @@ packages:
|
||||
react: 18.2.0
|
||||
scheduler: 0.23.0
|
||||
|
||||
/react-email@2.1.1(eslint@9.0.0):
|
||||
/react-email@2.1.1(eslint@8.57.0):
|
||||
resolution: {integrity: sha512-09oMVl/jN0/Re0bT0sEqYjyyFSCN/Tg0YmzjC9wfYpnMx02Apk40XXitySDfUBMR9EgTdr6T4lYknACqiLK3mg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
@@ -11565,8 +11361,8 @@ packages:
|
||||
commander: 11.1.0
|
||||
debounce: 2.0.0
|
||||
esbuild: 0.19.11
|
||||
eslint-config-prettier: 9.0.0(eslint@9.0.0)
|
||||
eslint-config-turbo: 1.10.12(eslint@9.0.0)
|
||||
eslint-config-prettier: 9.0.0(eslint@8.57.0)
|
||||
eslint-config-turbo: 1.10.12(eslint@8.57.0)
|
||||
framer-motion: 10.17.4(react-dom@18.2.0)(react@18.2.0)
|
||||
glob: 10.3.4
|
||||
log-symbols: 4.1.0
|
||||
@@ -12691,7 +12487,7 @@ packages:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
chokidar: 3.5.3
|
||||
chokidar: 3.6.0
|
||||
didyoumean: 1.2.2
|
||||
dlv: 1.1.3
|
||||
fast-glob: 3.3.2
|
||||
@@ -12703,11 +12499,11 @@ packages:
|
||||
normalize-path: 3.0.0
|
||||
object-hash: 3.0.0
|
||||
picocolors: 1.0.0
|
||||
postcss: 8.4.35
|
||||
postcss-import: 15.1.0(postcss@8.4.35)
|
||||
postcss-js: 4.0.1(postcss@8.4.35)
|
||||
postcss-load-config: 4.0.2(postcss@8.4.35)
|
||||
postcss-nested: 6.0.1(postcss@8.4.35)
|
||||
postcss: 8.4.33
|
||||
postcss-import: 15.1.0(postcss@8.4.33)
|
||||
postcss-js: 4.0.1(postcss@8.4.33)
|
||||
postcss-load-config: 4.0.2(postcss@8.4.33)
|
||||
postcss-nested: 6.0.1(postcss@8.4.33)
|
||||
postcss-selector-parser: 6.0.16
|
||||
resolve: 1.22.8
|
||||
sucrase: 3.35.0
|
||||
|
||||
Reference in New Issue
Block a user