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?
+
+
+
+
+
+
+
+ );
+}
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.
+
+
+
+
+
+
+
+ );
+}
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.
+
+
+
+
+
+
+
+ );
+}
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.
+
+
+
+
+
+
+
+ );
+}
+
+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?
+
+
+
+
+
+
+
+ );
+}
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