Next.js Supabase V3 (#463)

Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,45 +1,41 @@
{
"name": "@kit/admin",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@kit/eslint-config": "workspace:*",
"@kit/next": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "^8.21.3",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "catalog:",
"@makerkit/data-loader-supabase-nextjs": "catalog:",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -60,9 +60,8 @@ async function PersonalAccountPage(props: { account: Account }) {
userResult.data.user.banned_until !== 'none';
return (
<>
<PageBody className="gap-y-4">
<PageHeader
className="border-b"
description={
<AppBreadcrumbs
values={{
@@ -123,41 +122,39 @@ async function PersonalAccountPage(props: { account: Account }) {
</div>
</PageHeader>
<PageBody className={'space-y-6 py-4'}>
<div className={'flex items-center justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<div className={'flex items-center justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Personal Account</Badge>
<Badge variant={'outline'}>Personal Account</Badge>
<If condition={isBanned}>
<Badge variant={'destructive'}>Banned</Badge>
</If>
<If condition={isBanned}>
<Badge variant={'destructive'}>Banned</Badge>
</If>
</div>
</div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col gap-y-2.5'}>
<Heading level={6}>Teams</Heading>
<div className={'rounded-lg border p-2'}>
<AdminMembershipsTable memberships={memberships} />
</div>
</div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col gap-y-2.5'}>
<Heading level={6}>Teams</Heading>
<div className={'rounded-lg border p-2'}>
<AdminMembershipsTable memberships={memberships} />
</div>
</div>
</div>
</PageBody>
</>
</div>
</PageBody>
);
}
@@ -167,9 +164,8 @@ async function TeamAccountPage(props: {
const members = await getMembers(props.account.slug ?? '');
return (
<>
<PageBody className={'gap-y-6'}>
<PageHeader
className="border-b"
description={
<AppBreadcrumbs
values={{
@@ -191,39 +187,37 @@ async function TeamAccountPage(props: {
</AdminDeleteAccountDialog>
</PageHeader>
<PageBody className={'space-y-6 py-4'}>
<div className={'flex justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<div className={'flex justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Team Account</Badge>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Team Account</Badge>
</div>
</div>
<div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Team Members</Heading>
<div className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Team Members</Heading>
<div className={'rounded-lg border p-2'}>
<AdminMembersTable members={members} />
</div>
<div className={'rounded-lg border p-2'}>
<AdminMembersTable members={members} />
</div>
</div>
</div>
</PageBody>
</>
</div>
</PageBody>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
@@ -7,7 +9,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { ColumnDef } from '@tanstack/react-table';
import { EllipsisVertical } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import * as z from 'zod';
import { Tables } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
@@ -21,7 +23,6 @@ import {
} from '@kit/ui/dropdown-menu';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { Form, FormControl, FormField, FormItem } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
Select,
@@ -77,7 +78,7 @@ export function AdminAccountsTable(
}
function AccountsTableFilters(props: {
filters: z.infer<typeof FiltersSchema>;
filters: z.output<typeof FiltersSchema>;
}) {
const form = useForm({
resolver: zodResolver(FiltersSchema),
@@ -92,7 +93,7 @@ function AccountsTableFilters(props: {
const router = useRouter();
const pathName = usePathname();
const onSubmit = ({ type, query }: z.infer<typeof FiltersSchema>) => {
const onSubmit = ({ type, query }: z.output<typeof FiltersSchema>) => {
const params = new URLSearchParams({
account_type: type,
query: query ?? '',
@@ -105,6 +106,12 @@ function AccountsTableFilters(props: {
const type = useWatch({ control: form.control, name: 'type' });
const options = {
all: 'All Accounts',
team: 'Team',
personal: 'Personal',
};
return (
<Form {...form}>
<form
@@ -116,7 +123,7 @@ function AccountsTableFilters(props: {
onValueChange={(value) => {
form.setValue(
'type',
value as z.infer<typeof FiltersSchema>['type'],
value as z.output<typeof FiltersSchema>['type'],
{
shouldValidate: true,
shouldDirty: true,
@@ -128,16 +135,20 @@ function AccountsTableFilters(props: {
}}
>
<SelectTrigger>
<SelectValue placeholder={'Account Type'} />
<SelectValue placeholder={'Account Type'}>
{(value: keyof typeof options) => options[value]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Account Type</SelectLabel>
<SelectItem value={'all'}>All accounts</SelectItem>
<SelectItem value={'team'}>Team</SelectItem>
<SelectItem value={'personal'}>Personal</SelectItem>
{Object.entries(options).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
@@ -157,6 +168,8 @@ function AccountsTableFilters(props: {
</FormItem>
)}
/>
<button type="submit" hidden />
</form>
</Form>
);
@@ -194,75 +207,143 @@ function getColumns(): ColumnDef<Account>[] {
{
id: 'created_at',
header: 'Created At',
accessorKey: 'created_at',
cell: ({ row }) => {
return new Date(row.original.created_at!).toLocaleDateString(
undefined,
{
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
);
},
},
{
id: 'updated_at',
header: 'Updated At',
accessorKey: 'updated_at',
cell: ({ row }) => {
return row.original.updated_at
? new Date(row.original.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '-';
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const isPersonalAccount = row.original.is_personal_account;
const userId = row.original.id;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'outline'} size={'icon'}>
<EllipsisVertical className={'h-4'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={'end'}>
<DropdownMenuGroup>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>
<Link
className={'h-full w-full'}
href={`/admin/accounts/${userId}`}
>
View
</Link>
</DropdownMenuItem>
<If condition={isPersonalAccount}>
<AdminResetPasswordDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Send Reset Password link
</DropdownMenuItem>
</AdminResetPasswordDialog>
<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>
<If condition={!isPersonalAccount}>
<AdminDeleteAccountDialog accountId={row.original.id}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete Team Account
</DropdownMenuItem>
</AdminDeleteAccountDialog>
</If>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => <ActionsCell account={row.original} />,
},
];
}
type ActiveDialog =
| 'reset-password'
| 'impersonate'
| 'delete-user'
| 'delete-account'
| null;
function ActionsCell({ account }: { account: Account }) {
const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null);
const isPersonalAccount = account.is_personal_account;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant={'outline'} size={'icon'}>
<EllipsisVertical className={'h-4'} />
</Button>
}
/>
<DropdownMenuContent className="min-w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
render={
<Link
className={'h-full w-full'}
href={`/admin/accounts/${account.id}`}
>
View
</Link>
}
/>
{isPersonalAccount && (
<>
<DropdownMenuItem
onClick={() => setActiveDialog('reset-password')}
>
Send Reset Password link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveDialog('impersonate')}
>
Impersonate User
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setActiveDialog('delete-user')}
>
Delete Personal Account
</DropdownMenuItem>
</>
)}
{!isPersonalAccount && (
<DropdownMenuItem
variant="destructive"
onClick={() => setActiveDialog('delete-account')}
>
Delete Team Account
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{isPersonalAccount && (
<>
<AdminResetPasswordDialog
userId={account.id}
open={activeDialog === 'reset-password'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
<AdminImpersonateUserDialog
userId={account.id}
open={activeDialog === 'impersonate'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
<AdminDeleteUserDialog
userId={account.id}
open={activeDialog === 'delete-user'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
</>
)}
{!isPersonalAccount && (
<AdminDeleteAccountDialog
accountId={account.id}
open={activeDialog === 'delete-account'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
)}
</div>
);
}

View File

@@ -1,8 +1,7 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -26,6 +25,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
@@ -37,11 +37,14 @@ export function AdminBanUserDialog(
userId: string;
}>,
) {
const [open, setOpen] = useState(false);
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogContent>
<AlertDialogHeader>
@@ -53,15 +56,31 @@ export function AdminBanUserDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<BanUserForm userId={props.userId} onSuccess={() => setOpen(false)} />
<BanUserForm
userId={props.userId}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
);
}
function BanUserForm(props: { userId: string; onSuccess: () => void }) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
function BanUserForm(props: {
userId: string;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}) {
const { execute, hasErrored } = useAction(banUserAction, {
onExecute: () => props.setIsPending(true),
onSuccess: () => props.onSuccess(),
onSettled: () => props.setIsPending(false),
});
const form = useForm({
resolver: zodResolver(BanUserSchema),
@@ -76,18 +95,9 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
<form
data-test={'admin-ban-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await banUserAction(data);
props.onSuccess();
} catch {
setError(true);
}
});
})}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={error}>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -125,10 +135,16 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={props.isPending}>
Cancel
</AlertDialogCancel>
<Button disabled={pending} type={'submit'} variant={'destructive'}>
{pending ? 'Banning...' : 'Ban User'}
<Button
disabled={props.isPending}
type={'submit'}
variant={'destructive'}
>
{props.isPending ? 'Banning...' : 'Ban User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,8 +1,7 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -27,6 +26,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
@@ -38,9 +38,7 @@ import {
} from '../lib/server/schema/create-user.schema';
export function AdminCreateUserDialog(props: React.PropsWithChildren) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
const form = useForm({
resolver: zodResolver(CreateUserSchema),
@@ -52,28 +50,25 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
mode: 'onChange',
});
const onSubmit = (data: CreateUserSchemaType) => {
startTransition(async () => {
try {
const result = await createUserAction(data);
const { execute, result } = useAction(createUserAction, {
onExecute: () => setIsPending(true),
onSuccess: () => {
toast.success('User created successfully');
form.reset();
setIsPending(false);
setOpen(false);
},
onSettled: () => setIsPending(false),
});
if (result.success) {
toast.success('User creates successfully');
form.reset();
setOpen(false);
}
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
}
});
};
const error = result.serverError;
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogContent>
<AlertDialogHeader>
@@ -88,7 +83,9 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
<form
data-test={'admin-create-user-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit((data: CreateUserSchemaType) =>
execute(data),
)}
>
<If condition={!!error}>
<Alert variant={'destructive'}>
@@ -164,10 +161,10 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<Button disabled={pending} type={'submit'}>
{pending ? 'Creating...' : 'Create User'}
<Button disabled={isPending} type={'submit'}>
{isPending ? 'Creating...' : 'Create User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,8 +1,7 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -35,22 +34,15 @@ import { DeleteAccountSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteAccountDialog(
props: React.PropsWithChildren<{
accountId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>,
) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(DeleteAccountSchema),
defaultValues: {
accountId: props.accountId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog open={props.open} onOpenChange={props.onOpenChange}>
<If condition={props.children}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
</If>
<AlertDialogContent>
<AlertDialogHeader>
@@ -63,73 +55,75 @@ export function AdminDeleteAccountDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-form={'admin-delete-account-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await deleteAccountAction(data);
setError(false);
} catch {
setError(true);
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the account. Please check the
server logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<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>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
disabled={pending}
type={'submit'}
variant={'destructive'}
>
{pending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<DeleteAccountForm accountId={props.accountId} />
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteAccountForm(props: { accountId: string }) {
const { execute, isPending, hasErrored } = useAction(deleteAccountAction);
const form = useForm({
resolver: zodResolver(DeleteAccountSchema),
defaultValues: {
accountId: props.accountId,
confirmation: '',
},
});
return (
<Form {...form}>
<form
data-test={'admin-delete-account-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the account. Please check the server
logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<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>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'} variant={'destructive'}>
{isPending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}

View File

@@ -1,10 +1,7 @@
'use client';
import { useState, useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -37,22 +34,15 @@ import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteUserDialog(
props: React.PropsWithChildren<{
userId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>,
) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog open={props.open} onOpenChange={props.onOpenChange}>
<If condition={props.children}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
</If>
<AlertDialogContent>
<AlertDialogHeader>
@@ -65,78 +55,75 @@ export function AdminDeleteUserDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-delete-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await deleteUserAction(data);
setError(false);
} catch {
if (isRedirectError(error)) {
// Do nothing
} else {
setError(true);
}
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the user. Please check the server
logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<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
disabled={pending}
type={'submit'}
variant={'destructive'}
>
{pending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<DeleteUserForm userId={props.userId} />
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteUserForm(props: { userId: string }) {
const { execute, isPending, hasErrored } = useAction(deleteUserAction);
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<Form {...form}>
<form
data-test={'admin-delete-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the user. Please check the server logs
to see what went wrong.
</AlertDescription>
</Alert>
</If>
<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 disabled={isPending} type={'submit'} variant={'destructive'}>
{isPending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import { useState, useTransition } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -35,40 +36,34 @@ import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { impersonateUserAction } from '../lib/server/admin-server-actions';
import { ImpersonateUserSchema } from '../lib/server/schema/admin-actions.schema';
type Tokens = {
accessToken: string;
refreshToken: string;
};
export function AdminImpersonateUserDialog(
props: React.PropsWithChildren<{
userId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>,
) {
const form = useForm({
resolver: zodResolver(ImpersonateUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
const [tokens, setTokens] = useState<{
accessToken: string;
refreshToken: string;
}>();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<boolean | null>(null);
const [tokens, setTokens] = useState<Tokens>();
if (tokens) {
return (
<>
<ImpersonateUserAuthSetter tokens={tokens} />
<LoadingOverlay>Setting up your session...</LoadingOverlay>
</>
);
return <ImpersonateUserAuthSetter tokens={tokens} />;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={props.open}
onOpenChange={(open) => {
props.onOpenChange?.(open);
}}
>
<If condition={props.children}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
</If>
<AlertDialogContent>
<AlertDialogHeader>
@@ -87,73 +82,88 @@ export function AdminImpersonateUserDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-impersonate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
const result = await impersonateUserAction(data);
setTokens(result);
} catch {
setError(true);
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to impersonate user. Please check the logs to
understand what went wrong.
</AlertDescription>
</Alert>
</If>
<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 disabled={isPending} type={'submit'}>
{isPending ? 'Impersonating...' : 'Impersonate User'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<AdminImpersonateUserForm userId={props.userId} onSuccess={setTokens} />
</AlertDialogContent>
</AlertDialog>
);
}
function AdminImpersonateUserForm(props: {
userId: string;
onSuccess: (data: Tokens) => void;
}) {
const form = useForm({
resolver: zodResolver(ImpersonateUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
const { execute, isPending, hasErrored } = useAction(impersonateUserAction, {
onSuccess: ({ data }) => {
if (data) {
props.onSuccess(data);
}
},
});
return (
<Form {...form}>
<form
data-test={'admin-impersonate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to impersonate user. Please check the logs to understand
what went wrong.
</AlertDescription>
</Alert>
</If>
<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 disabled={isPending} type={'submit'}>
{isPending ? 'Impersonating...' : 'Impersonate User'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}
function ImpersonateUserAuthSetter({
tokens,
}: React.PropsWithChildren<{

View File

@@ -1,8 +1,7 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -26,6 +25,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
@@ -37,11 +37,14 @@ export function AdminReactivateUserDialog(
userId: string;
}>,
) {
const [open, setOpen] = useState(false);
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogContent>
<AlertDialogHeader>
@@ -54,16 +57,29 @@ export function AdminReactivateUserDialog(
<ReactivateUserForm
userId={props.userId}
onSuccess={() => setOpen(false)}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
);
}
function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
function ReactivateUserForm(props: {
userId: string;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}) {
const { execute, hasErrored } = useAction(reactivateUserAction, {
onExecute: () => props.setIsPending(true),
onSuccess: () => props.onSuccess(),
onSettled: () => props.setIsPending(false),
});
const form = useForm({
resolver: zodResolver(ReactivateUserSchema),
@@ -78,18 +94,9 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
<form
data-test={'admin-reactivate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await reactivateUserAction(data);
props.onSuccess();
} catch {
setError(true);
}
});
})}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={error}>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -127,10 +134,12 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={props.isPending}>
Cancel
</AlertDialogCancel>
<Button disabled={pending} type={'submit'}>
{pending ? 'Reactivating...' : 'Reactivate User'}
<Button disabled={props.isPending} type={'submit'}>
{props.isPending ? 'Reactivating...' : 'Reactivate User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,10 +1,9 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import * as z from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -34,50 +33,21 @@ import { toast } from '@kit/ui/sonner';
import { resetPasswordAction } from '../lib/server/admin-server-actions';
const FormSchema = z.object({
userId: z.string().uuid(),
userId: z.uuid(),
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
});
export function AdminResetPasswordDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const onSubmit = form.handleSubmit((data) => {
setError(null);
setSuccess(false);
startTransition(async () => {
try {
await resetPasswordAction(data);
setSuccess(true);
form.reset({ userId: props.userId, confirmation: '' });
toast.success('Password reset email successfully sent');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
toast.error('We hit an error. Please read the logs.');
}
});
});
export function AdminResetPasswordDialog(props: {
userId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
children?: React.ReactNode;
}) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog open={props.open} onOpenChange={props.onOpenChange}>
{props.children && (
<AlertDialogTrigger render={props.children as React.ReactElement} />
)}
<AlertDialogContent>
<AlertDialogHeader>
@@ -89,75 +59,102 @@ export function AdminResetPasswordDialog(
</AlertDialogHeader>
<div className="relative">
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="confirmation"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmation</FormLabel>
<FormDescription>
Type CONFIRM to execute this request.
</FormDescription>
<FormControl>
<Input
placeholder="CONFIRM"
{...field}
autoComplete="off"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<If condition={!!error}>
<Alert variant="destructive">
<AlertTitle>
We encountered an error while sending the email
</AlertTitle>
<AlertDescription>
Please check the server logs for more details.
</AlertDescription>
</Alert>
</If>
<If condition={success}>
<Alert>
<AlertTitle>
Password reset email sent successfully
</AlertTitle>
<AlertDescription>
The password reset email has been sent to the user.
</AlertDescription>
</Alert>
</If>
<input type="hidden" name="userId" value={props.userId} />
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<Button
type="submit"
disabled={isPending}
variant="destructive"
>
{isPending ? 'Sending...' : 'Send Reset Email'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<AdminResetPasswordForm
userId={props.userId}
onSuccess={() => props.onOpenChange(false)}
/>
</div>
</AlertDialogContent>
</AlertDialog>
);
}
function AdminResetPasswordForm({
userId,
onSuccess,
}: {
userId: string;
onSuccess: () => void;
}) {
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
userId,
confirmation: '',
},
});
const { execute, isPending, hasErrored, hasSucceeded } = useAction(
resetPasswordAction,
{
onSuccess: () => {
toast.success('Password reset email successfully sent');
onSuccess();
},
onError: () => {
toast.error('We hit an error. Please read the logs.');
},
},
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-4"
>
<FormField
control={form.control}
name="confirmation"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmation</FormLabel>
<FormDescription>
Type CONFIRM to execute this request.
</FormDescription>
<FormControl>
<Input placeholder="CONFIRM" {...field} autoComplete="off" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<If condition={hasErrored}>
<Alert variant="destructive">
<AlertTitle>
We encountered an error while sending the email
</AlertTitle>
<AlertDescription>
Please check the server logs for more details.
</AlertDescription>
</Alert>
</If>
<If condition={hasSucceeded}>
<Alert>
<AlertTitle>Password reset email sent successfully</AlertTitle>
<AlertDescription>
The password reset email has been sent to the user.
</AlertDescription>
</Alert>
</If>
<input type="hidden" name="userId" value={userId} />
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<Button type="submit" disabled={isPending} variant="destructive">
{isPending ? 'Sending...' : 'Send Reset Email'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}

View File

@@ -3,7 +3,6 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -19,212 +18,168 @@ import { CreateUserSchema } from './schema/create-user.schema';
import { ResetPasswordSchema } from './schema/reset-password.schema';
import { createAdminAccountsService } from './services/admin-accounts.service';
import { createAdminAuthUserService } from './services/admin-auth-user.service';
import { adminAction } from './utils/admin-action';
import { adminActionClient } from './utils/admin-action-client';
/**
* @name banUserAction
* @description Ban a user from the system.
*/
export const banUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const banUserAction = adminActionClient
.inputSchema(BanUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is banning user...`);
logger.info({ userId }, `Super Admin is banning user...`);
const { error } = await service.banUser(userId);
const { error } = await service.banUser(userId);
if (error) {
logger.error({ error }, `Error banning user`);
if (error) {
logger.error({ error }, `Error banning user`);
throw new Error('Error banning user');
}
return {
success: false,
};
}
revalidateAdmin();
revalidateAdmin();
logger.info({ userId }, `Super Admin has successfully banned user`);
},
{
schema: BanUserSchema,
},
),
);
logger.info({ userId }, `Super Admin has successfully banned user`);
});
/**
* @name reactivateUserAction
* @description Reactivate a user in the system.
*/
export const reactivateUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const reactivateUserAction = adminActionClient
.inputSchema(ReactivateUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is reactivating user...`);
logger.info({ userId }, `Super Admin is reactivating user...`);
const { error } = await service.reactivateUser(userId);
const { error } = await service.reactivateUser(userId);
if (error) {
logger.error({ error }, `Error reactivating user`);
if (error) {
logger.error({ error }, `Error reactivating user`);
throw new Error('Error reactivating user');
}
return {
success: false,
};
}
revalidateAdmin();
revalidateAdmin();
logger.info({ userId }, `Super Admin has successfully reactivated user`);
},
{
schema: ReactivateUserSchema,
},
),
);
logger.info({ userId }, `Super Admin has successfully reactivated user`);
});
/**
* @name impersonateUserAction
* @description Impersonate a user in the system.
*/
export const impersonateUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const impersonateUserAction = adminActionClient
.inputSchema(ImpersonateUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is impersonating user...`);
logger.info({ userId }, `Super Admin is impersonating user...`);
return await service.impersonateUser(userId);
},
{
schema: ImpersonateUserSchema,
},
),
);
return await service.impersonateUser(userId);
});
/**
* @name deleteUserAction
* @description Delete a user from the system.
*/
export const deleteUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const deleteUserAction = adminActionClient
.inputSchema(DeleteUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is deleting user...`);
logger.info({ userId }, `Super Admin is deleting user...`);
await service.deleteUser(userId);
await service.deleteUser(userId);
logger.info({ userId }, `Super Admin has successfully deleted user`);
logger.info({ userId }, `Super Admin has successfully deleted user`);
return redirect('/admin/accounts');
},
{
schema: DeleteUserSchema,
},
),
);
redirect('/admin/accounts');
});
/**
* @name deleteAccountAction
* @description Delete an account from the system.
*/
export const deleteAccountAction = adminAction(
enhanceAction(
async ({ accountId }) => {
const service = getAdminAccountsService();
const logger = await getLogger();
export const deleteAccountAction = adminActionClient
.inputSchema(DeleteAccountSchema)
.action(async ({ parsedInput: { accountId } }) => {
const service = getAdminAccountsService();
const logger = await getLogger();
logger.info({ accountId }, `Super Admin is deleting account...`);
logger.info({ accountId }, `Super Admin is deleting account...`);
await service.deleteAccount(accountId);
await service.deleteAccount(accountId);
revalidateAdmin();
revalidateAdmin();
logger.info(
{ accountId },
`Super Admin has successfully deleted account`,
);
logger.info({ accountId }, `Super Admin has successfully deleted account`);
return redirect('/admin/accounts');
},
{
schema: DeleteAccountSchema,
},
),
);
redirect('/admin/accounts');
});
/**
* @name createUserAction
* @description Create a new user in the system.
*/
export const createUserAction = adminAction(
enhanceAction(
async ({ email, password, emailConfirm }) => {
const adminClient = getSupabaseServerAdminClient();
const logger = await getLogger();
export const createUserAction = adminActionClient
.inputSchema(CreateUserSchema)
.action(async ({ parsedInput: { email, password, emailConfirm } }) => {
const adminClient = getSupabaseServerAdminClient();
const logger = await getLogger();
logger.info({ email }, `Super Admin is creating a new user...`);
logger.info({ email }, `Super Admin is creating a new user...`);
const { data, error } = await adminClient.auth.admin.createUser({
email,
password,
email_confirm: emailConfirm,
});
const { data, error } = await adminClient.auth.admin.createUser({
email,
password,
email_confirm: emailConfirm,
});
if (error) {
logger.error({ error }, `Error creating user`);
throw new Error(`Error creating user: ${error.message}`);
}
if (error) {
logger.error({ error }, `Error creating user`);
throw new Error(`Error creating user: ${error.message}`);
}
logger.info(
{ userId: data.user.id },
`Super Admin has successfully created a new user`,
);
logger.info(
{ userId: data.user.id },
`Super Admin has successfully created a new user`,
);
revalidatePath(`/admin/accounts`);
revalidatePath(`/admin/accounts`);
return {
success: true,
user: data.user,
};
},
{
schema: CreateUserSchema,
},
),
);
return {
success: true,
user: data.user,
};
});
/**
* @name resetPasswordAction
* @description Reset a user's password by sending a password reset email.
*/
export const resetPasswordAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const resetPasswordAction = adminActionClient
.inputSchema(ResetPasswordSchema)
.action(async ({ parsedInput: { userId } }) => {
const service = getAdminAuthService();
const logger = await getLogger();
logger.info({ userId }, `Super Admin is resetting user password...`);
logger.info({ userId }, `Super Admin is resetting user password...`);
const result = await service.resetPassword(userId);
const result = await service.resetPassword(userId);
logger.info(
{ userId },
`Super Admin has successfully sent password reset email`,
);
logger.info(
{ userId },
`Super Admin has successfully sent password reset email`,
);
return result;
},
{
schema: ResetPasswordSchema,
},
),
);
return result;
});
function revalidateAdmin() {
revalidatePath(`/admin/accounts/[id]`, 'page');

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { cache } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
const ConfirmationSchema = z.object({
confirmation: z.custom<string>((value) => value === 'CONFIRM'),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email address' }),
@@ -8,4 +8,4 @@ export const CreateUserSchema = z.object({
emailConfirm: z.boolean().default(false).optional(),
});
export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>;
export type CreateUserSchemaType = z.output<typeof CreateUserSchema>;

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
/**
* Schema for resetting a user's password

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';

View File

@@ -1,8 +1,7 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as z from 'zod';
import { Database } from '@kit/supabase/database';

View File

@@ -0,0 +1,20 @@
import 'server-only';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isSuperAdmin } from './is-super-admin';
/**
* @name adminActionClient
* @description Safe action client for admin-only actions.
* Extends authActionClient with super admin verification.
*/
export const adminActionClient = authActionClient.use(async ({ next, ctx }) => {
const isAdmin = await isSuperAdmin(getSupabaseServerClient());
if (!isAdmin) {
throw new Error('Unauthorized');
}
return next({ ctx });
});

View File

@@ -4,7 +4,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}