B2B-88: add starter kit structure and elements
This commit is contained in:
3
packages/features/admin/eslint.config.mjs
Normal file
3
packages/features/admin/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
17
packages/features/admin/node_modules/.bin/next
generated
vendored
Executable file
17
packages/features/admin/node_modules/.bin/next
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
|
||||
else
|
||||
exec node "$basedir/../next/dist/bin/next" "$@"
|
||||
fi
|
||||
1
packages/features/admin/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@hookform+resolvers@5.0.1_react-hook-form@7.57.0_react@19.1.0_/node_modules/@hookform/resolvers
|
||||
1
packages/features/admin/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/features/admin/node_modules/@kit/next
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@kit/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../next
|
||||
1
packages/features/admin/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/features/admin/node_modules/@kit/shared
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@kit/shared
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../shared
|
||||
1
packages/features/admin/node_modules/@kit/supabase
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@kit/supabase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../supabase
|
||||
1
packages/features/admin/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/features/admin/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../ui
|
||||
1
packages/features/admin/node_modules/@makerkit/data-loader-supabase-core
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@makerkit/data-loader-supabase-core
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@makerkit+data-loader-supabase-core@0.0.10_@supabase+postgrest-js@1.19.4_@supabase+supabase-js@2.49.4/node_modules/@makerkit/data-loader-supabase-core
|
||||
1
packages/features/admin/node_modules/@makerkit/data-loader-supabase-nextjs
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@makerkit/data-loader-supabase-nextjs
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@makerkit+data-loader-supabase-nextjs@1.2.5_@supabase+postgrest-js@1.19.4_@supabase+supabase-_5cjwjjqo4z557ekd5mcvtq4k6q/node_modules/@makerkit/data-loader-supabase-nextjs
|
||||
1
packages/features/admin/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@supabase+supabase-js@2.49.4/node_modules/@supabase/supabase-js
|
||||
1
packages/features/admin/node_modules/@tanstack/react-query
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@tanstack/react-query
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@tanstack+react-query@5.76.1_react@19.1.0/node_modules/@tanstack/react-query
|
||||
1
packages/features/admin/node_modules/@tanstack/react-table
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@tanstack/react-table
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@tanstack+react-table@8.21.3_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-table
|
||||
1
packages/features/admin/node_modules/@types/react
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/features/admin/node_modules/lucide-react
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/lucide-react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/lucide-react@0.510.0_react@19.1.0/node_modules/lucide-react
|
||||
1
packages/features/admin/node_modules/next
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next
|
||||
1
packages/features/admin/node_modules/react
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react@19.1.0/node_modules/react
|
||||
1
packages/features/admin/node_modules/react-dom
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/react-dom
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom
|
||||
1
packages/features/admin/node_modules/react-hook-form
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/react-hook-form
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react-hook-form@7.57.0_react@19.1.0/node_modules/react-hook-form
|
||||
1
packages/features/admin/node_modules/zod
generated
vendored
Symbolic link
1
packages/features/admin/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
45
packages/features/admin/package.json
Normal file
45
packages/features/admin/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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.0.1",
|
||||
"@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": "2.49.4",
|
||||
"@tanstack/react-query": "5.76.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.4",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components/*": "./src/components/*.tsx"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
404
packages/features/admin/src/components/admin-account-page.tsx
Normal file
404
packages/features/admin/src/components/admin-account-page.tsx
Normal 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;
|
||||
}
|
||||
263
packages/features/admin/src/components/admin-accounts-table.tsx
Normal file
263
packages/features/admin/src/components/admin-accounts-table.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
133
packages/features/admin/src/components/admin-ban-user-dialog.tsx
Normal file
133
packages/features/admin/src/components/admin-ban-user-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
95
packages/features/admin/src/components/admin-dashboard.tsx
Normal file
95
packages/features/admin/src/components/admin-dashboard.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
28
packages/features/admin/src/components/admin-guard.tsx
Normal file
28
packages/features/admin/src/components/admin-guard.tsx
Normal 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} />;
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1
packages/features/admin/src/index.ts
Normal file
1
packages/features/admin/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/server/utils/is-super-admin';
|
||||
240
packages/features/admin/src/lib/server/admin-server-actions.ts
Normal file
240
packages/features/admin/src/lib/server/admin-server-actions.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
'use server';
|
||||
|
||||
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';
|
||||
|
||||
import {
|
||||
BanUserSchema,
|
||||
DeleteAccountSchema,
|
||||
DeleteUserSchema,
|
||||
ImpersonateUserSchema,
|
||||
ReactivateUserSchema,
|
||||
} from './schema/admin-actions.schema';
|
||||
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';
|
||||
|
||||
/**
|
||||
* @name banUserAction
|
||||
* @description Ban a user from the system.
|
||||
*/
|
||||
export const banUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is banning user...`);
|
||||
|
||||
await service.banUser(userId);
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully banned user`);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: BanUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name reactivateUserAction
|
||||
* @description Reactivate a user in the system.
|
||||
*/
|
||||
export const reactivateUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is reactivating user...`);
|
||||
|
||||
await service.reactivateUser(userId);
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully reactivated user`);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: ReactivateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name impersonateUserAction
|
||||
* @description Impersonate a user in the system.
|
||||
*/
|
||||
export const impersonateUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is impersonating user...`);
|
||||
|
||||
return await service.impersonateUser(userId);
|
||||
},
|
||||
{
|
||||
schema: ImpersonateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteUserAction
|
||||
* @description Delete a user from the system.
|
||||
*/
|
||||
export const deleteUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is deleting user...`);
|
||||
|
||||
await service.deleteUser(userId);
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully deleted user`);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return redirect('/admin/accounts');
|
||||
},
|
||||
{
|
||||
schema: DeleteUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteAccountAction
|
||||
* @description Delete an account from the system.
|
||||
*/
|
||||
export const deleteAccountAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ accountId }) => {
|
||||
const service = getAdminAccountsService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ accountId }, `Super Admin is deleting account...`);
|
||||
|
||||
await service.deleteAccount(accountId);
|
||||
|
||||
logger.info(
|
||||
{ accountId },
|
||||
`Super Admin has successfully deleted account`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return redirect('/admin/accounts');
|
||||
},
|
||||
{
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @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();
|
||||
|
||||
logger.info({ email }, `Super Admin is creating a new user...`);
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ userId: data.user.id },
|
||||
`Super Admin has successfully created a new user`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: data.user,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: CreateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @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();
|
||||
|
||||
logger.info({ userId }, `Super Admin is resetting user password...`);
|
||||
|
||||
const result = await service.resetPassword(userId);
|
||||
|
||||
logger.info(
|
||||
{ userId },
|
||||
`Super Admin has successfully sent password reset email`,
|
||||
);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return result;
|
||||
},
|
||||
{
|
||||
schema: ResetPasswordSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath('/admin', 'layout');
|
||||
}
|
||||
|
||||
function getAdminAuthService() {
|
||||
const client = getSupabaseServerClient();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
return createAdminAuthUserService(client, adminClient);
|
||||
}
|
||||
|
||||
function getAdminAccountsService() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
return createAdminAccountsService(adminClient);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createAdminDashboardService } from '../services/admin-dashboard.service';
|
||||
|
||||
/**
|
||||
* @name loadAdminDashboard
|
||||
* @description Load the admin dashboard data.
|
||||
* @param params
|
||||
*/
|
||||
export const loadAdminDashboard = cache(adminDashboardLoader);
|
||||
|
||||
function adminDashboardLoader() {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAdminDashboardService(client);
|
||||
|
||||
return service.getDashboardData();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConfirmationSchema = z.object({
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
});
|
||||
|
||||
const UserIdSchema = ConfirmationSchema.extend({
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const BanUserSchema = UserIdSchema;
|
||||
export const ReactivateUserSchema = UserIdSchema;
|
||||
export const ImpersonateUserSchema = UserIdSchema;
|
||||
export const DeleteUserSchema = UserIdSchema;
|
||||
|
||||
export const DeleteAccountSchema = ConfirmationSchema.extend({
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateUserSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: 'Password must be at least 8 characters' }),
|
||||
emailConfirm: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for resetting a user's password
|
||||
*/
|
||||
export const ResetPasswordSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAdminAccountsService(client: SupabaseClient<Database>) {
|
||||
return new AdminAccountsService(client);
|
||||
}
|
||||
|
||||
class AdminAccountsService {
|
||||
constructor(private adminClient: SupabaseClient<Database>) {}
|
||||
|
||||
async deleteAccount(accountId: string) {
|
||||
const { error } = await this.adminClient
|
||||
.from('accounts')
|
||||
.delete()
|
||||
.eq('id', accountId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAdminAuthUserService(
|
||||
client: SupabaseClient<Database>,
|
||||
adminClient: SupabaseClient<Database>,
|
||||
) {
|
||||
return new AdminAuthUserService(client, adminClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name AdminAuthUserService
|
||||
* @description Service for performing admin actions on users in the system.
|
||||
* This service only interacts with the Supabase Auth Admin API.
|
||||
*/
|
||||
class AdminAuthUserService {
|
||||
constructor(
|
||||
private readonly client: SupabaseClient<Database>,
|
||||
private readonly adminClient: SupabaseClient<Database>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Delete a user by deleting the user record and auth record.
|
||||
* @param userId
|
||||
*/
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban a user by setting the ban duration to `876600h` (100 years).
|
||||
* @param userId
|
||||
*/
|
||||
async banUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
return this.setBanDuration(userId, `876600h`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a user by setting the ban duration to `none`.
|
||||
* @param userId
|
||||
*/
|
||||
async reactivateUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
return this.setBanDuration(userId, `none`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Impersonate a user by generating a magic link and returning the access and refresh tokens.
|
||||
* @param userId
|
||||
*/
|
||||
async impersonateUser(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the target user is not the current user.
|
||||
* @param targetUserId
|
||||
*/
|
||||
private 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`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetUser =
|
||||
await this.adminClient.auth.admin.getUserById(targetUserId);
|
||||
|
||||
const targetUserRole = targetUser.data.user?.app_metadata?.role;
|
||||
|
||||
if (targetUserRole === 'super-admin') {
|
||||
throw new Error(
|
||||
`You cannot perform a destructive action on a Super Admin account`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setBanDuration(userId: string, banDuration: string) {
|
||||
await this.adminClient.auth.admin.updateUserById(userId, {
|
||||
ban_duration: banDuration,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a user's password by sending a password reset email.
|
||||
* @param userId
|
||||
*/
|
||||
async resetPassword(userId: string) {
|
||||
await this.assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
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 reset password`);
|
||||
}
|
||||
|
||||
// Get the site URL from environment variable
|
||||
const siteUrl = z.string().url().parse(process.env.NEXT_PUBLIC_SITE_URL);
|
||||
|
||||
const redirectTo = `${siteUrl}/update-password`;
|
||||
|
||||
const { error: resetError } =
|
||||
await this.adminClient.auth.resetPasswordForEmail(email, {
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
if (resetError) {
|
||||
throw new Error(
|
||||
`Error sending password reset email: ${resetError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAdminDashboardService(client: SupabaseClient<Database>) {
|
||||
return new AdminDashboardService(client);
|
||||
}
|
||||
|
||||
export class AdminDashboardService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* Get the dashboard data for the admin dashboard
|
||||
* @param count
|
||||
*/
|
||||
async getDashboardData(
|
||||
{ count }: { count: 'exact' | 'estimated' | 'planned' } = {
|
||||
count: 'estimated',
|
||||
},
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
const ctx = {
|
||||
name: `admin.dashboard`,
|
||||
};
|
||||
|
||||
const selectParams = {
|
||||
count,
|
||||
head: true,
|
||||
};
|
||||
|
||||
const subscriptionsPromise = this.client
|
||||
.from('subscriptions')
|
||||
.select('*', selectParams)
|
||||
.eq('status', 'active')
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching active subscriptions`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const trialsPromise = this.client
|
||||
.from('subscriptions')
|
||||
.select('*', selectParams)
|
||||
.eq('status', 'trialing')
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching trialing subscriptions`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const accountsPromise = this.client
|
||||
.from('accounts')
|
||||
.select('*', selectParams)
|
||||
.eq('is_personal_account', true)
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching personal accounts`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const teamAccountsPromise = this.client
|
||||
.from('accounts')
|
||||
.select('*', selectParams)
|
||||
.eq('is_personal_account', false)
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{ ...ctx, error: response.error.message },
|
||||
`Error fetching team accounts`,
|
||||
);
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.count;
|
||||
});
|
||||
|
||||
const [subscriptions, trials, accounts, teamAccounts] = await Promise.all([
|
||||
subscriptionsPromise,
|
||||
trialsPromise,
|
||||
accountsPromise,
|
||||
teamAccountsPromise,
|
||||
]);
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
trials,
|
||||
accounts,
|
||||
teamAccounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
22
packages/features/admin/src/lib/server/utils/admin-action.ts
Normal file
22
packages/features/admin/src/lib/server/utils/admin-action.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { isSuperAdmin } from './is-super-admin';
|
||||
|
||||
/**
|
||||
* @name adminAction
|
||||
* @description Wrap a server action to ensure the user is a super admin.
|
||||
* @param fn
|
||||
*/
|
||||
export function adminAction<Args, Response>(fn: (params: Args) => Response) {
|
||||
return async (params: Args) => {
|
||||
const isAdmin = await isSuperAdmin(getSupabaseServerClient());
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return fn(params);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name isSuperAdmin
|
||||
* @description Check if the current user is a super admin.
|
||||
* @param client
|
||||
*/
|
||||
export async function isSuperAdmin(client: SupabaseClient<Database>) {
|
||||
try {
|
||||
const { data, error } = await client.rpc('is_super_admin');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
10
packages/features/admin/tsconfig.json
Normal file
10
packages/features/admin/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user