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,21 @@
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { Page } from '@/components/app/Page';
import AdminSidebar from '../../packages/admin/components/AdminSidebar';
import isUserSuperAdmin from './utils/is-user-super-admin';
async function AdminLayout({ children }: React.PropsWithChildren) {
const isAdmin = await isUserSuperAdmin();
if (!isAdmin) {
notFound();
}
const csrfToken = headers().get('X-CSRF-Token');
return <Page sidebar={<AdminSidebar />}>{children}</Page>;
}
export default AdminLayout;

View File

@@ -0,0 +1,21 @@
import { notFound } from 'next/navigation';
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
export function withAdminSession<Args extends unknown[], Response>(
fn: (...params: Args) => Response,
) {
return async (...params: Args) => {
const isAdmin = await isUserSuperAdmin({
client: getSupabaseServerActionClient(),
});
if (!isAdmin) {
notFound();
}
return fn(...params);
};
}

View File

@@ -0,0 +1,3 @@
import { GlobalLoader } from '@kit/ui/global-loader';
export default GlobalLoader;

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 };
}

View File

@@ -0,0 +1,68 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import AdminDashboard from '../../packages/admin/components/AdminDashboard';
import AdminGuard from '../../packages/admin/components/AdminGuard';
import AdminHeader from '../../packages/admin/components/AdminHeader';
export const metadata = {
title: `Admin | ${appConfig.name}`,
};
async function AdminPage() {
const data = await loadData();
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Admin</AdminHeader>
<PageBody>
<AdminDashboard data={data} />
</PageBody>
</div>
);
}
export default AdminGuard(AdminPage);
async function loadData() {
const client = getSupabaseServerComponentClient({ admin: true });
const { count: usersCount } = await client.from('users').select('*', {
count: 'exact',
head: true,
});
const { count: organizationsCount } = await client
.from('organizations')
.select('*', {
count: 'exact',
head: true,
});
const { count: activeSubscriptions } = await client
.from('subscriptions')
.select(`*`, {
count: 'exact',
head: true,
})
.eq('status', 'active');
const { count: trialSubscriptions } = await client
.from('subscriptions')
.select(`*`, {
count: 'exact',
head: true,
})
.eq('status', 'trialing');
return {
usersCount: usersCount || 0,
organizationsCount: organizationsCount || 0,
activeSubscriptions: activeSubscriptions || 0,
trialSubscriptions: trialSubscriptions || 0,
};
}

View File

