Skip to content
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
5 changes: 5 additions & 0 deletions docs/auth/delete-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Delete User

You can delete a currently signed-in user using one of the following methods:

- [`useDeleteUser` hook](../hooks/useDeleteUser.md)
59 changes: 59 additions & 0 deletions docs/hooks/useDeleteUser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
tags:
- hook
---

# `useDeleteUser` hook

`useDeleteUser` hook is used to delete the currently signed-in user. A very simple example would be:

```typescript
const { dispatch } = useDeleteUser({ auth });
await dispatch();
```

!!! warning
`useDeleteUser` is lazy by default and will not do anything until you use `dispatch` function.

You can also get the state[^unauthorized] of deletion process.

```typescript
const { state, dispatch } = useDeleteUser({ auth });
await dispatch();
// `state` is "ready" | "loading" | "anonymous"
```

!!! warning
`useDeleteUser` automatically listens to authentication state and will be `"anonymous"` if the user is anonymous. In `"anonymous"` state, `dispatch` will simply do nothing even if it is invoked.

By default, `"anonymous"` state includes both real anonymous and Firebase-handled anonymous users[^anonymity]. If you'd like to enable deleting Firebase-handled anonymous users as well, you can use `includeFirebaseAnon` as such:

```typescript
// assuming user is Firebase-handled anon
const { dispatch } = useDeleteUser({ auth, includeFirebaseAnon: true });
await dispatch(); // this will delete anonymous user
```

## Input Parameters

Input parameters for `useDeleteUser` hook is as follows:

| Name | Type | Description | Required | Default Value |
|---|---|---|---|---|
| `auth` | [`firebase/auth/Auth`][AuthRefDoc] | Reference to the Firebase Auth service instance. | ✅ | - |
| `includeFirebaseAnon` | `boolean` | Enable deleting Firebase-handled anonymous users. | ❌ | `false` |

## Return Type

`useDeleteUser` hook returns an object with properties as below:

| Name | Type | Description |
|---|---|---|
| `state` | `"ready" | "loading" | "anonymous"`[^unauthorized] | The state of sign-up process. |
| `dispatch` | `() => Promise<void>` | A callback to start deletion process. |

[^unauthorized]: You can consider `"anonymous"` state as logically *unauthorized*. Your website visitors are not authorized to delete users if they are anonymous (signed-in).

