Add Super Admin layout and update subscription functionalities

The key changes made in this code include the addition of a Super Admin layout. Also, subscription functionalities are updated and optimized. This ensures read, write permissions are specific to the relevant user and a helper function has been implemented to check if an account has an active subscription. Furthermore, UI enhancements have been made to the accounts table in the administration section. The seed data has also been modified.
This commit is contained in:
giancarlo
2024-04-24 19:00:55 +07:00
parent dbdccc59bc
commit 936adc271c
13 changed files with 245 additions and 138 deletions

View File

@@ -28,7 +28,7 @@ export async function AdminSidebar() {
<AppLogo href={'/admin'} />
</SidebarContent>
<SidebarContent>
<SidebarContent className={'mt-5'}>
<SidebarGroup label={'Admin'} collapsible={false}>
<SidebarItem end path={'/admin'} Icon={<Home className={'h-4'} />}>
Home

View File

@@ -0,0 +1,30 @@
import Link from 'next/link';
import { Menu } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
export function AdminMobileNavigation() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-8 w-8'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={'/admin'}>Home</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={'/admin/accounts'}>Accounts</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -3,7 +3,6 @@ import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { PageBody, PageHeader } from '@kit/ui/page';
interface SearchParams {
page?: string;
@@ -25,12 +24,6 @@ function AccountsPage({ searchParams }: { searchParams: SearchParams }) {
return (
<>
<PageHeader
title={'Accounts'}
description={`Manage your accounts, view their details, and more.`}
/>
<PageBody>
<ServerDataLoader
table={'accounts'}
client={client}
@@ -51,7 +44,6 @@ function AccountsPage({ searchParams }: { searchParams: SearchParams }) {
);
}}
</ServerDataLoader>
</PageBody>
</>
);
}

View File

@@ -1,11 +1,22 @@
import { Page } from '@kit/ui/page';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
export const metadata = {
title: `Admin`,
title: `Super Admin`,
};
export default function AdminLayout(props: React.PropsWithChildren) {
return <Page sidebar={<AdminSidebar />}>{props.children}</Page>;
return (
<Page sidebar={<AdminSidebar />}>
<PageHeader
mobileNavigation={<AdminMobileNavigation />}
title={'Super Admin'}
description={`Your SaaS stats at a glance`}
/>
<PageBody>{props.children}</PageBody>
</Page>
);
}

View File

@@ -1,15 +1,10 @@
import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { PageBody, PageHeader } from '@kit/ui/page';
function AdminPage() {
return (
<>
<PageHeader title={'Admin'} description={`Your SaaS stats at a glance`} />
<PageBody>
<AdminDashboard />
</PageBody>
</>
);
}

View File