@@ -0,0 +1,127 @@
'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 banUser = withAdminSession(async ({ userId }) => {
await setBanDuration(userId, `876600h`);
});
export const reactivateUser = withAdminSession(async ({ userId }) => {
await setBanDuration(userId, `none`);
});
export const impersonateUser = withAdminSession(async ({ userId }) => {
await assertUserIsNotCurrentSuperAdmin(userId);
const client = getClient();
const {
data: { user },
error,
} = await client.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 getClient().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,
};
});
export const deleteUserAction = withAdminSession(
async ({ userId }: { userId: string; csrfToken: string }) => {
await assertUserIsNotCurrentSuperAdmin(userId);
Logger.info({ userId }, `Admin requested to delete user account`);
// we don't want to send an email to the user
const sendEmail = false;
await deleteUser({
client: getClient(),
userId,
sendEmail,
});
revalidatePath('/admin/users', 'page');
Logger.info({ userId }, `User account deleted`);
redirect('/admin/users');
},
);
async function setBanDuration(userId: string, banDuration: string) {
await assertUserIsNotCurrentSuperAdmin(userId);
await getClient().auth.admin.updateUserById(userId, {
ban_duration: banDuration,
});
revalidatePath('/admin/users');
}
async function assertUserIsNotCurrentSuperAdmin(targetUserId: string) {
const { data: user } = await getSupabaseServerActionClient().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`,
);
}
}

View File

@@ -0,0 +1,32 @@
import { use } from 'react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import BanUserModal from '../components/BanUserModal';
interface Params {
params: {
uid: string;
};
}
function BanUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
const user = data.user;
const isBanned = 'banned_until' in user && user.banned_until !== 'none';
if (isBanned) {
throw new Error(`The user is already banned`);
}
return <BanUserModal user={user} />;
}
export default AdminGuard(BanUserModalPage);

View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { useFormStatus } from 'react-dom';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
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 ErrorBoundary from '@/components/app/ErrorBoundary';
import { banUser } from '../actions.server';
function BanUserModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const csrfToken = useCsrfToken();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = async () => {
await banUser({
userId: user.id,
csrfToken,
});
onDismiss();
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
<ErrorBoundary fallback={<BanErrorAlert />}>
<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 ban <b>{displayText}</b>.
</p>
<p>
You can unban them later, but they will not be able to log
in or use their account until you do.
</p>
<Label>
Type <b>BAN</b> to confirm
<Input type="text" required pattern={'BAN'} />
</Label>
<p>Are you sure you want to do this?</p>
</div>
<div className={'flex justify-end space-x-2.5'}>
<SubmitButton />
</div>
</div>
</form>
</ErrorBoundary>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button disabled={pending} variant={'destructive'}>
Yes, ban user
</Button>
);
}
export default BanUserModal;
function BanErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>There was an error banning this user.</AlertTitle>
<AlertDescription>Check the logs for more information.</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
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 { deleteUserAction } from '../actions.server';
function DeleteUserModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
await deleteUserAction({
userId: user.id,
csrfToken,
});
onDismiss();
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Deleting User</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 user <b>{displayText}</b>.
</p>
<p>
Delete this user will also delete the organizations they are a
Owner of, and potentially the data associated with those
organizations.
</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 user
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export default DeleteUserModal;

View File

@@ -0,0 +1,52 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import useSupabase from '@kit/hooks/use-supabase';
import Spinner from '@/components/app/Spinner';
function ImpersonateUserAuthSetter({
tokens,
}: React.PropsWithChildren<{
tokens: {
accessToken: string;
refreshToken: string;
};
}>) {
const supabase = useSupabase();
const router = useRouter();
useEffect(() => {
async function setAuth() {
await supabase.auth.setSession({
refresh_token: tokens.refreshToken,
access_token: tokens.accessToken,
});
router.push('/dashboard');
}
void setAuth();
}, [router, tokens, supabase.auth]);
return (
<div
className={
'flex h-screen w-screen flex-1 flex-col items-center justify-center'
}
>
<div className={'flex flex-col items-center space-y-4'}>
<Spinner />
<div>
<p>Setting up your session...</p>
</div>
</div>
</div>
);
}
export default ImpersonateUserAuthSetter;

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import If from '@/components/app/If';
import LoadingOverlay from '@/components/app/LoadingOverlay';
import { impersonateUser } from '../actions.server';
import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter';
function ImpersonateUserConfirmationModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const [error, setError] = useState<boolean>();
const [tokens, setTokens] = useState<{
accessToken: string;
refreshToken: string;
}>();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
try {
const response = await impersonateUser({
userId: user.id,
csrfToken,
});
setTokens(response);
} catch (e) {
setError(true);
}
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Impersonate User</DialogTitle>
</DialogHeader>
<If condition={tokens}>
{(tokens) => {
return (
<>
<ImpersonateUserAuthSetter tokens={tokens} />
<LoadingOverlay>Setting up your session...</LoadingOverlay>
</>
);
}}
</If>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Impersonation Error</AlertTitle>
<AlertDescription>
Sorry, something went wrong. Please check the logs.
</AlertDescription>
</Alert>
</If>
<If condition={!error && !tokens}>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to impersonate the account belonging to{' '}
<b>{displayText}</b> with ID <b>{user.id}</b>.
</p>
<p>
You will be able to log in as them, see and do everything they
can. To return to your own account, simply log out.
</p>
<p>
Like Uncle Ben said, with great power comes great
responsibility. Use this power wisely.
</p>
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button
type={'button'}
disabled={pending}
variant={'destructive'}
onClick={onConfirm}
>
Yes, let&apos;s do it
</Button>
</div>
</div>
</If>
</DialogContent>
</Dialog>
);
}
export default ImpersonateUserConfirmationModal;

View File

@@ -0,0 +1,76 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js';
import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { reactivateUser } from '../actions.server';
function ReactivateUserModal({
user,
}: React.PropsWithChildren<{
user: User;
}>) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(true);
const [pending, startTransition] = useTransition();
const csrfToken = useCsrfToken();
const displayText = user.email ?? user.phone ?? '';
const onDismiss = () => {
router.back();
setIsOpen(false);
};
const onConfirm = () => {
startTransition(async () => {
await reactivateUser({
userId: user.id,
csrfToken,
});
onDismiss();
});
};
return (
<Dialog open={isOpen} onOpenChange={onDismiss}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reactivate User</DialogTitle>
</DialogHeader>
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-2 text-sm'}>
<p>
You are about to reactivate the account belonging to{' '}
<b>{displayText}</b>.
</p>
<p>Are you sure you want to do this?</p>
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button disabled={pending} onClick={onConfirm}>
Yes, reactivate user
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default ReactivateUserModal;

View File

@@ -0,0 +1,25 @@
import { use } from 'react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import DeleteUserModal from '../components/DeleteUserModal';
interface Params {
params: {
uid: string;
};
}
function DeleteUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
return <DeleteUserModal user={data.user} />;
}
export default AdminGuard(DeleteUserModalPage);

View File

@@ -0,0 +1,25 @@
import { use } from 'react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import ImpersonateUserConfirmationModal from '../components/ImpersonateUserConfirmationModal';
interface Params {
params: {
uid: string;
};
}
function ImpersonateUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
return <ImpersonateUserConfirmationModal user={data.user} />;
}
export default AdminGuard(ImpersonateUserModalPage);

View File

@@ -0,0 +1,34 @@
import { use } from 'react';
import { redirect } from 'next/navigation';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import ReactivateUserModal from '../components/ReactivateUserModal';
interface Params {
params: {
uid: string;
};
}
function ReactivateUserModalPage({ params }: Params) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data, error } = use(client.auth.admin.getUserById(params.uid));
if (!data || error) {
throw new Error(`User not found`);
}
const user = data.user;
const isActive = !('banned_until' in user) || user.banned_until === 'none';
if (isActive) {
redirect(`/admin/users`);
}
return <ReactivateUserModal user={user} />;
}
export default AdminGuard(ReactivateUserModalPage);

View File

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

View File

@@ -0,0 +1,68 @@
'use client';
import Link from 'next/link';
import { EllipsisVerticalIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import If from '@/components/app/If';
function UserActionsDropdown({
uid,
isBanned,
}: React.PropsWithChildren<{
uid: string;
isBanned: boolean;
}>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'}>
<span className={'flex items-center space-x-2.5'}>
<span>Manage User</span>
<EllipsisVerticalIcon className={'w-4'} />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${uid}/impersonate`}>Impersonate</Link>
</DropdownMenuItem>
<If condition={!isBanned}>
<DropdownMenuItem asChild>
<Link
className={'text-orange-500'}
href={`/admin/users/${uid}/ban`}
>
Ban
</Link>
</DropdownMenuItem>
</If>
<If condition={isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${uid}/reactivate`}>Reactivate</Link>
</DropdownMenuItem>
</If>
<DropdownMenuItem asChild>
<Link className={'text-red-500'} href={`/admin/users/${uid}/delete`}>
Delete
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default UserActionsDropdown;

View File

@@ -0,0 +1,241 @@
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import configuration from '@/config/app.config';
import type MembershipRole from '@/lib/organizations/types/membership-role';
import { PageBody } from '@/components/app/Page';
import RoleBadge from '../../../(app)/[account]/account/organization/components/RoleBadge';
import AdminGuard from '../../../../packages/admin/components/AdminGuard';
import AdminHeader from '../../../../packages/admin/components/AdminHeader';
import UserActionsDropdown from './components/UserActionsDropdown';
interface Params {
params: {
uid: string;
};
}
export const metadata = {
title: `Manage User | ${configuration.name}`,
};
async function AdminUserPage({ params }: Params) {
const uid = params.uid;
const data = await loadData(uid);
const { auth, user } = data;
const displayName = user?.displayName;
const authUser = auth?.user;
const email = authUser?.email;
const phone = authUser?.phone;
const organizations = data.organizations ?? [];
const isBanned = Boolean(
authUser && 'banned_until' in authUser && authUser.banned_until !== 'none',
);
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Manage User</AdminHeader>
<PageBody>
<div className={'flex flex-col space-y-6'}>
<div className={'flex justify-between'}>
<Breadcrumbs displayName={displayName ?? email ?? ''} />
<div>
<UserActionsDropdown uid={uid} isBanned={isBanned} />
</div>
</div>
<Card>
<CardHeader>
<CardTitle>User Details</CardTitle>
</CardHeader>
<CardContent>
<div className={'flex items-center space-x-2'}>
<div>
<Label>Status</Label>
</div>
<div className={'inline-flex'}>
{isBanned ? (
<Badge variant={'destructive'}>Banned</Badge>
) : (
<Badge variant={'success'}>Active</Badge>
)}
</div>
</div>
<Label>
Display name
<Input
className={'max-w-sm'}
defaultValue={displayName ?? ''}
disabled
/>
</Label>
<Label>
Email
<Input
className={'max-w-sm'}
defaultValue={email ?? ''}
disabled
/>
</Label>
<Label>
Phone number
<Input
className={'max-w-sm'}
defaultValue={phone ?? ''}
disabled
/>
</Label>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Organizations</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Organization ID</TableHead>
<TableHead>UUID</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{organizations.map((membership) => {
const organization = membership.organization;
const href = `/admin/organizations/${organization.uuid}/members`;
return (
<TableRow key={membership.id}>
<TableCell>{organization.id}</TableCell>
<TableCell>{organization.uuid}</TableCell>
<TableCell>
<Link className={'hover:underline'} href={href}>
{organization.name}
</Link>
</TableCell>
<TableCell>
<div className={'inline-flex'}>
<RoleBadge role={membership.role} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</PageBody>
</div>
);
}
export default AdminGuard(AdminUserPage);
async function loadData(uid: string) {
const client = getSupabaseServerComponentClient({ admin: true });
const authUser = client.auth.admin.getUserById(uid);
const userData = client
.from('users')
.select(
`
id,
displayName: display_name,
photoURL: photo_url,
onboarded
`,
)
.eq('id', uid)
.single();
const organizationsQuery = client
.from('memberships')
.select<
string,
{
id: number;
role: MembershipRole;
organization: {
id: number;
uuid: string;
name: string;
};
}
>(
`
id,
role,
organization: organization_id !inner (
id,
uuid,
name
)
`,
)
.eq('user_id', uid);
const [auth, user, organizations] = await Promise.all([
authUser,
userData,
organizationsQuery,
]);
return {
auth: auth.data,
user: user.data,
organizations: organizations.data,
};
}
function Breadcrumbs(
props: React.PropsWithChildren<{
displayName: string;
}>,
) {
return (
<div className={'flex items-center space-x-1 p-2 text-xs'}>
<Link href={'/admin'}>Admin</Link>
<ChevronRightIcon className={'w-3'} />
<Link href={'/admin/users'}>Users</Link>
<ChevronRightIcon className={'w-3'} />
<span>{props.displayName}</span>
</div>
);
}

View File

@@ -0,0 +1,239 @@
'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 type UserData from '@kit/session/types/user-data';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kit/ui/tooltip';
import { DataTable } from '@/components/app/DataTable';
import If from '@/components/app/If';
type UserRow = {
id: string;
email: string | undefined;
phone: string | undefined;
createdAt: string;
updatedAt: string | undefined;
lastSignInAt: string | undefined;
banDuration: string | undefined;
data: UserData;
};
const columns: ColumnDef<UserRow>[] = [
{
header: '',
id: 'avatar',
size: 10,
cell: ({ row }) => {
const user = row.original;
const data = user.data;
const displayName = data?.displayName;
const photoUrl = data?.photoUrl;
const displayText = displayName ?? user.email ?? user.phone ?? '';
return (
<Tooltip>
<TooltipTrigger>
<Avatar>
{photoUrl ? <AvatarImage src={photoUrl} /> : null}
<AvatarFallback>{displayText[0]}</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{displayText}</TooltipContent>
</Tooltip>
);
},
},
{
header: 'ID',
id: 'id',
size: 30,
cell: ({ row }) => {
const id = row.original.id;
return (
<Link className={'hover:underline'} href={`/admin/users/${id}`}>
{id}
</Link>
);
},
},
{
header: 'Email',
id: 'email',
cell: ({ row }) => {
const email = row.original.email;
return (
<span title={email} className={'block max-w-full truncate'}>
{email}
</span>
);
},
},
{
header: 'Name',
size: 50,
id: 'displayName',
cell: ({ row }) => {
return row.original.data?.displayName ?? '';
},
},
{
header: 'Created at',
id: 'createdAt',
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
const i18n = getI18n();
const language = i18n.language ?? 'en';
const createdAtLabel = date.toLocaleDateString(language);
return <span>{createdAtLabel}</span>;
},
},
{
header: 'Last sign in',
id: 'lastSignInAt',
cell: ({ row }) => {
const lastSignInAt = row.original.lastSignInAt;
if (!lastSignInAt) {
return <span>-</span>;
}
const date = new Date(lastSignInAt);
return <span suppressHydrationWarning>{date.toLocaleString()}</span>;
},
},
{
header: 'Status',
id: 'status',
cell: ({ row }) => {
const banDuration = row.original.banDuration;
if (!banDuration || banDuration === 'none') {
return (
<Badge className={'inline-flex'} color={'success'}>
Active
</Badge>
);
}
return (
<Badge className={'inline-flex'} color={'error'}>
Banned
</Badge>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const user = row.original;
const banDuration = row.original.banDuration;
const isBanned = banDuration && banDuration !== 'none';
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(user.id)}
>
Copy user ID
</DropdownMenuItem>
<If condition={!isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/impersonate`}>
Impersonate User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/5'
}
href={`/admin/users/${user.id}/ban`}
>
Ban User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-red-500 hover:bg-red-50 dark:hover:bg-red-500/5'
}
href={`/admin/users/${user.id}/delete`}
>
Delete User
</Link>
</DropdownMenuItem>
</If>
<If condition={isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/reactivate`}>
Reactivate User
</Link>
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function UsersTable({
users,
page,
pageCount,
perPage,
}: React.PropsWithChildren<{
users: UserRow[];
pageCount: number;
page: number;
perPage: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-users-table',
}}
pageIndex={page - 1}
pageSize={perPage}
pageCount={pageCount}
data={users}
columns={columns}
/>
);
}
export default UsersTable;

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 UsersAdminPageError() {
return (
<PageBody>
<Alert variant={'destructive'}>
<AlertTitle>Could not load users</AlertTitle>
<AlertDescription>
There was an error loading the users. Please check your console
errors.
</AlertDescription>
</Alert>
</PageBody>
);
}
export default UsersAdminPageError;

View File

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

View File

@@ -0,0 +1,94 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import type UserData from '@kit/session/types/user-data';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import AdminGuard from '../../../packages/admin/components/AdminGuard';
import AdminHeader from '../../../packages/admin/components/AdminHeader';
import getPageFromQueryParams from '../utils/get-page-from-query-param';
import UsersTable from './components/UsersTable';
import { getUsers } from './queries';
interface UsersAdminPageProps {
searchParams: {
page?: string;
};
}
export const metadata = {
title: `Users | ${appConfig.name}`,
};
async function UsersAdminPage({ searchParams }: UsersAdminPageProps) {
const page = getPageFromQueryParams(searchParams.page);
const perPage = 1;
const { users, total } = await loadUsers(page, perPage);
const pageCount = Math.ceil(total / perPage);
return (
<div className={'flex flex-1 flex-col'}>
<AdminHeader>Users</AdminHeader>
<PageBody>
<UsersTable
users={users}
page={page}
pageCount={pageCount}
perPage={perPage}
/>
</PageBody>
</div>
);
}
export default AdminGuard(UsersAdminPage);
async function loadAuthUsers(page = 1, perPage = 20) {
const client = getSupabaseServerComponentClient({ admin: true });
const response = await client.auth.admin.listUsers({
page,
perPage,
});
if (response.error) {
throw response.error;
}
return response.data;
}
async function loadUsers(page = 1, perPage = 20) {
const { users: authUsers, total } = await loadAuthUsers(page, perPage);
const ids = authUsers.map((user) => user.id);
const usersData = await getUsers(ids);
const users = authUsers
.map((user) => {
const data = usersData.find((u) => u.id === user.id) as UserData;
const banDuration =
'banned_until' in user ? (user.banned_until as string) : 'none';
return {
id: user.id,
email: user.email,
phone: user.phone,
createdAt: user.created_at,
updatedAt: user.updated_at,
lastSignInAt: user.last_sign_in_at,
banDuration,
data,
};
})
.filter(Boolean);
return {
total,
users,
};
}

View File

@@ -0,0 +1,25 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { USERS_TABLE } from '@/lib/db-tables';
export async function getUsers(ids: string[]) {
const client = getSupabaseServerComponentClient({ admin: true });
const { data: users, error } = await client
.from(USERS_TABLE)
.select(
`
id,
photoURL: photo_url,
displayName: display_name,
onboarded
`,
)
.in('id', ids);
if (error) {
throw error;
}
return users;
}

View File

@@ -0,0 +1,16 @@
/**
* Get page from query params
* @name getPageFromQueryParams
* @param pageParam
*/
function getPageFromQueryParams(pageParam: string | undefined) {
const page = pageParam ? parseInt(pageParam) : 1;
if (Number.isNaN(page) || page <= 0) {
return 1;
}
return page;
}
export default getPageFromQueryParams;

View File

@@ -0,0 +1,56 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
/**
* @name ENFORCE_MFA
* @description Set this constant to true if you want the SuperAdmin user to
* sign in using MFA when accessing the Admin page
*/
const ENFORCE_MFA = false;
/**
* @name isUserSuperAdmin
* @description Checks if the current user is an admin by checking the
* user_metadata.role field in Supabase Auth is set to a SuperAdmin role.
*/
const isUserSuperAdmin = async (params: {
client: SupabaseClient<Database>;
enforceMfa?: boolean;
}) => {
const enforceMfa = params.enforceMfa ?? ENFORCE_MFA;
const { data, error } = await params.client.auth.getUser();
if (error) {
return false;
}
// If we enforce MFA, we need to check that the user is MFA authenticated.
if (enforceMfa) {
const isMfaAuthenticated = await verifyIsMultiFactorAuthenticated(
params.client,
);
if (!isMfaAuthenticated) {
return false;
}
}
const adminMetadata = data.user?.app_metadata;
const role = adminMetadata?.role;
return role === 'super-admin';
};
export default isUserSuperAdmin;
async function verifyIsMultiFactorAuthenticated(client: SupabaseClient) {
const { data, error } =
await client.auth.mfa.getAuthenticatorAssuranceLevel();
if (error || !data) {
return false;
}
return data.currentLevel === 'aal2';
}