From 936adc271cc09b26b571c2dbdf323da575942c7d Mon Sep 17 00:00:00 2001 From: giancarlo Date: Wed, 24 Apr 2024 19:00:55 +0700 Subject: [PATCH] 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. --- .../app/admin/_components/admin-sidebar.tsx | 2 +- .../admin/_components/mobile-navigation.tsx | 30 ++++++ apps/web/app/admin/accounts/page.tsx | 48 ++++----- apps/web/app/admin/layout.tsx | 17 ++- apps/web/app/admin/page.tsx | 7 +- apps/web/app/api/billing/webhook/route.ts | 13 ++- apps/web/styles/globals.css | 4 + .../migrations/20221215192558_schema.sql | 26 ++++- apps/web/supabase/seed.sql | 2 +- .../personal-billing-subscriptions.test.sql | 20 +++- .../team-billing-subscriptions.test.sql | 24 ++++- .../src/components/admin-account-page.tsx | 89 ++++++++------- .../src/components/admin-accounts-table.tsx | 101 +++++++++--------- 13 files changed, 245 insertions(+), 138 deletions(-) create mode 100644 apps/web/app/admin/_components/mobile-navigation.tsx diff --git a/apps/web/app/admin/_components/admin-sidebar.tsx b/apps/web/app/admin/_components/admin-sidebar.tsx index 69cdc4654..5ba0575e1 100644 --- a/apps/web/app/admin/_components/admin-sidebar.tsx +++ b/apps/web/app/admin/_components/admin-sidebar.tsx @@ -28,7 +28,7 @@ export async function AdminSidebar() { - + }> Home diff --git a/apps/web/app/admin/_components/mobile-navigation.tsx b/apps/web/app/admin/_components/mobile-navigation.tsx new file mode 100644 index 000000000..c5565b915 --- /dev/null +++ b/apps/web/app/admin/_components/mobile-navigation.tsx @@ -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 ( + + + + + + + + Home + + + + Accounts + + + + ); +} diff --git a/apps/web/app/admin/accounts/page.tsx b/apps/web/app/admin/accounts/page.tsx index cd4fc4c72..99e2aa050 100644 --- a/apps/web/app/admin/accounts/page.tsx +++ b/apps/web/app/admin/accounts/page.tsx @@ -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,33 +24,26 @@ function AccountsPage({ searchParams }: { searchParams: SearchParams }) { return ( <> - - - - - {({ data, page, pageSize, pageCount }) => { - return ( - - ); - }} - - + + {({ data, page, pageSize, pageCount }) => { + return ( + + ); + }} + ); } diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index d588b2e7e..880f4398b 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -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 }>{props.children}; + return ( + }> + } + title={'Super Admin'} + description={`Your SaaS stats at a glance`} + /> + + {props.children} + + ); } diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index b5c69a0ae..98ebcbdab 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -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 ( <> - - - - - + ); } diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts index 1657b7bc3..d0c87257c 100644 --- a/apps/web/app/api/billing/webhook/route.ts +++ b/apps/web/app/api/billing/webhook/route.ts @@ -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`); diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index d401e411a..4551fa264 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -90,4 +90,8 @@ 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; } \ No newline at end of file diff --git a/apps/web/supabase/migrations/20221215192558_schema.sql b/apps/web/supabase/migrations/20221215192558_schema.sql index 33d2d1a86..17a2492cf 100644 --- a/apps/web/supabase/migrations/20221215192558_schema.sql +++ b/apps/web/supabase/migrations/20221215192558_schema.sql @@ -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( diff --git a/apps/web/supabase/seed.sql b/apps/web/supabase/seed.sql index bfba5e010..455e02a82 100644 --- a/apps/web/supabase/seed.sql +++ b/apps/web/supabase/seed.sql @@ -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); diff --git a/apps/web/supabase/tests/database/personal-billing-subscriptions.test.sql b/apps/web/supabase/tests/database/personal-billing-subscriptions.test.sql index 3c08cb60d..542cc1746 100644 --- a/apps/web/supabase/tests/database/personal-billing-subscriptions.test.sql +++ b/apps/web/supabase/tests/database/personal-billing-subscriptions.test.sql @@ -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(); diff --git a/apps/web/supabase/tests/database/team-billing-subscriptions.test.sql b/apps/web/supabase/tests/database/team-billing-subscriptions.test.sql index aebc6c705..2aa6b13e7 100644 --- a/apps/web/supabase/tests/database/team-billing-subscriptions.test.sql +++ b/apps/web/supabase/tests/database/team-billing-subscriptions.test.sql @@ -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(); diff --git a/packages/features/admin/src/components/admin-account-page.tsx b/packages/features/admin/src/components/admin-account-page.tsx index 247e9bfb2..a0061593b 100644 --- a/packages/features/admin/src/components/admin-account-page.tsx +++ b/packages/features/admin/src/components/admin-account-page.tsx @@ -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 ( - <> - +
+
{props.account.name} - - Personal Account - - - Banned -
- } - > + + Personal Account + + + Banned + +
+
@@ -111,24 +110,22 @@ async function PersonalAccountPage(props: { account: Account }) {
- +
- -
- +
+ -
- - This user is a member of the following teams: - +
+ + Teams + -
- -
+
+
- - +
+
); } @@ -138,9 +135,9 @@ async function TeamAccountPage(props: { const members = await getMembers(props.account.slug ?? ''); return ( - <> - +
+
{props.account.name} - - Team Account
- } - > + + Team Account +
+ - +
- +
- -
- This team has the following members: + + Team Members +
- - +
+
); } @@ -203,11 +200,21 @@ async function SubscriptionsTable(props: { accountId: string }) { return (
- Subscription + + Subscription + This account does not have an active subscription.} + fallback={ + + No subscription found for this account. + + + This account does not have a subscription. + + + } > {(subscription) => { return ( diff --git a/packages/features/admin/src/components/admin-accounts-table.tsx b/packages/features/admin/src/components/admin-accounts-table.tsx index ffc625f84..764f5535b 100644 --- a/packages/features/admin/src/components/admin-accounts-table.tsx +++ b/packages/features/admin/src/components/admin-accounts-table.tsx @@ -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,59 +99,63 @@ function AccountsTableFilters(props: { }; return ( -
-
- onSubmit(data))} - > - { + form.setValue( + 'type', + value as z.infer['type'], + { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }, + ); - - - Account Type + return onSubmit(form.getValues()); + }} + > + + + - All accounts - Team - Personal - - - + + + Account Type - ( - - - - - - )} - /> - - + All accounts + Team + Personal + + + + + ( + + + + + + )} + /> + + +
); }