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 1 commit
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
20 changes: 13 additions & 7 deletions app/components/admin/users-table.tsx
Original file line number Diff line number Diff line change
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" reloadDocument>
<Tooltip label="Deactivate user">
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd use Delete vs. Deactivate everywhere, since we're really dropping the user from the db.

<IconButton
aria-label="Deactivate 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="deactivate-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);
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.

return here, so the Promise is returned"

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.

Instead of doing this, what if we add setIsReconciliationNeeded(true); inside deleteUserByUsername and just use it?

}
129 changes: 89 additions & 40 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 { 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' | 'deactivate-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', '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) => {
Expand All @@ -91,7 +127,9 @@ 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('');

Expand All @@ -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 (
<>
<Heading as="h1" size={{ base: 'lg', md: 'xl' }} mt={{ base: 6, md: 12 }}>
Expand Down Expand Up @@ -130,6 +178,7 @@ export default function AdminRoute() {
Users
</Heading>
<Form method="post" onChange={onFormChange}>
<input type="hidden" name="intent" value="search-users" />
<FormControl>
<InputGroup width={{ sm: '100%', md: 300 }}>
<InputLeftAddon children={<FaSearch />} />
Expand All @@ -143,7 +192,7 @@ export default function AdminRoute() {
<FormHelperText>Please enter at least 3 characters to search.</FormHelperText>
</FormControl>
</Form>
<UsersTable users={users ?? []} searchText={searchText} />
<UsersTable users={actionResult?.users ?? []} searchText={searchText} />
</>
);
}