Skip to content

Commit

Permalink
Add user deactivation functionality (#607)
Browse files Browse the repository at this point in the history
* Add user deactivation functionality

* Update users search triggering logic

* Updates after feedback
  • Loading branch information
Myrfion authored Apr 13, 2023
1 parent 4ffaa6d commit d8e1d3d
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 66 deletions.
22 changes: 14 additions & 8 deletions app/components/admin/users-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -85,6 +85,7 @@ export default function UsersTable({ users, searchText }: UsersTableProps) {
name="newEffectiveUsername"
value={user.username}
/>
<input type="hidden" name="intent" value="impersonate-user" />
<IconButton
type="submit"
aria-label="Impersonate user"
Expand All @@ -93,13 +94,18 @@ export default function UsersTable({ users, searchText }: UsersTableProps) {
/>
</Form>
</Tooltip>
<Tooltip label="Deactivate user">
<IconButton
aria-label="Deactivate user"
icon={<DeleteIcon color="black" boxSize={5} />}
variant="ghost"
/>
</Tooltip>
<Form method="post">
<Tooltip label="Delete user">
<IconButton
aria-label="Delete user"
icon={<DeleteIcon color="black" boxSize={5} />}
variant="ghost"
type="submit"
/>
</Tooltip>
<input type="hidden" name="username" value={user.username} />
<input type="hidden" name="intent" value="delete-user" />
</Form>
</HStack>
</Td>
</Tr>
Expand Down
8 changes: 8 additions & 0 deletions app/lib/user.server.ts
Original file line number Diff line number Diff line change
@@ -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);
}
174 changes: 116 additions & 58 deletions app/routes/__index/admin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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) => {
Expand All @@ -91,15 +127,37 @@ export default function AdminRoute() {
const submit = useSubmit();

const { userCount, dnsRecordCount, certificateCount } = useTypedLoaderData<typeof loader>();
const users = useTypedActionData<UserWithMetrics[] | null>();
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 (
<>
Expand Down Expand Up @@ -129,21 +187,21 @@ export default function AdminRoute() {
<Heading as="h2" size={{ base: 'lg', md: 'xl' }} mt="8" mb="4">
Users
</Heading>
<Form method="post" onChange={onFormChange}>
<FormControl>
<InputGroup width={{ sm: '100%', md: 300 }}>
<InputLeftAddon children={<FaSearch />} />
<Input
placeholder="Search..."
name="searchText"
value={searchText}
onChange={(event) => setSearchText(event.currentTarget.value)}
/>
</InputGroup>
<FormHelperText>Please enter at least 3 characters to search.</FormHelperText>
</FormControl>
</Form>
<UsersTable users={users ?? []} searchText={searchText} />

<FormControl>
<InputGroup width={{ sm: '100%', md: 300 }}>
<InputLeftAddon children={<FaSearch />} />
<Input
placeholder="Search..."
name="searchText"
value={searchText}
onChange={(event) => setSearchText(event.currentTarget.value)}
/>
</InputGroup>
<FormHelperText>Please enter at least 3 characters to search.</FormHelperText>
</FormControl>

<UsersTable users={actionResult?.users ?? []} searchText={searchText} />
</>
);
}

0 comments on commit d8e1d3d

Please sign in to comment.