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"
- />
-
+
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.
+
+
+
>
);
}