Skip to content

Commit

Permalink
Merge pull request #1223 from Shelf-nu/1222-feat-request-to-delete-ac…
Browse files Browse the repository at this point in the history
…count

feat:  request to delete account
  • Loading branch information
DonKoko authored Aug 2, 2024
2 parents 7b67655 + 90556f0 commit e50347a
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 63 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,7 @@ GEOCODE_API_KEY="geocode-api-key"
SENTRY_ORG="sentry-org"
SENTRY_PROJECT="sentry-project"
SENTRY_DSN="sentry-dsn"
# CHROME_EXECUTABLE_PATH="/usr/bin/chromium"
# CHROME_EXECUTABLE_PATH="/usr/bin/chromium"

# Used for sending emails to admins for stuff like Request user delete. Optional. Defaults to [email protected]
ADMIN_EMAIL="[email protected]"
7 changes: 6 additions & 1 deletion app/components/layout/header/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export type Action = {
};

/** The button variant. Default is primary */
export type ButtonVariant = "primary" | "secondary" | "tertiary" | "link";
export type ButtonVariant =
| "primary"
| "secondary"
| "tertiary"
| "link"
| "danger";

/** Width of the button. Default is auto */
export type ButtonWidth = "auto" | "full";
4 changes: 4 additions & 0 deletions app/components/shared/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export const Button = React.forwardRef<HTMLElement, ButtonProps>(
link: tw(
`border-none p-0 text-text-sm font-semibold text-primary-700 hover:text-primary-800`
),
danger: tw(
`border-error-600 bg-error-600 text-white focus:ring-2`,
disabled ? "border-error-300 bg-error-300" : "hover:bg-error-800"
),
};

const sizes = {
Expand Down
134 changes: 79 additions & 55 deletions app/components/user/delete-user.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { User } from "@prisma/client";
import { useEffect, useState } from "react";
import { useActionData } from "@remix-run/react";
import { Button } from "~/components/shared/button";

import {
Expand All @@ -11,62 +12,85 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/shared/modal";
import { useDisabled } from "~/hooks/use-disabled";
import { useUserData } from "~/hooks/use-user-data";
import type { action } from "~/routes/_layout+/account-details.general";
import { Form } from "../custom-form";
import Input from "../forms/input";
import { TrashIcon } from "../icons/library";

export const DeleteUser = ({
user,
}: {
user: {
id: User["id"];
firstName: User["firstName"];
lastName: User["lastName"];
};
}) => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
data-test-id="deleteUserButton"
className="justify-start px-6 py-3 text-sm font-semibold text-gray-700 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-slate-100 hover:text-gray-700"
width="full"
>
Delete
</Button>
</AlertDialogTrigger>
export const DeleteUser = () => {
const disabled = useDisabled();
const user = useUserData();
const actionData = useActionData<typeof action>();
const [open, setOpen] = useState(false);

<AlertDialogContent>
<AlertDialogHeader>
<div className="mx-auto md:m-0">
<span className="flex size-12 items-center justify-center rounded-full bg-error-50 p-2 text-error-600">
<TrashIcon />
</span>
</div>
<AlertDialogTitle>
Delete {user.firstName} {user.lastName}
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this user? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<div className="flex justify-center gap-2">
<AlertDialogCancel asChild>
<Button variant="secondary">Cancel</Button>
</AlertDialogCancel>
useEffect(() => {
if (actionData && !actionData?.error && actionData.success) {
setOpen(false);
}
}, [actionData]);

<Form method="delete">
<Button
className="border-error-600 bg-error-600 hover:border-error-800 hover:bg-error-800"
type="submit"
data-test-id="confirmdeleteUserButton"
>
Delete
</Button>
</Form>
</div>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button
data-test-id="deleteUserButton"
variant="danger"
className="mt-3"
>
Send delete request
</Button>
</AlertDialogTrigger>

<AlertDialogContent>
<Form method="delete" className="">
<AlertDialogHeader>
<div className="mx-auto md:m-0">
<span className="flex size-12 items-center justify-center rounded-full bg-error-50 p-2 text-error-600">
<TrashIcon />
</span>
</div>
<AlertDialogTitle>
Are you sure you want to delete your account?
</AlertDialogTitle>
<AlertDialogDescription>
In order to delete your account you need to send a request that
will be fulfilled within the next 72 hours. Account deletion is
final and cannot be undone.
</AlertDialogDescription>

<Input
inputType="textarea"
name="reason"
label="Reason for deleting your account"
required
/>
</AlertDialogHeader>
<AlertDialogFooter className="mt-3">
<div className="flex justify-center gap-2">
<AlertDialogCancel asChild>
<Button variant="secondary" disabled={disabled} type="button">
Cancel
</Button>
</AlertDialogCancel>

<input type="hidden" name="email" value={user?.email} />

<Button
className="border-error-600 bg-error-600 hover:border-error-800 hover:bg-error-800"
type="submit"
data-test-id="confirmdeleteUserButton"
disabled={disabled}
name="intent"
value="deleteUser"
>
Confirm
</Button>
</div>
</AlertDialogFooter>
</Form>
</AlertDialogContent>
</AlertDialog>
);
};
13 changes: 13 additions & 0 deletions app/hooks/use-disabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useNavigation, type Fetcher } from "@remix-run/react";
import { isFormProcessing } from "~/utils/form";