[^anonymity]: See ["On Anonimity" section on `useSignOut` hook](useSignOut.md#on-anonymity) to learn more about how Firebase handles anonymity.

[AuthRefDoc]: https://firebase.google.com/docs/reference/node/firebase.auth.Auth
2 changes: 1 addition & 1 deletion docs/hooks/useSignOut.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ await dispatch();

## On Anonymity

In Firebase, there are two types of anonymity: Firebase-handle anonymous users (which are stored in Firebase and seen as real users) and real anonymous users (which are essentially `null` users).
In Firebase, there are two types of anonymity: Firebase-handled anonymous users (which are stored in Firebase and seen as real users) and real anonymous users (which are essentially `null` users).

`useSignOut` considers both cases as anonymous and behaves accordingly. So, in a case where user is *Firebase-handled* or *really* anonymous, `useSignOut` will have `"anonymous"` state. If, for a reason, this behavior is not desirable for you, you can use `onlyRealAnon` parameter on `useSignOut` hook. To see both cases, check this code:

Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ nav:
- auth/get-user.md
- auth/sign-up.md
- auth/sign-out.md
- auth/delete-user.md
- Hooks:
- Firestore:
- hooks/useDocument.md
Expand All @@ -61,6 +62,7 @@ nav:
- Auth:
- hooks/useUser.md
- hooks/useSignOut.md
- hooks/useDeleteUser.md
- Components:
- Firestore:
- components/FirestoreDocument.md
Expand Down
2 changes: 2 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ export * from "./useSignOut";
export * from "./SignOut";

export * from "./useSignUp";

export * from "./useDeleteUser";
113 changes: 113 additions & 0 deletions src/auth/useDeleteUser.hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2024 Eray Erdin
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

import { renderHook } from "@testing-library/react";
import { FirebaseError } from "firebase/app";
import {
UserCredential,
createUserWithEmailAndPassword,
deleteUser,
signInAnonymously,
signInWithEmailAndPassword,
signOut,
} from "firebase/auth";
import sleep from "sleep-sleep";
import { useDeleteUser } from ".";
import { auth } from "../firebase";

const generateEmail = (id: string) => `usedeleteuser_${id}@hook.com`;
const password = "111111" as const;

describe("when real anon, useDeleteUser hook", () => {
beforeEach(async () => {
await signOut(auth);
});

it("should have anonymous state", async () => {
const { result } = renderHook(() => useDeleteUser({ auth }));
const { state } = result.current;
expect(state).toBe("anonymous");
});
});

describe("when anon, useDeleteUser hook", () => {
let credential: UserCredential;

beforeEach(async () => {
credential = await signInAnonymously(auth);
});

afterEach(async () => {
await signOut(auth);
await deleteUser(credential.user);
});

it("should have anonymous state", async () => {
const { result } = renderHook(() => useDeleteUser({ auth }));
const { state } = result.current;
expect(state).toBe("anonymous");
});

it("should have ready state if includeFirebaseAnon", async () => {
const { result } = renderHook(() =>
useDeleteUser({ auth, includeFirebaseAnon: true }),
);
const { state } = result.current;
expect(state).toBe("ready");
});
});

describe("when authed, useDeleteUser hook", () => {
let credential: UserCredential;
let emailIndex: number = 0;

beforeEach(async () => {
const email = generateEmail(emailIndex.toString());

await createUserWithEmailAndPassword(auth, email, password);
credential = await signInWithEmailAndPassword(
auth,
generateEmail(emailIndex.toString()),
password,
);
emailIndex++;
});

afterEach(async () => {
await signOut(auth);
try {
await deleteUser(credential.user);
} catch (e) {
if (e instanceof FirebaseError && e.code == "auth/user-token-expired") {
return;
}
throw e;
}
});

it("should have ready state", async () => {
const { result } = renderHook(() => useDeleteUser({ auth }));
const { state } = result.current;
expect(state).toBe("ready");
});

it("should delete user", async () => {
const { result } = renderHook(() => useDeleteUser({ auth }));
const { dispatch } = result.current;
await dispatch();
await sleep(100);
const { state } = result.current;
expect(state).toBe("anonymous");
});

it("should have loading state while dispatching", async () => {
const { result } = renderHook(() => useDeleteUser({ auth }));
const { dispatch } = result.current;
dispatch();
await sleep(1);
const { state } = result.current;
expect(state).toBe("loading");
});
});
54 changes: 54 additions & 0 deletions src/auth/useDeleteUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2024 Eray Erdin
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

import { Auth, deleteUser, signOut } from "firebase/auth";
import { useEffect, useState } from "react";
import { useUser } from ".";

type UseDeleteUserParams = {
auth: Auth;
includeFirebaseAnon?: boolean;
};

type UseDeleteUserState = "ready" | "loading" | "anonymous";
type UseDeleteUserDispatcher = () => Promise<void>;
type UseDeleteUser = {
state: UseDeleteUserState;
dispatch: UseDeleteUserDispatcher;
};

export const useDeleteUser = ({
auth,
includeFirebaseAnon = false,
}: UseDeleteUserParams): UseDeleteUser => {
const user = useUser({ auth });
const [state, setState] = useState<UseDeleteUserState>("ready");

useEffect(() => {
setState(
user
? user.isAnonymous
? includeFirebaseAnon
? "ready"
: "anonymous"
: "ready"
: "anonymous",
);
}, [user, includeFirebaseAnon]);

const dispatch: UseDeleteUserDispatcher = async () => {
if (user) {
setState("loading");
await deleteUser(user);
await signOut(auth);
setState("anonymous");
}
};

return {
state,
dispatch: state === "ready" ? dispatch : async () => {},
};
};