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 + + + + + ( + + + + + + )} + /> + + +
); }