This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

@@ -0,0 +1,29 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
const getClient = () => getSupabaseServerActionClient({ admin: true });
export const deleteOrganizationAction = withAdminSession(
async ({ id }: { id: number; csrfToken: string }) => {
const client = getClient();
Logger.info({ id }, `Admin requested to delete Organization`);
await deleteOrganization(client, {
organizationId: id,
});
revalidatePath('/admin/organizations', 'page');
Logger.info({ id }, `Organization account deleted`);
redirect('/admin/organizations');
},
);

View File

@@ -0,0 +1,95 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import type Organization from '@/lib/organizations/types/organization';
import { deleteOrganizationAction } from '../actions.server';
function DeleteOrganizationModal({
organization,
}: React.PropsWithChildren<{
organization: Organization;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
await deleteOrganizationAction({
id: organization.id,
csrfToken,
});
onDismiss();
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Deleting Organization</DialogTitle>
</DialogHeader>
<form action={onConfirm}>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to delete the organization{' '}
<b>{organization.name}</b>.
</p>
<p>
Delete this organization will potentially delete the data
associated with it.
</p>
<p>
<b>This action is not reversible</b>.
</p>
<p>Are you sure you want to do this?</p>
</div>
<div>
<Label>
Confirm by typing <b>DELETE</b>
<Input required type={'text'} pattern={'DELETE'} />
</Label>
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button disabled={pending} variant={'destructive'}>
Yes, delete organization
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export default DeleteOrganizationModal;

View File

@@ -0,0 +1,25 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { getOrganizationByUid } from '@/lib/organizations/database/queries';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import DeleteOrganizationModal from '../components/DeleteOrganizationModal';
interface Params {
params: {
uid: string;
};
}
async function DeleteOrganizationModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = await getOrganizationByUid(client, params.uid);
if (!data || error) {
throw new Error(`Organization not found`);
}
return <DeleteOrganizationModal organization={data} />;
}
export default AdminGuard(DeleteOrganizationModalPage);

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,139 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisVerticalIcon } from 'lucide-react';
import type UserData from '@kit/session/types/user-data';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import type Membership from '@/lib/organizations/types/membership';
import { DataTable } from '@/components/app/DataTable';
import RoleBadge from '../../../../../(app)/[account]/account/organization/components/RoleBadge';
type Data = {
id: Membership['id'];
role: Membership['role'];
user: {
id: UserData['id'];
displayName: UserData['displayName'];
};
};
const columns: ColumnDef<Data>[] = [
{
header: 'Membership ID',
id: 'id',
accessorKey: 'id',
},
{
header: 'User ID',
id: 'user-id',
cell: ({ row }) => {
const userId = row.original.user.id;
return (
<Link className={'hover:underline'} href={`/admin/users/${userId}`}>
{userId}
</Link>
);
},
},
{
header: 'Name',
id: 'name',
accessorKey: 'user.displayName',
},
{
header: 'Role',
cell: ({ row }) => {
return (
<div className={'inline-flex'}>
<RoleBadge role={row.original.role} />
</div>
);
},
},
{
header: 'Actions',
cell: ({ row }) => {
const membership = row.original;
const userId = membership.user.id;
return (
<div className={'flex'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisVerticalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/users/${userId}`}>View User</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${userId}/impersonate`}>
Impersonate User
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function OrganizationsMembersTable({
memberships,
page,
perPage,
pageCount,
}: React.PropsWithChildren<{
memberships: Data[];
page: number;
perPage: number;
pageCount: number;
}>) {
const data = memberships.filter((membership) => {
return membership.user;
});
const router = useRouter();
const path = usePathname();
return (
<DataTable
tableProps={{
'data-test': 'admin-organization-members-table',
}}
onPaginationChange={({ pageIndex }) => {
const { pathname } = new URL(path, window.location.origin);
const page = pageIndex + 1;
router.push(pathname + '?page=' + page);
}}
pageCount={pageCount}
pageIndex={page - 1}
pageSize={perPage}
columns={columns}
data={data}
/>
);
}
export default OrganizationsMembersTable;

View File

@@ -0,0 +1,82 @@
import { use } from 'react';
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import AdminHeader from '@packages/admin/components/AdminHeader';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import getPageFromQueryParams from '../../../utils/get-page-from-query-param';
import { getMembershipsByOrganizationUid } from '../../queries';
import OrganizationsMembersTable from './components/OrganizationsMembersTable';
interface AdminMembersPageParams {
params: {
uid: string;
};
searchParams: {
page?: string;
};
}
export const metadata = {
title: `Members | ${appConfig.name}`,
};
function AdminMembersPage(params: AdminMembersPageParams) {
const adminClient = getSupabaseServerComponentClient({ admin: true });
const uid = params.params.uid;
const perPage = 20;
const page = getPageFromQueryParams(params.searchParams.page);
const { data: memberships, count } = use(
getMembershipsByOrganizationUid(adminClient, { uid, page, perPage }),
);
const pageCount = count ? Math.ceil(count / perPage) : 0;
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Manage Members</AdminHeader>
<PageBody>
<div className={'flex flex-col space-y-4'}>
<Breadcrumbs />
<OrganizationsMembersTable
page={page}
perPage={perPage}
pageCount={pageCount}
memberships={memberships}
/>
</div>
</PageBody>
</div>
);
}
export default AdminMembersPage;
function Breadcrumbs() {
return (
<div className={'flex items-center space-x-2 p-2 text-xs'}>
<div className={'flex items-center space-x-1.5'}>
<Link href={'/admin'}>Admin</Link>
</div>
<ChevronRightIcon className={'w-3'} />
<Link href={'/admin/organizations'}>Organizations</Link>
<ChevronRightIcon className={'w-3'} />
<span>Members</span>
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import Link from 'next/link';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import pricingConfig from '@/config/pricing.config';
import { DataTable } from '@/components/app/DataTable';
import SubscriptionStatusBadge from '../../../(app)/[account]/components/organizations/SubscriptionStatusBadge';
import type { getOrganizations } from '../queries';
type Response = Awaited<ReturnType<typeof getOrganizations>>;
type Organizations = Response['organizations'];
const columns: ColumnDef<Organizations[0]>[] = [
{
header: 'ID',
accessorKey: 'id',
id: 'id',
size: 10,
},
{
header: 'UUID',
accessorKey: 'uuid',
id: 'uuid',
size: 200,
},
{
header: 'Name',
accessorKey: 'name',
id: 'name',
},
{
header: 'Subscription',
id: 'subscription',
cell: ({ row }) => {
const priceId = row.original?.subscription?.data?.priceId;
const plan = pricingConfig.products.find((product) => {
return product.plans.some((plan) => plan.stripePriceId === priceId);
});
if (plan) {
const price = plan.plans.find((plan) => plan.stripePriceId === priceId);
if (!price) {
return 'Unknown Price';
}
return `${plan.name} - ${price.name}`;
}
return '-';
},
},
{
header: 'Subscription Status',
id: 'subscription-status',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
if (!subscription) {
return '-';
}
return <SubscriptionStatusBadge subscription={subscription} />;
},
},
{
header: 'Subscription Period',
id: 'subscription-period',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
const i18n = getI18n();
const language = i18n.language ?? 'en';
if (!subscription) {
return '-';
}
const canceled = subscription.cancelAtPeriodEnd;
const date = subscription.periodEndsAt;
const formattedDate = new Date(date).toLocaleDateString(language);
return canceled ? (
<span className={'text-orange-500'}>Stops on {formattedDate}</span>
) : (
<span className={'text-green-500'}>Renews on {formattedDate}</span>
);
},
},
{
header: 'Members',
id: 'members',
cell: ({ row }) => {
const memberships = row.original.memberships.filter((item) => !item.code);
const invites = row.original.memberships.length - memberships.length;
const uid = row.original.uuid;
const length = memberships.length;
return (
<Link
data-test={'organization-members-link'}
href={`organizations/${uid}/members`}
className={'cursor-pointer hover:underline'}
>
{length} member{length === 1 ? '' : 's'}{' '}
{invites ? `(${invites} invites)` : ''}
</Link>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const organization = row.original;
const uid = organization.uuid;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(uid)}
>
Copy UUID
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/organizations/${uid}/members`}>
View Members
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={'text-red-500'}
href={`/admin/organizations/${uid}/delete`}
>
Delete
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function OrganizationsTable({
organizations,
pageCount,
perPage,
page,
}: React.PropsWithChildren<{
organizations: Organizations;
pageCount: number;
perPage: number;
page: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-organizations-table',
}}
pageSize={perPage}
pageIndex={page - 1}
pageCount={pageCount}
columns={columns}
data={organizations}
/>
);
}
export default OrganizationsTable;

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,21 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { PageBody } from '@/components/app/Page';
function OrganizationsAdminPageError() {
return (
<PageBody>
<Alert variant={'destructive'}>
<AlertTitle>Could not load organizations</AlertTitle>
<AlertDescription>
There was an error loading the organizations. Please check your
console errors.
</AlertDescription>
</Alert>
</PageBody>
);
}
export default OrganizationsAdminPageError;

View File

@@ -0,0 +1,14 @@
function OrganizationsLayout(
props: React.PropsWithChildren<{
modal: React.ReactNode;
}>,
) {
return (
<>
{props.children}
{props.modal}
</>
);
}
export default OrganizationsLayout;

View File

@@ -0,0 +1,69 @@
import AdminGuard from '@/packages/admin/components/AdminGuard';
import AdminHeader from '@/packages/admin/components/AdminHeader';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { Input } from '@kit/ui/input';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import OrganizationsTable from './components/OrganizationsTable';
import { getOrganizations } from './queries';
interface OrganizationsAdminPageProps {
searchParams: {
page?: string;
search?: string;
};
}
export const metadata = {
title: `Organizations | ${appConfig.name}`,
};
async function OrganizationsAdminPage({
searchParams,
}: OrganizationsAdminPageProps) {
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
const client = getSupabaseServerComponentClient({ admin: true });
const perPage = 10;
const search = searchParams.search || '';
const { organizations, count } = await getOrganizations(
client,
search,
page,
perPage,
);
const pageCount = count ? Math.ceil(count / perPage) : 0;
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Manage Organizations</AdminHeader>
<PageBody>
<div className={'flex flex-col space-y-4'}>
<form method={'GET'}>
<Input
name={'search'}
defaultValue={search}
placeholder={'Search Organization...'}
/>
</form>
<OrganizationsTable
perPage={perPage}
page={page}
pageCount={pageCount}
organizations={organizations}
/>
</div>
</PageBody>
</div>
);
}
export default AdminGuard(OrganizationsAdminPage);

View File

@@ -0,0 +1,132 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/database.types';
import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables';
import type { UserOrganizationData } from '@/lib/organizations/database/queries';
import type MembershipRole from '@/lib/organizations/types/membership-role';
type Client = SupabaseClient<Database>;
export async function getOrganizations(
client: Client,
search: string,
page = 1,
perPage = 20,
) {
const startOffset = (page - 1) * perPage;
const endOffset = startOffset - 1 + perPage;
let query = client.from(ORGANIZATIONS_TABLE).select<
string,
UserOrganizationData['organization'] & {
memberships: {
userId: string;
role: MembershipRole;
code: string;
}[];
}
>(
`
id,
uuid,
name,
logoURL: logo_url,
memberships (
userId: user_id,
role,
code
),
subscription: organizations_subscriptions (
customerId: customer_id,
data: subscription_id (
id,
status,
currency,
interval,
cancelAtPeriodEnd: cancel_at_period_end,
intervalCount: interval_count,
priceId: price_id,
createdAt: created_at,
periodStartsAt: period_starts_at,
periodEndsAt: period_ends_at,
trialStartsAt: trial_starts_at,
trialEndsAt: trial_ends_at
)
)`,
{
count: 'exact',
},
);
if (search) {
query = query.ilike('name', `%${search}%`);
}
const {
data: organizations,
count,
error,
} = await query.range(startOffset, endOffset);
if (error) {
throw error;
}
return {
organizations,
count,
};
}
export async function getMembershipsByOrganizationUid(
client: Client,
params: {
uid: string;
page: number;
perPage: number;
},
) {
const startOffset = (params.page - 1) * params.perPage;
const endOffset = startOffset + params.perPage;
const { data, error, count } = await client
.from(MEMBERSHIPS_TABLE)
.select<
string,
{
id: number;
role: MembershipRole;
user: {
id: string;
displayName: string;
photoURL: string;
};
}
>(
`
id,
role,
user: user_id (
id,
displayName: display_name,
photoURL: photo_url
),
organization: organization_id !inner (
id,
uuid
)`,
{
count: 'exact',
},
)
.eq('organization.uuid', params.uid)
.is('code', null)
.range(startOffset, endOffset);
if (error) {
throw error;
}
return { data, count };
}