B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,404 @@
import { BadgeX, Ban, ShieldPlus, VenetianMask } from 'lucide-react';
import { Tables } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
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 {
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 Account = Tables<'accounts'>;
type Membership = Tables<'accounts_memberships'>;
export function AdminAccountPage(props: {
account: Account & { memberships: Membership[] };
}) {
const isPersonalAccount = props.account.is_personal_account;
if (isPersonalAccount) {
return <PersonalAccountPage account={props.account} />;
}
return <TeamAccountPage account={props.account} />;
}
async function PersonalAccountPage(props: { account: Account }) {
const adminClient = getSupabaseServerAdminClient();
const { data, error } = await adminClient.auth.admin.getUserById(
props.account.id,
);
if (!data || error) {
throw new Error(`User not found`);
}
const memberships = await getMemberships(props.account.id);
const isBanned =
'banned_until' in data.user && data.user.banned_until !== 'none';
return (
<>
<PageHeader
className="border-b"
description={
<AppBreadcrumbs
values={{
[props.account.id]:
props.account.name ?? props.account.email ?? 'Account',
}}
/>
}
>
<div className={'flex gap-x-2.5'}>
<If condition={isBanned}>
<AdminReactivateUserDialog userId={props.account.id}>
<Button
size={'sm'}
variant={'secondary'}
data-test={'admin-reactivate-account-button'}
>
<ShieldPlus className={'mr-1 h-4'} />
Reactivate
</Button>
</AdminReactivateUserDialog>
</If>
<If condition={!isBanned}>
<AdminBanUserDialog userId={props.account.id}>
<Button
size={'sm'}
variant={'secondary'}
data-test={'admin-ban-account-button'}
>
<Ban className={'text-destructive mr-1 h-3'} />
Ban
</Button>
</AdminBanUserDialog>
<AdminImpersonateUserDialog userId={props.account.id}>
<Button
size={'sm'}
variant={'secondary'}
data-test={'admin-impersonate-button'}
>
<VenetianMask className={'mr-1 h-4 text-blue-500'} />
Impersonate
</Button>
</AdminImpersonateUserDialog>
</If>
<AdminDeleteUserDialog userId={props.account.id}>
<Button
size={'sm'}
variant={'destructive'}
data-test={'admin-delete-account-button'}
>
<BadgeX className={'mr-1 h-4'} />
Delete
</Button>
</AdminDeleteUserDialog>
</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}
/>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Personal Account</Badge>
<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>
<AdminMembershipsTable memberships={memberships} />
</div>
</div>
</div>
</PageBody>
</>
);
}
async function TeamAccountPage(props: {
account: Account & { memberships: Membership[] };
}) {
const members = await getMembers(props.account.slug ?? '');
return (
<>
<PageHeader
className="border-b"
description={
<AppBreadcrumbs
values={{
[props.account.id]:
props.account.name ?? props.account.email ?? 'Account',
}}
/>
}
>
<AdminDeleteAccountDialog accountId={props.account.id}>
<Button
size={'sm'}
variant={'destructive'}
data-test={'admin-delete-account-button'}
>
<BadgeX className={'mr-1 h-4'} />
Delete
</Button>
</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}
/>
<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 className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Team Members</Heading>
<AdminMembersTable members={members} />
</div>
</div>
</div>
</PageBody>
</>
);
}
async function SubscriptionsTable(props: { accountId: string }) {
const client = getSupabaseServerClient();
const { data: subscription, error } = await client
.from('subscriptions')
.select('*, subscription_items !inner (*)')
.eq('account_id', props.accountId)
.maybeSingle();
if (error) {
return (
<Alert variant={'destructive'}>
<AlertTitle>There was an error loading subscription.</AlertTitle>
<AlertDescription>
Please check the logs for more information or try again later.
</AlertDescription>
</Alert>
);
}
return (
<div className={'flex flex-col gap-y-1'}>
<Heading level={6}>Subscription</Heading>
<If
condition={subscription}
fallback={
<span className={'text-muted-foreground text-sm'}>
This account does not currently have a subscription.
</span>
}
>
{(subscription) => {
return (
<div className={'flex flex-col space-y-4'}>
<Table>
<TableHeader>
<TableHead>Subscription ID</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Customer ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Period Starts At</TableHead>
<TableHead>Ends At</TableHead>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<span>{subscription.id}</span>
</TableCell>
<TableCell>
<span>{subscription.billing_provider}</span>
</TableCell>
<TableCell>
<span>{subscription.billing_customer_id}</span>
</TableCell>
<TableCell>
<span>{subscription.status}</span>
</TableCell>
<TableCell>
<span>{subscription.created_at}</span>
</TableCell>
<TableCell>
<span>{subscription.period_starts_at}</span>
</TableCell>
<TableCell>
<span>{subscription.period_ends_at}</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Table>
<TableHeader>
<TableHead>Product ID</TableHead>
<TableHead>Variant ID</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Type</TableHead>
</TableHeader>
<TableBody>
{subscription.subscription_items.map((item) => {
return (
<TableRow key={item.variant_id}>
<TableCell>
<span>{item.product_id}</span>
</TableCell>
<TableCell>
<span>{item.variant_id}</span>
</TableCell>
<TableCell>
<span>{item.quantity}</span>
</TableCell>
<TableCell>
<span>{item.price_amount}</span>
</TableCell>
<TableCell>
<span>{item.interval}</span>
</TableCell>
<TableCell>
<span>{item.type}</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}}
</If>
</div>
);
}
async function getMemberships(userId: string) {
const client = getSupabaseServerClient();
const memberships = await client
.from('accounts_memberships')
.select<
string,
Membership & {
account: {
id: string;
name: string;
};
}
>('*, account: account_id !inner (id, name)')
.eq('user_id', userId);
if (memberships.error) {
throw memberships.error;
}
return memberships.data;
}
async function getMembers(accountSlug: string) {
const client = getSupabaseServerClient();
const members = await client.rpc('get_account_members', {
account_slug: accountSlug,
});
if (members.error) {
throw members.error;
}
return members.data;
}

View File

@@ -0,0 +1,263 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { ColumnDef } from '@tanstack/react-table';
import { EllipsisVertical } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} 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,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
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';
import { AdminResetPasswordDialog } from './admin-reset-password-dialog';
type Account = Database['public']['Tables']['accounts']['Row'];
const FiltersSchema = z.object({
type: z.enum(['all', 'team', 'personal']),
query: z.string().optional(),
});
export function AdminAccountsTable(
props: React.PropsWithChildren<{
data: Account[];
pageCount: number;
pageSize: number;
page: number;
filters: {
type: 'all' | 'team' | 'personal';
query: string;
};
}>,
) {
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex justify-end'}>
<AccountsTableFilters filters={props.filters} />
</div>
<DataTable
pageSize={props.pageSize}
pageIndex={props.page - 1}
pageCount={props.pageCount}
data={props.data}
columns={getColumns()}
/>
</div>
);
}
function AccountsTableFilters(props: {
filters: z.infer<typeof FiltersSchema>;
}) {
const form = useForm({
resolver: zodResolver(FiltersSchema),
defaultValues: {
type: props.filters?.type ?? 'all',
query: props.filters?.query ?? '',
},
mode: 'onChange',
reValidateMode: 'onChange',
});
const router = useRouter();
const pathName = usePathname();
const onSubmit = ({ type, query }: z.infer<typeof FiltersSchema>) => {
const params = new URLSearchParams({
account_type: type,
query: query ?? '',
});
const url = `${pathName}?${params.toString()}`;
router.push(url);
};
return (
<Form {...form}>
<form
className={'flex gap-2.5'}
onSubmit={form.handleSubmit((data) => onSubmit(data))}
>
<Select
value={form.watch('type')}
onValueChange={(value) => {
form.setValue(
'type',
value as z.infer<typeof FiltersSchema>['type'],
{
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
},
);
return onSubmit(form.getValues());
}}
>
<SelectTrigger>
<SelectValue placeholder={'Account Type'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Account Type</SelectLabel>
<SelectItem value={'all'}>All accounts</SelectItem>
<SelectItem value={'team'}>Team</SelectItem>
<SelectItem value={'personal'}>Personal</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormField
name={'query'}
render={({ field }) => (
<FormItem>
<FormControl className={'w-full min-w-36 md:min-w-80'}>
<Input
data-test={'admin-accounts-table-filter-input'}
className={'w-full'}
placeholder={`Search account...`}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
);
}
function getColumns(): ColumnDef<Account>[] {
return [
{
id: 'name',
header: 'Name',
cell: ({ row }) => {
return (
<Link
className={'hover:underline'}
href={`/admin/accounts/${row.original.id}`}
>
{row.original.name}
</Link>
);
},
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'type',
header: 'Type',
cell: ({ row }) => {
return row.original.is_personal_account ? 'Personal' : 'Team';
},
},
{
id: 'created_at',
header: 'Created At',
accessorKey: 'created_at',
},
{
id: 'updated_at',
header: 'Updated At',
accessorKey: 'updated_at',
},
{
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>
);
},
},
];
}

View File

@@ -0,0 +1,133 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
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 { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { banUserAction } from '../lib/server/admin-server-actions';
import { BanUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminBanUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(BanUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Ban User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to ban this user? Please note that the user
will stay logged in until their session expires.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-ban-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await banUserAction(data);
setError(false);
} catch {
setError(true);
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error banning 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?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
disabled={pending}
type={'submit'}
variant={'destructive'}
>
Ban User
</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { createUserAction } from '../lib/server/admin-server-actions';
import {
CreateUserSchema,
CreateUserSchemaType,
} 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 form = useForm<CreateUserSchemaType>({
resolver: zodResolver(CreateUserSchema),
defaultValues: {
email: '',
password: '',
emailConfirm: false,
},
mode: 'onChange',
});
const onSubmit = (data: CreateUserSchemaType) => {
startTransition(async () => {
try {
const result = await createUserAction(data);
if (result.success) {
toast.success('User creates successfully');
form.reset();
setOpen(false);
}
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
}
});
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Create New User</AlertDialogTitle>
<AlertDialogDescription>
Complete the form below to create a new user.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-create-user-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
>
<If condition={!!error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
<FormField
name={'email'}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
required
type="email"
placeholder={'user@example.com'}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name={'password'}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
required
type="password"
placeholder={'Password'}
{...field}
/>
</FormControl>
<FormDescription>
Password must be at least 8 characters long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name={'emailConfirm'}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="flex flex-col space-y-1">
<FormLabel>Auto-confirm email</FormLabel>
<FormDescription>
If checked, the user&apos;s email will be automatically
confirmed.
</FormDescription>
</div>
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={pending} type={'submit'}>
{pending ? 'Creating...' : 'Create User'}
</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,95 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { loadAdminDashboard } from '../lib/server/loaders/admin-dashboard.loader';
export async function AdminDashboard() {
const data = await loadAdminDashboard();
return (
<div
className={
'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3' +
' xl:grid-cols-4'
}
>
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>
The number of personal accounts that have been created.
</CardDescription>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{data.accounts}</Figure>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Team Accounts</CardTitle>
<CardDescription>
The number of team accounts that have been created.
</CardDescription>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{data.teamAccounts}</Figure>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Paying Customers</CardTitle>
<CardDescription>
The number of paying customers with active subscriptions.
</CardDescription>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{data.subscriptions}</Figure>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Trials</CardTitle>
<CardDescription>
The number of trial subscriptions currently active.
</CardDescription>
</CardHeader>
<CardContent>
<div className={'flex justify-between'}>
<Figure>{data.trials}</Figure>
</div>
</CardContent>
</Card>
<div>
<p className={'text-muted-foreground w-max text-xs'}>
The above data is estimated and may not be 100% accurate.
</p>
</div>
</div>
);
}
function Figure(props: React.PropsWithChildren) {
return <div className={'text-3xl font-bold'}>{props.children}</div>;
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
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 { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { deleteAccountAction } from '../lib/server/admin-server-actions';
import { DeleteAccountSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteAccountDialog(
props: React.PropsWithChildren<{
accountId: string;
}>,
) {
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>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this account? All the data
associated with this account will be permanently deleted. Any active
subscriptions will be canceled.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
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>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
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 { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { deleteUserAction } from '../lib/server/admin-server-actions';
import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
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>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this user? All the data associated
with this user will be permanently deleted. Any active subscriptions
will be canceled.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
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 {
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>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,28 @@
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isSuperAdmin } from '../lib/server/utils/is-super-admin';
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
/**
* AdminGuard is a server component wrapper that checks if the user is a super-admin before rendering the component.
* If the user is not a super-admin, we redirect to a 404.
* @param Component - The Page or Layout component to wrap
*/
export function AdminGuard<Params extends object>(
Component: LayoutOrPageComponent<Params>,
) {
return async function AdminGuardServerComponentWrapper(params: Params) {
const client = getSupabaseServerClient();
const isUserSuperAdmin = await isSuperAdmin(client);
// if the user is not a super-admin, we redirect to a 404
if (!isUserSuperAdmin) {
notFound();
}
return <Component {...params} />;
};
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useState, useTransition } from 'react';
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 { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
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 { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { impersonateUserAction } from '../lib/server/admin-server-actions';
import { ImpersonateUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminImpersonateUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
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);
if (tokens) {
return (
<>
<ImpersonateUserAuthSetter tokens={tokens} />
<LoadingOverlay>Setting up your session...</LoadingOverlay>
</>
);
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Impersonate User</AlertDialogTitle>
<AlertDialogDescription className={'flex flex-col space-y-1'}>
<span>
Are you sure you want to impersonate this user? You will be logged
in as this user. To stop impersonating, log out.
</span>
<span>
<b>NB:</b> If the user has 2FA enabled, you will not be able to
impersonate them.
</span>
</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>
</AlertDialogContent>
</AlertDialog>
);
}
function ImpersonateUserAuthSetter({
tokens,
}: React.PropsWithChildren<{
tokens: {
accessToken: string;
refreshToken: string;
};
}>) {
useSetSession(tokens);
return <LoadingOverlay>Setting up your session...</LoadingOverlay>;
}
function useSetSession(tokens: { accessToken: string; refreshToken: string }) {
const supabase = useSupabase();
return useQuery({
queryKey: ['impersonate-user', tokens.accessToken, tokens.refreshToken],
gcTime: 0,
queryFn: async () => {
await supabase.auth.signOut();
await supabase.auth.setSession({
refresh_token: tokens.refreshToken,
access_token: tokens.accessToken,
});
// use a hard refresh to avoid hitting cached pages
window.location.replace('/home');
},
});
}

View File

@@ -0,0 +1,67 @@
'use client';
import Link from 'next/link';
import { ColumnDef } from '@tanstack/react-table';
import { Database } from '@kit/supabase/database';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
type Memberships =
Database['public']['Functions']['get_account_members']['Returns'][number];
export function AdminMembersTable(props: { members: Memberships[] }) {
return <DataTable data={props.members} columns={getColumns()} />;
}
function getColumns(): ColumnDef<Memberships>[] {
return [
{
header: 'User ID',
accessorKey: 'user_id',
},
{
header: 'Name',
cell: ({ row }) => {
const name = row.original.name ?? row.original.email;
return (
<div className={'flex items-center space-x-2'}>
<div>
<ProfileAvatar
pictureUrl={row.original.picture_url}
displayName={name}
/>
</div>
<Link
className={'hover:underline'}
href={`/admin/accounts/${row.original.id}`}
>
<span>{name}</span>
</Link>
</div>
);
},
},
{
header: 'Email',
accessorKey: 'email',
},
{
header: 'Role',
cell: ({ row }) => {
return row.original.role;
},
},
{
header: 'Created At',
accessorKey: 'created_at',
},
{
header: 'Updated At',
accessorKey: 'updated_at',
},
];
}

View File

@@ -0,0 +1,53 @@
'use client';
import Link from 'next/link';
import { ColumnDef } from '@tanstack/react-table';
import { Tables } from '@kit/supabase/database';
import { DataTable } from '@kit/ui/enhanced-data-table';
type Membership = Tables<'accounts_memberships'> & {
account: {
id: string;
name: string;
};
};
export function AdminMembershipsTable(props: { memberships: Membership[] }) {
return <DataTable data={props.memberships} columns={getColumns()} />;
}
function getColumns(): ColumnDef<Membership>[] {
return [
{
header: 'User ID',
accessorKey: 'user_id',
},
{
header: 'Team',
cell: ({ row }) => {
return (
<Link
className={'hover:underline'}
href={`/admin/accounts/${row.original.account_id}`}
>
{row.original.account.name}
</Link>
);
},
},
{
header: 'Role',
accessorKey: 'account_role',
},
{
header: 'Created At',
accessorKey: 'created_at',
},
{
header: 'Updated At',
accessorKey: 'updated_at',
},
];
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
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 { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { reactivateUserAction } from '../lib/server/admin-server-actions';
import { ReactivateUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminReactivateUserDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(ReactivateUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reactivate User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reactivate this user?
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-reactivate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await reactivateUserAction(data);
setError(false);
} catch {
setError(true);
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error reactivating 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?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={pending} type={'submit'}>
{pending ? 'Reactivating...' : 'Reactivate User'}
</Button>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
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 { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { resetPasswordAction } from '../lib/server/admin-server-actions';
const FormSchema = z.object({
userId: z.string().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.');
}
});
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send a Reset Password Email</AlertDialogTitle>
<AlertDialogDescription>
Do you want to send a reset password email to this user?
</AlertDialogDescription>
</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>
</div>
</AlertDialogContent>
</AlertDialog>
);
}