@@ -28,7 +28,18 @@ export async function POST(request: Request) {
);
try {
await service.handleWebhookEvent(request);
await service.handleWebhookEvent(request, {
onEvent(event: string, data: unknown) {
logger.info(
{
...ctx,
event,
data,
},
`Received billing event`,
);
},
});
logger.info(ctx, `Successfully processed billing webhook`);

View File

@@ -91,3 +91,7 @@ Optimize dropdowns for mobile
[data-radix-menu-content] {
@apply rounded-none md:rounded-lg;
}
[data-radix-menu-content] [role="menuitem"] {
@apply md:min-h-0 min-h-12;
}

View File

@@ -1410,9 +1410,7 @@ on conflict (
intv_count
from
item_data
on conflict (subscription_id,
product_id,
variant_id)
on conflict (id)
do update set
price_amount = excluded.price_amount,
quantity = excluded.quantity,
@@ -2154,6 +2152,28 @@ language plpgsql;
grant execute on function public.add_invitations_to_account(text,
public.invitation[]) to authenticated, service_role;
create or replace function public.has_active_subscription(target_account_id uuid)
returns boolean
as $$
begin
return exists (
select
1
from
public.subscriptions
where
account_id = target_account_id
and active = true);
end;
$$
language plpgsql;
grant execute on function public.has_active_subscription(uuid) to
authenticated, service_role;
-- Storage
-- Account Image
insert into storage.buckets(

View File

@@ -57,7 +57,7 @@ execute function "supabase_functions"."http_request"(
INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at", "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token", "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at", "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin", "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change", "phone_change_token", "phone_change_sent_at", "email_change_token_current", "email_change_confirm_status", "banned_until", "reauthentication_token", "reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous") VALUES
('00000000-0000-0000-0000-000000000000', 'b73eb03e-fb7a-424d-84ff-18e2791ce0b4', 'authenticated', 'authenticated', 'custom@makerkit.dev', '$2a$10$b3ZPpU6TU3or30QzrXnZDuATPAx2pPq3JW.sNaneVY3aafMSuR4yi', '2024-04-20 08:38:00.860548+00', NULL, '', '2024-04-20 08:37:43.343769+00', '', NULL, '', '', NULL, '2024-04-20 08:38:00.93864+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "b73eb03e-fb7a-424d-84ff-18e2791ce0b4", "email": "custom@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:37:43.3385+00', '2024-04-20 08:38:00.942809+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', '31a03e74-1639-45b6-bfa7-77447f1a4762', 'authenticated', 'authenticated', 'test@makerkit.dev', '$2a$10$NaMVRrI7NyfwP.AfAVWt6O/abulGnf9BBqwa6DqdMwXMvOCGpAnVO', '2024-04-20 08:20:38.165331+00', NULL, '', NULL, '', NULL, '', '', NULL, '2024-04-20 09:36:02.521776+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "31a03e74-1639-45b6-bfa7-77447f1a4762", "email": "test@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:20:34.459113+00', '2024-04-20 10:07:48.554125+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', '31a03e74-1639-45b6-bfa7-77447f1a4762', 'authenticated', 'authenticated', 'test@makerkit.dev', '$2a$10$NaMVRrI7NyfwP.AfAVWt6O/abulGnf9BBqwa6DqdMwXMvOCGpAnVO', '2024-04-20 08:20:38.165331+00', NULL, '', NULL, '', NULL, '', '', NULL, '2024-04-20 09:36:02.521776+00', '{"provider": "email", "providers": ["email"], "role": "super-admin"}', '{"sub": "31a03e74-1639-45b6-bfa7-77447f1a4762", "email": "test@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:20:34.459113+00', '2024-04-20 10:07:48.554125+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', '5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', 'authenticated', 'authenticated', 'owner@makerkit.dev', '$2a$10$D6arGxWJShy8q4RTW18z7eW0vEm2hOxEUovUCj5f3NblyHfamm5/a', '2024-04-20 08:36:37.517993+00', NULL, '', '2024-04-20 08:36:27.639648+00', '', NULL, '', '', NULL, '2024-04-20 08:36:37.614337+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf", "email": "owner@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:36:27.630379+00', '2024-04-20 08:36:37.617955+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', '6b83d656-e4ab-48e3-a062-c0c54a427368', 'authenticated', 'authenticated', 'member@makerkit.dev', '$2a$10$6h/x.AX.6zzphTfDXIJMzuYx13hIYEi/Iods9FXH19J2VxhsLycfa', '2024-04-20 08:41:15.376778+00', NULL, '', '2024-04-20 08:41:08.689674+00', '', NULL, '', '', NULL, '2024-04-20 08:41:15.484606+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "6b83d656-e4ab-48e3-a062-c0c54a427368", "email": "member@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:41:08.683395+00', '2024-04-20 08:41:15.485494+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false);

View File

@@ -15,6 +15,7 @@ VALUES (tests.get_supabase_uid('primary_owner'), 'stripe', 'cus_test');
-- Call the upsert_subscription function
SELECT public.upsert_subscription(tests.get_supabase_uid('primary_owner'), 'cus_test', 'sub_test', true, 'active', 'stripe', false, 'usd', now(), now() + interval '1 month', '[
{
"id": "sub_123",
"product_id": "prod_test",
"variant_id": "var_test",
"type": "flat",
@@ -24,6 +25,7 @@ SELECT public.upsert_subscription(tests.get_supabase_uid('primary_owner'), 'cus_
"interval_count": 1
},
{
"id": "sub_456",
"product_id": "prod_test_2",
"variant_id": "var_test_2",
"type": "flat",
@@ -57,6 +59,7 @@ SELECT is(
-- Call the upsert_subscription function again to update the subscription
SELECT public.upsert_subscription(tests.get_supabase_uid('primary_owner'), 'cus_test', 'sub_test', false, 'past_due', 'stripe', true, 'usd', now(), now() + interval '1 month', '[
{
"id": "sub_123",
"product_id": "prod_test",
"variant_id": "var_test",
"type": "flat",
@@ -66,6 +69,7 @@ SELECT public.upsert_subscription(tests.get_supabase_uid('primary_owner'), 'cus_
"interval_count": 1
},
{
"id": "sub_456",
"product_id": "prod_test_2",
"variant_id": "var_test_2",
"type": "flat",
@@ -132,21 +136,33 @@ select throws_ok(
'permission denied for function upsert_subscription'
);
select is(
(public.has_active_subscription(tests.get_supabase_uid('primary_owner'))),
true,
'The function public.has_active_subscription should return true when the account has a subscription'
);
-- foreigners
select tests.create_supabase_user('foreigner');
select tests.authenticate_as('foreigner');
-- account cannot read other's subscription
SELECT is_empty(
select is_empty(
$$ select 1 from subscriptions where id = 'sub_test' $$,
'The account cannot read the other account subscriptions'
);
SELECT is_empty(
select is_empty(
$$ select 1 from subscription_items where subscription_id = 'sub_test' $$,
'The account cannot read the other account subscription items'
);
select is(
(public.has_active_subscription(tests.get_supabase_uid('primary_owner'))),
false,
'The function public.has_active_subscription should return false when a foreigner is querying the account subscription'
);
-- Finish the tests and clean up
select * from finish();

View File

@@ -15,6 +15,7 @@ VALUES (makerkit.get_account_id_by_slug('makerkit'), 'stripe', 'cus_test');
-- Call the upsert_subscription function
SELECT public.upsert_subscription(makerkit.get_account_id_by_slug('makerkit'), 'cus_test', 'sub_test', true, 'active', 'stripe', false, 'usd', now(), now() + interval '1 month', '[
{
"id": "sub_123",
"product_id": "prod_test",
"variant_id": "var_test",
"type": "flat",
@@ -24,6 +25,7 @@ SELECT public.upsert_subscription(makerkit.get_account_id_by_slug('makerkit'), '
"interval_count": 1
},
{
"id": "sub_456",
"product_id": "prod_test_2",
"variant_id": "var_test_2",
"type": "flat",
@@ -57,6 +59,7 @@ SELECT is(
-- Call the upsert_subscription function again to update the subscription
SELECT public.upsert_subscription(makerkit.get_account_id_by_slug('makerkit'), 'cus_test', 'sub_test', false, 'past_due', 'stripe', true, 'usd', now(), now() + interval '1 month', '[
{
"id": "sub_123",
"product_id": "prod_test",
"variant_id": "var_test",
"type": "flat",
@@ -66,6 +69,7 @@ SELECT public.upsert_subscription(makerkit.get_account_id_by_slug('makerkit'), '
"interval_count": 1
},
{
"id": "sub_456",
"product_id": "prod_test_2",
"variant_id": "var_test_2",
"type": "flat",
@@ -116,31 +120,43 @@ SELECT is(
select tests.authenticate_as('member');
-- account can read their own subscription
SELECT isnt_empty(
select isnt_empty(
$$ select 1 from subscriptions where id = 'sub_test' $$,
'The account can read their own subscription'
);
SELECT isnt_empty(
select isnt_empty(
$$ select * from subscription_items where subscription_id = 'sub_test' $$,
'The account can read their own subscription items'
);
select is(
(public.has_active_subscription(makerkit.get_account_id_by_slug('makerkit'))),
true,
'The function public.has_active_subscription should return true when the account has a subscription'
);
-- foreigners
select tests.create_supabase_user('foreigner');
select tests.authenticate_as('foreigner');
-- account cannot read other's subscription
SELECT is_empty(
select is_empty(
$$ select 1 from subscriptions where id = 'sub_test' $$,
'The account cannot read the other account subscriptions'
);
SELECT is_empty(
select is_empty(
$$ select 1 from subscription_items where subscription_id = 'sub_test' $$,
'The account cannot read the other account subscription items'
);
select is(
(public.has_active_subscription(makerkit.get_account_id_by_slug('makerkit'))),
false,
'The function public.has_active_subscription should return false when a foreigner is querying the account subscription'
);
-- Finish the tests and clean up
select * from finish();

View File

@@ -7,7 +7,6 @@ import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { PageBody, PageHeader } from '@kit/ui/page';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { Separator } from '@kit/ui/separator';
import {
@@ -59,9 +58,9 @@ async function PersonalAccountPage(props: { account: Account }) {
'banned_until' in data.user && data.user.banned_until !== 'none';
return (
<>
<PageHeader
title={
<div className={'flex flex-col space-y-4'}>
<div className={'flex items-center justify-between'}>
<div className={'flex items-center space-x-4'}>
<div className={'flex items-center space-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
@@ -69,6 +68,7 @@ async function PersonalAccountPage(props: { account: Account }) {
/>
<span>{props.account.name}</span>
</div>
<Badge variant={'outline'}>Personal Account</Badge>
@@ -76,8 +76,7 @@ async function PersonalAccountPage(props: { account: Account }) {
<Badge variant={'destructive'}>Banned</Badge>
</If>
</div>
}
>
<div className={'flex space-x-1'}>
<If condition={isBanned}>
<AdminReactivateUserDialog userId={props.account.id}>
@@ -111,15 +110,14 @@ async function PersonalAccountPage(props: { account: Account }) {
</Button>
</AdminDeleteUserDialog>
</div>
</PageHeader>
</div>
<PageBody>
<div className={'flex flex-col space-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col space-y-2.5'}>
<Heading level={6}>
This user is a member of the following teams:
<Heading className={'font-bold'} level={5}>
Teams
</Heading>
<div>
@@ -127,8 +125,7 @@ async function PersonalAccountPage(props: { account: Account }) {
</div>
</div>
</div>
</PageBody>
</>
</div>
);
}
@@ -138,9 +135,9 @@ async function TeamAccountPage(props: {
const members = await getMembers(props.account.slug ?? '');
return (
<>
<PageHeader
title={
<div className={'flex flex-col space-y-4'}>
<div className={'flex justify-between'}>
<div className={'flex items-center space-x-4'}>
<div className={'flex items-center space-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
@@ -148,33 +145,33 @@ async function TeamAccountPage(props: {
/>
<span>{props.account.name}</span>
</div>
<Badge variant={'outline'}>Team Account</Badge>
</div>
}
>
<AdminDeleteAccountDialog accountId={props.account.id}>
<Button size={'sm'} variant={'destructive'}>
<BadgeX className={'mr-1 h-4'} />
Delete
</Button>
</AdminDeleteAccountDialog>
</PageHeader>
</div>
<PageBody>
<div>
<div className={'flex flex-col space-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<Separator />
<div className={'flex flex-col space-y-2.5'}>
<Heading level={6}>This team has the following members:</Heading>
<Heading className={'font-bold'} level={5}>
Team Members
</Heading>
<AdminMembersTable members={members} />
</div>
</div>
</PageBody>
</>
</div>
</div>
);
}
@@ -203,11 +200,21 @@ async function SubscriptionsTable(props: { accountId: string }) {
return (
<div className={'flex flex-col space-y-2.5'}>
<Heading level={6}>Subscription</Heading>
<Heading className={'font-bold'} level={5}>
Subscription
</Heading>
<If
condition={subscription}
fallback={<>This account does not have an active subscription.</>}
fallback={
<Alert>
<AlertTitle>No subscription found for this account.</AlertTitle>
<AlertDescription>
This account does not have a subscription.
</AlertDescription>
</Alert>
}
>
{(subscription) => {
return (

View File

@@ -21,6 +21,7 @@ import {
} from '@kit/ui/dropdown-menu';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { Form, FormControl, FormField, FormItem } from '@kit/ui/form';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
@@ -98,7 +99,10 @@ function AccountsTableFilters(props: {
};
return (
<div className={'flex justify-end space-x-4'}>
<div className={'flex items-center justify-between space-x-4'}>
<Heading level={4}>Accounts</Heading>
<div className={'flex space-x-4'}>
<Form {...form}>
<form
className={'flex space-x-4'}
@@ -139,7 +143,7 @@ function AccountsTableFilters(props: {
name={'query'}
render={({ field }) => (
<FormItem>
<FormControl className={'min-w-72'}>
<FormControl className={'w-full min-w-36 md:min-w-72'}>
<Input
className={'w-full'}
placeholder={`Search account...`}
@@ -152,6 +156,7 @@ function AccountsTableFilters(props: {
</form>
</Form>
</div>
</div>
);
}