diff --git a/app/components/admin/users-table.tsx b/app/components/admin/users-table.tsx index 0040f6d2..c12deb21 100644 --- a/app/components/admin/users-table.tsx +++ b/app/components/admin/users-table.tsx @@ -34,7 +34,7 @@ export default function UsersTable({ users, searchText }: UsersTableProps) { const isInputValid = searchText.length >= MIN_USERS_SEARCH_TEXT; const isLoading = navigation.state === 'submitting'; - const shouldShowInstruction = users.length === 0 && !isInputValid && !isLoading; + const shouldShowInstruction = !isInputValid && !isLoading; const shouldShowNoUsersMessage = users.length === 0 && isInputValid && !isLoading; const shouldShowUsers = !(isLoading || shouldShowInstruction || shouldShowNoUsersMessage); @@ -85,6 +85,7 @@ export default function UsersTable({ users, searchText }: UsersTableProps) { name="newEffectiveUsername" value={user.username} /> + - - } - variant="ghost" - /> - +
+ + } + variant="ghost" + type="submit" + /> + + + +
diff --git a/app/lib/user.server.ts b/app/lib/user.server.ts new file mode 100644 index 00000000..88884445 --- /dev/null +++ b/app/lib/user.server.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client'; +import { setIsReconciliationNeeded } from '~/models/system-state.server'; +import { deleteUserByUsername } from '~/models/user.server'; + +export async function deleteUser(username: User['username']) { + await deleteUserByUsername(username); + return setIsReconciliationNeeded(true); +} diff --git a/app/routes/__index/admin/index.tsx b/app/routes/__index/admin/index.tsx index b9902476..68a33f0d 100644 --- a/app/routes/__index/admin/index.tsx +++ b/app/routes/__index/admin/index.tsx @@ -6,14 +6,15 @@ import { Input, InputGroup, InputLeftAddon, + useToast, } from '@chakra-ui/react'; import type { Certificate, User } from '@prisma/client'; import { redirect } from '@remix-run/node'; -import { Form, useSubmit } from '@remix-run/react'; -import { useState } from 'react'; +import { useSubmit } from '@remix-run/react'; +import { useCallback, useEffect, useState } from 'react'; import { FaUsers, FaSearch, FaStickyNote } from 'react-icons/fa'; import { TbFileCertificate } from 'react-icons/tb'; -import { useTypedActionData, useTypedLoaderData } from 'remix-typedjson'; +import { typedjson, useTypedActionData, useTypedLoaderData } from 'remix-typedjson'; import { z } from 'zod'; import { parseFormSafe } from 'zodix'; import AdminMetricCard from '~/components/admin/admin-metric-card'; @@ -24,6 +25,9 @@ import { getTotalUserCount, isUserDeactivated, searchUsers } from '~/models/user import { requireAdmin, setEffectiveUsername } from '~/session.server'; import type { ActionArgs, LoaderArgs } from '@remix-run/node'; +import { deleteUser } from '~/lib/user.server'; + +export type AdminActionIntent = 'search-users' | 'impersonate-user' | 'delete-user'; export interface UserWithMetrics extends User { dnsRecordCount: number; @@ -34,48 +38,80 @@ export const MIN_USERS_SEARCH_TEXT = 3; export const action = async ({ request }: ActionArgs) => { const admin = await requireAdmin(request); - const formData = await request.formData(); - const newEffectiveUsername = formData.get('newEffectiveUsername'); - if (typeof newEffectiveUsername === 'string') { - if (await isUserDeactivated(newEffectiveUsername)) { - return redirect('/'); - } - return redirect('/', { - headers: { - 'Set-Cookie': await setEffectiveUsername(admin.username, newEffectiveUsername), - }, - }); - } const actionParams = await parseFormSafe( - formData, - z.object({ - searchText: z.string().min(MIN_USERS_SEARCH_TEXT), - }) + request, + z + .object({ + intent: z.enum(['search-users', 'impersonate-user', 'delete-user']), + searchText: z.string().min(MIN_USERS_SEARCH_TEXT).optional(), + newEffectiveUsername: z.string().optional(), + username: z.string().optional(), + }) + .refine( + (data) => { + if (data.intent === 'search-users') { + return !!data.searchText; + } + if (data.intent === 'impersonate-user') { + return !!data.newEffectiveUsername; + } + if (data.intent === 'delete-user') { + return !!data.username; + } + return false; + }, + { + message: 'A required field based on the intent is missing or empty.', + path: [], + } + ) ); + if (actionParams.success === false) { - return []; + return { users: [] }; } - const { searchText } = actionParams.data; - - const users = await searchUsers(searchText); - const userStats = await Promise.all( - users.map((user) => - Promise.all([ - getDnsRecordCountByUsername(user.username), - getCertificateByUsername(user.username), - ]) - ) - ); - - const usersWithStats = users.map((user, index): UserWithMetrics => { - const [dnsRecordCount, certificate] = userStats[index]; - - return { ...user, dnsRecordCount, certificate }; - }); - - return usersWithStats; + const { intent } = actionParams.data; + switch (intent) { + case 'search-users': + const { searchText } = actionParams.data; + + const users = await searchUsers(searchText ?? ''); + const userStats = await Promise.all( + users.map((user) => + Promise.all([ + getDnsRecordCountByUsername(user.username), + getCertificateByUsername(user.username), + ]) + ) + ); + + const usersWithStats = users.map((user, index): UserWithMetrics => { + const [dnsRecordCount, certificate] = userStats[index]; + + return { ...user, dnsRecordCount, certificate }; + }); + + return typedjson({ users: usersWithStats }); + case 'impersonate-user': + const { newEffectiveUsername } = actionParams.data; + if (await isUserDeactivated(newEffectiveUsername ?? '')) { + return redirect('/'); + } + return redirect('/', { + headers: { + 'Set-Cookie': await setEffectiveUsername(admin.username, newEffectiveUsername ?? ''), + }, + }); + case 'delete-user': + const { username } = actionParams.data; + await deleteUser(username ?? ''); + + return typedjson({ isUserDeleted: true }); + default: + return typedjson({ result: 'error', message: 'Unknown intent' }); + } }; export const loader = async ({ request }: LoaderArgs) => { @@ -91,15 +127,37 @@ export default function AdminRoute() { const submit = useSubmit(); const { userCount, dnsRecordCount, certificateCount } = useTypedLoaderData(); - const users = useTypedActionData(); + const actionResult = useTypedActionData<{ users?: UserWithMetrics[]; isUserDeleted?: boolean }>(); + + const toast = useToast(); const [searchText, setSearchText] = useState(''); - function onFormChange(event: any) { + const reloadUsers = useCallback(() => { if (searchText.length >= MIN_USERS_SEARCH_TEXT) { - submit(event.currentTarget); + const formData = new FormData(); + formData.append('searchText', searchText); + formData.append('intent', 'search-users'); + + submit(formData, { method: 'post' }); } - } + }, [searchText, submit]); + + useEffect(() => { + if (actionResult?.isUserDeleted) { + toast({ + title: 'User was deleted', + position: 'bottom-right', + status: 'success', + }); + reloadUsers(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionResult?.isUserDeleted]); + + useEffect(() => { + reloadUsers(); + }, [reloadUsers, searchText]); return ( <> @@ -129,21 +187,21 @@ export default function AdminRoute() { Users -
- - - } /> - setSearchText(event.currentTarget.value)} - /> - - Please enter at least 3 characters to search. - -
- + + + + } /> + setSearchText(event.currentTarget.value)} + /> + + Please enter at least 3 characters to search. + + + ); }