/**
* Used to know if a button should be disabled on navigation.
* By default it works with navigation state
* Optionally it can receive a fetcher to use as state
*/
export function useDisabled(fetcher?: Fetcher) {
const navigation = useNavigation();
const state = fetcher ? fetcher.state : navigation.state;
return isFormProcessing(state);
}
45 changes: 43 additions & 2 deletions app/routes/_layout+/account-details.general.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { User } from "@prisma/client";
import type { ActionFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";

Expand All @@ -10,6 +11,7 @@ import { Form } from "~/components/custom-form";
import FormRow from "~/components/forms/form-row";
import Input from "~/components/forms/input";
import { Button } from "~/components/shared/button";
import { DeleteUser } from "~/components/user/delete-user";
import PasswordResetForm from "~/components/user/password-reset-form";
import ProfilePicture from "~/components/user/profile-picture";

Expand All @@ -24,10 +26,12 @@ import { appendToMetaTitle } from "~/utils/append-to-meta-title";
import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch";
import { delay } from "~/utils/delay";
import { sendNotification } from "~/utils/emitter/send-notification.server";
import { ADMIN_EMAIL } from "~/utils/env";
import { makeShelfError } from "~/utils/error";
import { isFormProcessing } from "~/utils/form";
import { getValidationErrors } from "~/utils/http";
import { data, error, parseData } from "~/utils/http.server";
import { sendEmail } from "~/utils/mail.server";
import { zodFieldIsRequired } from "~/utils/zod";

export const UpdateFormSchema = z.object({
Expand All @@ -44,8 +48,9 @@ export const UpdateFormSchema = z.object({

const Actions = z.discriminatedUnion("intent", [
z.object({
intent: z.literal("resetPassword"),
intent: z.enum(["resetPassword", "deleteUser"]),
email: z.string(),
reason: z.string(),
}),
UpdateFormSchema.extend({
intent: z.literal("updateUser"),
Expand Down Expand Up @@ -102,6 +107,34 @@ export async function action({ context, request }: ActionFunctionArgs) {

return json(data({ success: true }));
}
case "deleteUser": {
let reason = "No reason provided";
if ("reason" in payload && payload.reason) {
reason = payload?.reason;
}

void sendEmail({
to: ADMIN_EMAIL || "[email protected]",
subject: "Delete account request",
text: `User with id ${userId} and email ${payload.email} has requested to delete their account. \n\n Reason: ${reason}\n\n`,
});

void sendEmail({
to: payload.email,
subject: "Delete account request received",
text: `We have received your request to delete your account. It will be processed within 72 hours.\n\n Kind regards,\nthe Shelf team \n\n`,
});

sendNotification({
title: "Account deletion request",
message:
"Your request has been sent to the admin and will be processed within 24 hours. You will receive an email confirmation.",
icon: { name: "success", variant: "success" },
senderId: authSession.userId,
});

return json(data({ success: true }));
}
default: {
checkExhaustiveSwitch(intent);
return json(data(null));
Expand Down Expand Up @@ -132,7 +165,7 @@ export default function UserPage() {
const transition = useNavigation();
const disabled = isFormProcessing(transition.state);
const data = useActionData<typeof action>();
const user = useUserData();
const user = useUserData() as unknown as User;
const usernameError =
getValidationErrors<typeof UpdateFormSchema>(data?.error)?.username
?.message || zo.errors.username()?.message;
Expand Down Expand Up @@ -268,6 +301,14 @@ export default function UserPage() {
<p className="text-sm text-gray-600">Update your password here</p>
</div>
<PasswordResetForm userEmail={user?.email || ""} />

<div className="my-6">
<h3 className="text-text-lg font-semibold">Delete account</h3>
<p className="text-sm text-gray-600">
Send a request to delete your account.
</p>
<DeleteUser />
</div>
</div>
);
}
4 changes: 0 additions & 4 deletions app/routes/_layout+/admin-dashboard+/$userId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { Button } from "~/components/shared/button";
import { DateS } from "~/components/shared/date";
import { Spinner } from "~/components/shared/spinner";
import { Table, Td, Tr } from "~/components/table";
import { DeleteUser } from "~/components/user/delete-user";
import { db } from "~/database/db.server";
import { updateUserTierId } from "~/modules/tier/service.server";
import { deleteUser, getUserByID } from "~/modules/user/service.server";
Expand Down Expand Up @@ -207,9 +206,6 @@ export default function Area51UserPage() {
<div>
<div className="flex justify-between">
<h1>User: {user?.email}</h1>
<div className="flex gap-3">
<DeleteUser user={user} />
</div>
</div>
<div className="flex gap-2">
<div className="w-[400px]">
Expand Down
44 changes: 44 additions & 0 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,47 @@ const QRScannerComponent = () => {
);
};
```

### `useDisabled`

The `useDisabled` hook is used to determine if a button should be disabled during navigation. By default, it operates with the navigation state, but it can optionally accept a fetcher to use as the state.

**Usage:**

```typescript
/** Without fetcher, using default navigation */
const isDisabled = useDisabled();

/** Without fetcher */
const isDisabled = useDisabled(fetcher);
```

**Parameters:**

- `fetcher` (optional): An object that contains the state to be used. If not provided, the navigation state will be used.

**Returns:**

- `boolean`: Returns `true` if the form is processing and the button should be disabled, otherwise `false`.

**Example:**

```typescript
import { useDisabled } from './path/to/hooks';

const MyComponent = () => {
const fetcher = useFetcher();
const isDisabled = useDisabled(fetcher);

return (
<button disabled={isDisabled}>
Submit
</button>
);
};
```

**Dependencies:**

- `useNavigation`: A hook that provides the current navigation state.
- `isFormProcessing`: A function that checks if the form is currently processing based on the state.

0 comments on commit e50347a

Please sign in to comment.