Remove admin functionality related code
The admin functionality related code has been removed which includes various user and organization functionalities like delete, update, ban etc. This includes action logic, UI components and supportive utility functions. Notable deletions include the server action files, dialog components for actions like banning and deleting, and related utility functions. This massive cleanup is aimed at simplifying the codebase and the commit reflects adherence to project restructuring.
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -1,29 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
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');
|
||||
},
|
||||
);
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type Organization from '@/lib/organizations/types/organization';
|
||||
|
||||
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 { 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;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { getOrganizationByUid } from '@/lib/organizations/database/queries';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
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);
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { DataTable } from '@/components/app/DataTable';
|
||||
import type Membership from '@/lib/organizations/types/membership';
|
||||
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 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;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import AdminHeader from '@packages/admin/components/AdminHeader';
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
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;
|
||||
@@ -1,14 +0,0 @@
|
||||
function OrganizationsLayout(
|
||||
props: React.PropsWithChildren<{
|
||||
modal: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
{props.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsLayout;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
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 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);
|
||||
@@ -1,131 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
@@ -1,111 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import ErrorBoundary from '@/components/app/ErrorBoundary';
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
'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;
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import Spinner from '@/components/app/Spinner';
|
||||
|
||||
import useSupabase from '@kit/hooks/use-supabase';
|
||||
|
||||
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;
|
||||
@@ -1,128 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import LoadingOverlay from '@/components/app/LoadingOverlay';
|
||||
|
||||
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 { 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's do it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonateUserConfirmationModal;
|
||||
@@ -1,76 +0,0 @@
|
||||
'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;
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
@@ -1,34 +0,0 @@
|
||||
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);
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import { EllipsisVerticalIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
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;
|
||||
@@ -1,238 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import configuration from '@/config/app.config';
|
||||
import type MembershipRole from '@/lib/organizations/types/membership-role';
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
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;
|
||||
@@ -1,14 +0,0 @@
|
||||
function UserLayout(
|
||||
props: React.PropsWithChildren<{
|
||||
modal: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{props.modal}
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserLayout;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import type UserData from '@kit/session/types/user-data';
|
||||
|
||||
import AdminGuard from '../../../packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '../../../packages/admin/components/AdminHeader';
|
||||
import getPageFromQueryParams from '../utils/get-page-from-query-param';
|
||||
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></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,
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { USERS_TABLE } from '@/lib/db-tables';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,56 +0,0 @@
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user