Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user deactivation functionality #607

Merged
merged 3 commits into from
Apr 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do a follow-up to audit all callers of setIsReconciliationNeeded(true) to see if we can move them into the model methods per @cychu42's comment, if you don't want to do it here.

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