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

View File

@@ -0,0 +1,127 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
const getClient = () => getSupabaseServerActionClient({ admin: true });
export const banUser = withAdminSession(async ({ userId }) => {
await setBanDuration(userId, `876600h`);
});
export const reactivateUser = withAdminSession(async ({ userId }) => {
await setBanDuration(userId, `none`);
});
export const impersonateUser = withAdminSession(async ({ userId }) => {
await assertUserIsNotCurrentSuperAdmin(userId);
const client = getClient();
const {
data: { user },
error,
} = await client.auth.admin.getUserById(userId);
if (error || !user) {
throw new Error(`Error fetching user`);
}
const email = user.email;
if (!email) {
throw new Error(`User has no email. Cannot impersonate`);
}
const { error: linkError, data } = await getClient().auth.admin.generateLink({
type: 'magiclink',
email,
options: {
redirectTo: `/`,
},
});
if (linkError || !data) {
throw new Error(`Error generating magic link`);
}
const response = await fetch(data.properties?.action_link, {
method: 'GET',
redirect: 'manual',
});
const location = response.headers.get('Location');
if (!location) {
throw new Error(`Error generating magic link. Location header not found`);
}
const hash = new URL(location).hash.substring(1);
const query = new URLSearchParams(hash);
const accessToken = query.get('access_token');
const refreshToken = query.get('refresh_token');
if (!accessToken || !refreshToken) {
throw new Error(
`Error generating magic link. Tokens not found in URL hash.`,
);
}
return {
accessToken,
refreshToken,
};
});
export const deleteUserAction = withAdminSession(
async ({ userId }: { userId: string; csrfToken: string }) => {
await assertUserIsNotCurrentSuperAdmin(userId);
Logger.info({ userId }, `Admin requested to delete user account`);
// we don't want to send an email to the user
const sendEmail = false;
await deleteUser({
client: getClient(),
userId,
sendEmail,
});
revalidatePath('/admin/users', 'page');
Logger.info({ userId }, `User account deleted`);
redirect('/admin/users');
},
);
async function setBanDuration(userId: string, banDuration: string) {
await assertUserIsNotCurrentSuperAdmin(userId);
await getClient().auth.admin.updateUserById(userId, {
ban_duration: banDuration,
});
revalidatePath('/admin/users');
}
async function assertUserIsNotCurrentSuperAdmin(targetUserId: string) {
const { data: user } = await getSupabaseServerActionClient().auth.getUser();
const currentUserId = user.user?.id;
if (!currentUserId) {
throw new Error(`Error fetching user`);
}
if (currentUserId === targetUserId) {
throw new Error(
`You cannot perform a destructive action on your own account as a Super Admin`,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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