From 19c16cfb44bd59dea8387627661dc75f27671e60 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Tue, 9 Apr 2024 15:26:31 +0800 Subject: [PATCH] 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. --- packages/features/admin/package.json | 2 + .../src/components/admin-account-page.tsx | 254 +++++++++++++++++- .../src/components/admin-accounts-table.tsx | 36 ++- .../src/components/admin-ban-user-dialog.tsx | 100 +++++++ .../admin-delete-account-dialog.tsx | 100 +++++++ .../components/admin-delete-user-dialog.tsx | 103 +++++++ .../admin-impersonate-user-dialog.tsx | 155 +++++++++++ .../admin-reactivate-user-dialog.tsx | 96 +++++++ .../src/lib/server/admin-server-actions.ts | 127 +++++++++ .../src/lib/server/enhance-admin-action.ts | 19 ++ .../lib/server/schema/admin-actions.schema.ts | 18 ++ .../server/services/admin-accounts.service.ts | 18 ++ .../services/admin-auth-user.service.ts | 116 ++++++++ packages/ui/src/shadcn/form.tsx | 2 + pnpm-lock.yaml | 248 ++--------------- 15 files changed, 1150 insertions(+), 244 deletions(-) create mode 100644 packages/features/admin/src/components/admin-ban-user-dialog.tsx create mode 100644 packages/features/admin/src/components/admin-delete-account-dialog.tsx create mode 100644 packages/features/admin/src/components/admin-delete-user-dialog.tsx create mode 100644 packages/features/admin/src/components/admin-impersonate-user-dialog.tsx create mode 100644 packages/features/admin/src/components/admin-reactivate-user-dialog.tsx create mode 100644 packages/features/admin/src/lib/server/admin-server-actions.ts create mode 100644 packages/features/admin/src/lib/server/enhance-admin-action.ts create mode 100644 packages/features/admin/src/lib/server/schema/admin-actions.schema.ts create mode 100644 packages/features/admin/src/lib/server/services/admin-accounts.service.ts create mode 100644 packages/features/admin/src/lib/server/services/admin-auth-user.service.ts diff --git a/packages/features/admin/package.json b/packages/features/admin/package.json index 5d16fa729..57194265c 100644 --- a/packages/features/admin/package.json +++ b/packages/features/admin/package.json @@ -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:*", diff --git a/packages/features/admin/src/components/admin-account-page.tsx b/packages/features/admin/src/components/admin-account-page.tsx index 743b8c0ce..fbae14cc1 100644 --- a/packages/features/admin/src/components/admin-account-page.tsx +++ b/packages/features/admin/src/components/admin-account-page.tsx @@ -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 ( <> + title={ +
+ + + {props.account.name} + + Personal Account +
+ } + > +
+ + + + + + + + + + + + + + + + + + + +
+
-
- Memberships +
+ -
- +
+ + This user is a member of the following teams: + + +
+ +
@@ -51,17 +120,180 @@ async function TeamAccountPage(props: { return ( <> + title={ +
+ + + {props.account.name} + + Team Account +
+ } + > + + + +
- +
+ + + + +
+ This team has the following members: + + +
+
); } +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 ( + + There was an error loading subscription. + + + Please check the logs for more information or try again later. + + + ); + } + + return ( +
+ Subscription + + This account does not have an active subscription.} + > + {(subscription) => { + return ( +
+ + + Subscription ID + + Provider + + Customer ID + + Status + + Created At + + Period Starts At + + Ends At + + + + + + {subscription.id} + + + + {subscription.billing_provider} + + + + {subscription.billing_customer_id} + + + + {subscription.status} + + + + {subscription.created_at} + + + + {subscription.period_starts_at} + + + + {subscription.period_ends_at} + + + +
+ + + + Product ID + + Variant ID + + Quantity + + Price + + Interval + + Type + + + + {subscription.subscription_items.map((item) => { + return ( + + + {item.product_id} + + + + {item.variant_id} + + + + {item.quantity} + + + + {item.price_amount} + + + + {item.interval} + + + + {item.type} + + + ); + })} + +
+
+ ); + }} +
+
+ ); +} + async function getMemberships(userId: string) { const client = getSupabaseServerComponentClient({ admin: true, diff --git a/packages/features/admin/src/components/admin-accounts-table.tsx b/packages/features/admin/src/components/admin-accounts-table.tsx index 26be21e10..ffc625f84 100644 --- a/packages/features/admin/src/components/admin-accounts-table.tsx +++ b/packages/features/admin/src/components/admin-accounts-table.tsx @@ -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[] { header: '', cell: ({ row }) => { const isPersonalAccount = row.original.is_personal_account; + const userId = row.original.id; return ( @@ -208,18 +213,35 @@ function getColumns(): ColumnDef[] { Actions - View + + View + - - Ban - + + e.preventDefault()}> + Impersonate User + + + + + e.preventDefault()}> + Delete Personal Account + + - - Delete - + + + e.preventDefault()}> + Delete Team Account + + + diff --git a/packages/features/admin/src/components/admin-ban-user-dialog.tsx b/packages/features/admin/src/components/admin-ban-user-dialog.tsx new file mode 100644 index 000000000..f7fbe7e51 --- /dev/null +++ b/packages/features/admin/src/components/admin-ban-user-dialog.tsx @@ -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 ( + + {props.children} + + + + Ban User + + + Are you sure you want to ban this user? + + + +
+ + ( + + + Type CONFIRM to confirm + + + + + + + + Are you sure you want to do this? + + + + + )} + /> + + + Cancel + + + + + +
+
+ ); +} diff --git a/packages/features/admin/src/components/admin-delete-account-dialog.tsx b/packages/features/admin/src/components/admin-delete-account-dialog.tsx new file mode 100644 index 000000000..bdce327b0 --- /dev/null +++ b/packages/features/admin/src/components/admin-delete-account-dialog.tsx @@ -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 ( + + {props.children} + + + + Delete Account + + + 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. + + + +
+ + ( + + + Type CONFIRM to confirm + + + + + + + + Are you sure you want to do this? This action cannot be + undone. + + + )} + /> + + + Cancel + + + + + +
+
+ ); +} diff --git a/packages/features/admin/src/components/admin-delete-user-dialog.tsx b/packages/features/admin/src/components/admin-delete-user-dialog.tsx new file mode 100644 index 000000000..7223cd106 --- /dev/null +++ b/packages/features/admin/src/components/admin-delete-user-dialog.tsx @@ -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 ( + + {props.children} + + + + Delete User + + + 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. + + + +
+ + ( + + + Type CONFIRM to confirm + + + + + + + + Are you sure you want to do this? This action cannot be + undone. + + + + + )} + /> + + + Cancel + + + + + +
+
+ ); +} diff --git a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx new file mode 100644 index 000000000..5f0ca2b16 --- /dev/null +++ b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx @@ -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 ( + <> + + + Setting up your session... + + ); + } + + return ( + + {props.children} + + + + Impersonate User + + + Are you sure you want to impersonate this user? You will be logged + in as this user. To stop impersonating, log out. + + + +
+ { + const tokens = await impersonateUser(data); + + setTokens(tokens); + })} + > + ( + + + Type CONFIRM to confirm + + + + + + + + Are you sure you want to impersonate this user? + + + + + )} + /> + + + Cancel + + + + + +
+
+ ); +} + +function ImpersonateUserAuthSetter({ + tokens, +}: React.PropsWithChildren<{ + tokens: { + accessToken: string; + refreshToken: string; + }; +}>) { + useSetSession(tokens); + + return Setting up your session...; +} + +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'); + }, + }); +} diff --git a/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx b/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx new file mode 100644 index 000000000..a13e3feb0 --- /dev/null +++ b/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx @@ -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 ( + + {props.children} + + + + Reactivate User + + + Are you sure you want to reactivate this user? + + + +
+ + ( + + + Type CONFIRM to confirm + + + + + + + + Are you sure you want to do this? + + + + + )} + /> + + + Cancel + + + + + +
+
+ ); +} diff --git a/packages/features/admin/src/lib/server/admin-server-actions.ts b/packages/features/admin/src/lib/server/admin-server-actions.ts new file mode 100644 index 000000000..c32ab069d --- /dev/null +++ b/packages/features/admin/src/lib/server/admin-server-actions.ts @@ -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'); +} diff --git a/packages/features/admin/src/lib/server/enhance-admin-action.ts b/packages/features/admin/src/lib/server/enhance-admin-action.ts new file mode 100644 index 000000000..56c733b24 --- /dev/null +++ b/packages/features/admin/src/lib/server/enhance-admin-action.ts @@ -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( + fn: (params: Args) => Response, +) { + return async (params: Args) => { + const isAdmin = await isSuperAdmin(getSupabaseServerActionClient()); + + if (!isAdmin) { + notFound(); + } + + return fn(params); + }; +} diff --git a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts new file mode 100644 index 000000000..debf89ce8 --- /dev/null +++ b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts @@ -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(), +}); diff --git a/packages/features/admin/src/lib/server/services/admin-accounts.service.ts b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts new file mode 100644 index 000000000..1746aab4d --- /dev/null +++ b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts @@ -0,0 +1,18 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { Database } from '@kit/supabase/database'; + +export class AdminAccountsService { + constructor(private adminClient: SupabaseClient) {} + + async deleteAccount(accountId: string) { + const { error } = await this.adminClient + .from('accounts') + .delete() + .eq('id', accountId); + + if (error) { + throw error; + } + } +} diff --git a/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts b/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts new file mode 100644 index 000000000..f441e07e2 --- /dev/null +++ b/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts @@ -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, + private readonly adminClient: SupabaseClient, + ) {} + + 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, + }); + } +} diff --git a/packages/ui/src/shadcn/form.tsx b/packages/ui/src/shadcn/form.tsx index 335659df3..939e7cc1b 100644 --- a/packages/ui/src/shadcn/form.tsx +++ b/packages/ui/src/shadcn/form.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import type * as LabelPrimitive from '@radix-ui/react-label'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19a9c76aa..38796c605 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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