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:
giancarlo
2024-03-25 15:40:43 +08:00
parent 752259ab17
commit 95793c42b4
135 changed files with 1062 additions and 2872 deletions

View File

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

View File

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

View File

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

View File

@@ -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');
},
);

View File

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

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

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

View File

@@ -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`,
);
}
}

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -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&apos;s do it
</Button>
</div>
</div>
</If>
</DialogContent>
</Dialog>
);
}
export default ImpersonateUserConfirmationModal;

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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