From d1ffeb2f2cb6436b5cb5050158bff61e4374e12f Mon Sep 17 00:00:00 2001 From: Myrfion Date: Wed, 12 Apr 2023 18:37:41 -0400 Subject: [PATCH 1/3] Add user deactivation functionality --- app/components/admin/users-table.tsx | 20 +++-- app/lib/user.server.ts | 8 ++ app/routes/__index/admin/index.tsx | 129 ++++++++++++++++++--------- 3 files changed, 110 insertions(+), 47 deletions(-) create mode 100644 app/lib/user.server.ts diff --git a/app/components/admin/users-table.tsx b/app/components/admin/users-table.tsx index 0040f6d2..94016cc1 100644 --- a/app/components/admin/users-table.tsx +++ b/app/components/admin/users-table.tsx @@ -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..c7d70ed8 --- /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); + setIsReconciliationNeeded(true); +} diff --git a/app/routes/__index/admin/index.tsx b/app/routes/__index/admin/index.tsx index b9902476..9df35f81 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 { 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' | 'deactivate-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', 'deactivate-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 === 'deactivate-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 'deactivate-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,7 +127,9 @@ 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(''); @@ -101,6 +139,16 @@ export default function AdminRoute() { } } + useEffect(() => { + if (actionResult?.isUserDeleted) { + toast({ + title: 'User was deleted', + position: 'bottom-right', + status: 'success', + }); + } + }, [actionResult, toast]); + return ( <> @@ -130,6 +178,7 @@ export default function AdminRoute() { Users
+ } /> @@ -143,7 +192,7 @@ export default function AdminRoute() { Please enter at least 3 characters to search.
- + ); } From 3dff1fb9c4d87d1bbeae2b8f21991142365d6cca Mon Sep 17 00:00:00 2001 From: Myrfion Date: Wed, 12 Apr 2023 19:11:25 -0400 Subject: [PATCH 2/3] Update users search triggering logic --- app/components/admin/users-table.tsx | 4 +-- app/routes/__index/admin/index.tsx | 49 ++++++++++++++++------------ 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/app/components/admin/users-table.tsx b/app/components/admin/users-table.tsx index 94016cc1..c18971bb 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); @@ -94,7 +94,7 @@ export default function UsersTable({ users, searchText }: UsersTableProps) { /> -
+ { 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) { @@ -146,8 +150,14 @@ export default function AdminRoute() { position: 'bottom-right', status: 'success', }); + reloadUsers(); } - }, [actionResult, toast]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionResult?.isUserDeleted]); + + useEffect(() => { + reloadUsers(); + }, [reloadUsers, searchText]); return ( <> @@ -177,21 +187,20 @@ 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. + + ); From a93b357ae8b564514cdd77a60b14ff3f080226d1 Mon Sep 17 00:00:00 2001 From: Myrfion Date: Thu, 13 Apr 2023 14:11:49 -0400 Subject: [PATCH 3/3] Updates after feedback --- app/components/admin/users-table.tsx | 6 +++--- app/lib/user.server.ts | 2 +- app/routes/__index/admin/index.tsx | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/components/admin/users-table.tsx b/app/components/admin/users-table.tsx index c18971bb..c12deb21 100644 --- a/app/components/admin/users-table.tsx +++ b/app/components/admin/users-table.tsx @@ -95,16 +95,16 @@ export default function UsersTable({ users, searchText }: UsersTableProps) {
- + } variant="ghost" type="submit" /> - + diff --git a/app/lib/user.server.ts b/app/lib/user.server.ts index c7d70ed8..88884445 100644 --- a/app/lib/user.server.ts +++ b/app/lib/user.server.ts @@ -4,5 +4,5 @@ import { deleteUserByUsername } from '~/models/user.server'; export async function deleteUser(username: User['username']) { await deleteUserByUsername(username); - setIsReconciliationNeeded(true); + return setIsReconciliationNeeded(true); } diff --git a/app/routes/__index/admin/index.tsx b/app/routes/__index/admin/index.tsx index f6a9f760..68a33f0d 100644 --- a/app/routes/__index/admin/index.tsx +++ b/app/routes/__index/admin/index.tsx @@ -10,7 +10,7 @@ import { } 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 { 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'; @@ -27,7 +27,7 @@ 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' | 'deactivate-user'; +export type AdminActionIntent = 'search-users' | 'impersonate-user' | 'delete-user'; export interface UserWithMetrics extends User { dnsRecordCount: number; @@ -43,7 +43,7 @@ export const action = async ({ request }: ActionArgs) => { request, z .object({ - intent: z.enum(['search-users', 'impersonate-user', 'deactivate-user']), + 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(), @@ -56,7 +56,7 @@ export const action = async ({ request }: ActionArgs) => { if (data.intent === 'impersonate-user') { return !!data.newEffectiveUsername; } - if (data.intent === 'deactivate-user') { + if (data.intent === 'delete-user') { return !!data.username; } return false; @@ -104,7 +104,7 @@ export const action = async ({ request }: ActionArgs) => { 'Set-Cookie': await setEffectiveUsername(admin.username, newEffectiveUsername ?? ''), }, }); - case 'deactivate-user': + case 'delete-user': const { username } = actionParams.data; await deleteUser(username ?? '');