Task 13: Register and Login on Frontend

In the previous task, we manually stored the user's JWT in local storage. In this task, we will add a login component to the frontend and integrate it with the backend. This will enable users to sign in and automatically have their JWT stored in the app. Additionally, we will implement user registration through the frontend and integrate it with the backend.

Step 1: Prepare the Frontend for Login

First, let’s add a new function, login, to the app/src/lib/api.ts file:

// Login, store the token, and return the user
export const login = async (
  username: string,
  password: string,
): Promise<User> => {
  const API_URL = import.meta.env.VITE_API_URL;
  const response = await fetch(`${API_URL}/users/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ username, password }),
  });

  const responseJson = await response.json();

  if (!response.ok) {
    throw new Error(
      `Error: ${response.status} - ${
        responseJson.message || response.statusText
      }`,
    );
  }

  const { access_token } = responseJson.data;

  if (!access_token) {
    throw new Error("Authentication token is missing from the response!");
  }

  storeAuthenticatedUserToken(access_token);
  const user = getAuthenticatedUser();
  return user;
};

While at it, let’s clean the api.ts file:

  • Delete the findUser function; it is not used anymore!

    // Helper function to find a user by ID
    export const findUser = async (id: string): Promise<User | undefined> => {
      return new Promise((resolve) => {
        setTimeout(() => {
          const user = db.users.find((user) => user.id === id);
          resolve(user);
        }, 200); // Simulate an API delay
      });
    };
    
  • Delete the mock db:

    // Mock database
    const db = {
      users: [...users],
      posts: [...posts],
    };
    
  • Delete this unused import statement:

    import { users, posts } from "@/lib/data";
    
  • Every API function uses the API_URL variable. Declare it at the top of the file and remove its declarations in each function!

    const API_URL = import.meta.env.VITE_API_URL;
    

Notice the login function calls the storeAuthenticatedUserToken function. Define this function in the app/src/lib/auth.ts file:

// Store the token in local storage
export const storeAuthenticatedUserToken = (token: string): void => {
  localStorage.setItem("token", token);
};

Next, update the store (app/src/lib/store.ts) to hold user information:

- import { PostWithUserData } from "./types";
+ import { PostWithUserData, User } from "./types";
  import { create } from "zustand";
  import { immer } from "zustand/middleware/immer";
	
  type State = {
    posts: PostWithUserData[];
+   user: User | null;
  };

  type Action = {
    setPosts: (posts: PostWithUserData[]) => void;
    removePost: (id: string) => void;
    addPost: (post: PostWithUserData) => void;
+   setUser: (user: User) => void;
+   clearUser: () => void;
  };
	
  // define the initial state
  const initialState: State = {
    posts: [],
+   user: null,
  };
	
  export const useStore = create<State & Action>()(
    immer((set, get) => ({
      ...initialState,
	
      setPosts: (posts) => set({ posts }),
	
      removePost: (id) => {
        const newPosts = get().posts.filter((post) => post.id !== id);
        set({ posts: newPosts });
      },
	
      addPost: (post) => {
        set({ posts: [post, ...get().posts] });
      },

+     setUser: (user) => set({ user }),

+     clearUser: () => set({ user: null }),
    })),
  );

Let’s add a custom query to manage user related state and actions. Create a file use-mutations-users.tsx in app/src/hooks folder with this content:

import { login } from "@/lib/api";
import { useStore } from "@/lib/store";
import { useToast } from "@/components/ui/use-toast";
import { useEffect } from "react";
import { getAuthenticatedUser } from "@/lib/auth";

function useMutationUser() {
  const { toast } = useToast();
  const setUser = useStore((state) => state.setUser);
	const clearUser = useStore((state) => state.clearUser);

  const loginUser = async (username: string, password: string) => {
    try {
      const user = await login(username, password);
      setUser(user);
    } catch (error) {
      toast({
        variant: "destructive",
        title: "Failed to login",
        description:
          (error as Error).message ||
          "There was an error signing you in. Please try again later.",
      });
    }
  };

	useEffect(() => {
    try {
      const user = getAuthenticatedUser();
      setUser(user);
    } catch (error) {
      clearUser();
    }
  }, []);

  return { loginUser };
}

export default useMutationUser;

This custom hook, useMutationUser, is designed to manage user authentication-related state changes and handle login operations within our application. It encapsulates the logic for logging in a user and updating the global state accordingly.

Pay attention to the useEffect hook that runs on component mount to check if the user is already authenticated (perhaps from a previous session):

  • It attempts to retrieve the authenticated user's data using getAuthenticatedUser from @/lib/auth.
  • If successful, it updates the global state with the user's data. If it fails (e.g., if the token is invalid or expired), it clears the user data from the state.

This hook can be used in any component that requires user login functionality, such as a login form.

Please commit your changes to version control:

git add .
git commit -m "Prepare the Frontend for Login"

Step 2: Add Login Dialog to the Frontend

Add a new file app/src/components/login-dialog.tsx with the following content:

import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import useMutationUser from "@/hooks/use-mutations-users";

export const LoginDialog = () => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const { toast } = useToast();
  const { loginUser } = useMutationUser();

  const clearFields = () => {
    setUsername("");
    setPassword("");
  };

  const handleLogin = async () => {
    if (!username || !password) {
      toast({
        variant: "destructive",
        title: "Sorry! Username and password cannot be empty!",
        description: `Please enter your credentials to login.`,
      });
      return;
    }

    loginUser(username, password);

    clearFields();
  };

  const handleCancel = () => {
    clearFields();
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button aria-label={"Click to login"} variant="default">
          Login
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Login</DialogTitle>
          <DialogDescription>Provide your credentials here.</DialogDescription>
        </DialogHeader>
        <div className="grid gap-2 py-4">
          <div className="grid items-center grid-cols-4 gap-4">
            <Label htmlFor="username" className="text-right">
              Username
            </Label>
            <Input
              id="username"
              value={username}
              className="col-span-3"
              onChange={(e) => {
                setUsername(e.target.value);
              }}
            />
          </div>
          <div className="grid items-center grid-cols-4 gap-4">
            <Label htmlFor="password" className="text-right">
              Password
            </Label>
            <Input
              id="password"
              value={password}
              className="col-span-3"
              type="password"
              onChange={(e) => {
                setPassword(e.target.value);
              }}
            />
          </div>
        </div>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant={"secondary"} type="reset" onClick={handleCancel}>
              Cancel
            </Button>
          </DialogClose>
          <DialogClose asChild>
            <Button type="submit" onClick={handleLogin}>
              Login
            </Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

Next update the app/src/App.tsx to include a login dialog component:

  import Sidebar from "./components/sidebar";
  import Feed from "./components/feed";
  import { Toaster } from "./components/ui/toaster";
+ import { LoginDialog } from "./components/login-dialog";

  function App() {
    return (
      <div className="flex justify-center min-h-screen">
        <Sidebar />
        <Feed />
+       <div className="flex flex-col gap-2 p-4">
+         <LoginDialog />
+       </div>
        <Toaster />
      </div>
    );
  }

  export default App;

Remove the token stored in the browser’s local storage. Then, try to login through the frontend app:

Please commit your changes to version control:

git add .
git commit -m "Add Login Dialog to Frontend"

Step 3: Add Logout Dialog to the Frontend

Let’s make note that we do not have an API endpoint for log out. It is not necessary when using JWT based authentication. Typically, when using JWT authentication, the client side stores the token somewhere and attaches it to every request that requires authentication. So, the first step in logging out is to simply delete the token you stored on the client side (e.g. browser local storage). In this case, the client will no longer have a token to include in the request, resulting in an unauthorized response status.

For other types of authentication, such as session-based authentication, it is common to have an API endpoint for logout. Even with JWT based authentication, it is not uncommon to have a logout endpoint. Having an API endpoint for logout can be beneficial for several reasons:

  1. Security: When a user logs out, the API endpoint can perform necessary actions to invalidate the user's session and revoke their authentication token. This helps to ensure that even if an unauthorized person gains access to the token, they cannot continue to access protected resources.
  2. Clearing session data: The logout API endpoint can be used to clear any session-related data stored on the server-side. This can include removing session cookies, clearing session variables, or any other necessary cleanup actions.
  3. Logging: The logout endpoint can also be used to log user logout events, providing a record of when and how users are logging out of the system. This can be useful for auditing purposes or identifying any unusual logout patterns.
  4. Additional actions: The logout API endpoint can be extended to perform other actions that may be required during the logout process, such as clearing cached data, updating user status, or sending notifications.

We are not going to do any of these! However, we will make a placeholder API function on the frontend so we could be able to easily incorporate a logout mechanism that involves backend and the frontend.

On that point, add this logout function to app/src/lib/api.ts:

// Logout and clear the token
export const logout = async (): Promise<void> => {
  // You can send a request to the server to perform server-side logout
  // Here we just clear the token
  removeAuthenticatedUserToken();
};

The logout function calls removeAuthenticatedUserToken. Declare that function in app/src/lib/auth.ts as follows:

// Remove the token from local storage
export const removeAuthenticatedUserToken = (): void => {
  localStorage.removeItem("token");
};

Next, update the app/src/hooks/use-mutations-users.tsx file as follows:

  • Update the import statement:

    - import { login } from "@/lib/api";
    + import { login, logout } from "@/lib/api";
    
  • Add the logoutUser function:

    const logoutUser = async () => {
      try {
        await logout();
        clearUser();
      } catch (error) {
        toast({
          variant: "destructive",
          title: "Failed to logout",
          description:
            (error as Error).message ||
            "There was an error signing you out. Please try again later.",
        });
      }
    };
    
  • Update the return statement:

    - return { loginUser };
    + return { loginUser, logoutUser };
    

Next, we will create a logout dialog to confirm the user's intention to log out. Create a file named logout-dialog.tsx in app/src/components folder with the following content:

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import useMutationUser from "@/hooks/use-mutations-users";

export const LogoutDialog = () => {
  const { logoutUser } = useMutationUser();

  const handleLogout = async () => {
    logoutUser();
  };

  const handleCancel = () => {
    // do nothing!
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button aria-label={"Click to login"} variant="destructive">
          Logout
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Logout</DialogTitle>
          <DialogDescription>
            Are you sure your want to logout?
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant={"secondary"} type="reset" onClick={handleCancel}>
              Cancel
            </Button>
          </DialogClose>
          <DialogClose asChild>
            <Button type="submit" onClick={handleLogout}>
              Logout
            </Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

Finally, update app/src/App.tsx as follows:

import Sidebar from "./components/sidebar";
import Feed from "./components/feed";
import { Toaster } from "./components/ui/toaster";
import { LoginDialog } from "./components/login-dialog";
import { useStore } from "./lib/store";
import { LogoutDialog } from "./components/logout-dialog";

function App() {
  const user = useStore((state) => state.user);

  return (
    <div className="flex justify-center min-h-screen">
      <Sidebar />
      <Feed />
      <div className="flex flex-col gap-2 p-4">
        {user ? <LogoutDialog /> : <LoginDialog />}
      </div>
      <Toaster />
    </div>
  );
}

export default App;

Notice that we are conditionally switching between showing the login or logout button based on whether there is a user object in the store.

Try the app! Log in and out, and make sure everything works!

Please commit your changes to version control:

git add .
git commit -m "Add Logout Dialog to the Frontend"

Step 4: Require Users to Login To Make Post

Currently, if a user is not signed in, they can attempt to create a post only to see an error message stating that the backend has rejected the request with a 401 Unauthorized error. It would improve the user experience if we inform the user in advance that they need to log in before making a post. Let’s make that happen!

Update the app/src/components/add-post-dialog.tsx file as follows:

import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "./ui/textarea";
import { PlusCircledIcon } from "@radix-ui/react-icons";
import useMutationPosts from "@/hooks/use-mutations-posts";
import { useToast } from "@/components/ui/use-toast";
import { useStore } from "@/lib/store";

export const AddPostDialog = () => {
  const [content, setContent] = useState("");
  const { addNewPost } = useMutationPosts();
  const { toast } = useToast();
  const user = useStore((state) => state.user);

  const handleSave = async () => {
    if (!content) {
      toast({
        variant: "destructive",
        title: "Sorry! Content cannot be empty! 🙁",
        description: `Please enter the content for your post.`,
      });
      return;
    }
    await addNewPost(content);
    setContent("");
  };

  const handleCancel = () => {
    setContent("");
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button aria-label={"Make a Post"} variant="default" size="sm">
          <PlusCircledIcon className="w-5 h-5" />
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[525px]">
        <DialogHeader>
          <DialogTitle>Add Post</DialogTitle>
          <DialogDescription>
            {user
              ? "Provide the content of your post here."
              : "Please login to make a post."}
          </DialogDescription>
        </DialogHeader>
        {user && (
          <div className="grid gap-4 py-4">
            <div className="grid items-center grid-cols-4 gap-4">
              <Textarea
                id="content"
                value={content}
                className="col-span-4"
                onChange={(e) => {
                  setContent(e.target.value);
                }}
              />
            </div>
          </div>
        )}
        <DialogFooter>
          {!user && (
            <DialogClose asChild>
              <Button>Okay</Button>
            </DialogClose>
          )}
          {user && (
            <DialogClose asChild>
              <Button variant={"secondary"} type="reset" onClick={handleCancel}>
                Cancel
              </Button>
            </DialogClose>
          )}
          {user && (
            <DialogClose asChild>
              <Button type="submit" onClick={handleSave}>
                Save
              </Button>
            </DialogClose>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

Let's review the updates made to the AddPostDialog component. In a nutshell, we have introduced user authentication checks and modified the UI behavior based on the user's login status. Here's a breakdown of the changes:

  1. State Hook for User Data: Added a new state hook to retrieve the current user's data using Zustand's useStore.

    const user = useStore((state) => state.user);
    
  2. Conditional Rendering Based on User Authentication: The dialog's description now changes based on whether the user is logged in or not. If the user is logged in, it prompts them to provide post content. If not, it informs them to log in to make a post.

    <DialogDescription>
      {user ? "Provide the content of your post here." : "Please login to make a post."}
    </DialogDescription>
    
  3. Conditional UI Elements: The input area for the post content (Textarea) is now conditionally rendered. It only appears if the user is logged in.

    {user && (
      <div className="grid gap-4 py-4">
        {/* Textarea and other elements */}
      </div>
    )}
    
  4. Modifying DialogFooter Based on User Status: The DialogFooter now conditionally renders its child components based on the user's login status. If the user is not logged in, only an "Okay" button is shown. If the user is logged in, it shows "Cancel" and "Save" buttons.

    <DialogFooter>
      {!user && <DialogClose asChild><Button>Okay</Button></DialogClose>}
      {user && (
        {/* "Cancel" and "Save" buttons */}
      )}
    </DialogFooter>
    

These changes effectively make the AddPostDialog component responsive to the user's authentication status, enhancing user experience by guiding unauthenticated users to log in and providing a seamless post creation process for authenticated users. This implementation reflects a thoughtful design approach that accounts for different user states within the application.

Try the app in the browser and make sure it works as expected!

Please commit your changes to version control:

git add .
git commit -m "Require Users to Login To Make Post"

Step 5: Require Users to Login To Delete Post

To enhance the user experience, we can improve the display of the delete post option. It should only be visible when the user is signed in and, furthermore, only on their own posts.

Update the app/src/components/post.tsx file to pass the username to PostFooter:

- <PostFooter postId={id} />
+ <PostFooter postId={id} username={username} />

Now update the app/src/components/post-footer.tsx file:

  • Update the props

    - const PostFooter = ({ postId }: { postId: string }) =>
    + const PostFooter = ({
    +   postId,
    +   username,
    + }: {
    +   postId: string;
    +   username?: string;
    + }) => {
    
  • Pass username to PostAction:

    - <PostActions postId={postId} />
    + <PostActions postId={postId} username={username} />
    

Now update the app/src/components/post-actions.tsx file:

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import useMutationPosts from "@/hooks/use-mutations-posts";
import { useStore } from "@/lib/store";
import { useEffect, useState } from "react";

const PostActions = ({
  postId,
  username,
}: {
  postId: string;
  username?: string;
}) => {
  const { deletePostById } = useMutationPosts();
  const { user } = useStore((state) => state);
  const [isOwner, setIsOwner] = useState(false);

  useEffect(() => {
    if (user && user.username === username) {
      setIsOwner(true);
    } else {
      setIsOwner(false);
    }
  }, [user, username]);

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="sm">
          <DotsHorizontalIcon className="w-5 h-5" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        {isOwner && <DropdownMenuItem>Edit post</DropdownMenuItem>}
        {isOwner && (
          <DropdownMenuItem onClick={() => deletePostById(postId)}>
            Delete post
          </DropdownMenuItem>
        )}
        <DropdownMenuItem>Copy link to post</DropdownMenuItem>
        <DropdownMenuItem className="text-red-500">
          Report post
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};

export default PostActions;

The updates made to the PostActions component introduce functionality to conditionally render certain menu items based on the user's ownership of the post. Here's a breakdown of the changes:

  1. Additional Prop: A new prop username is added to the component. This prop represents the username of the user who created the post.

    username?: string;
    
  2. State Hook for Ownership Check: A new state variable isOwner is introduced, initialized with useState(false). This variable indicates whether the current user is the owner of the post.

    const [isOwner, setIsOwner] = useState(false);
    
  3. Effect Hook for Ownership Determination: An useEffect hook is added to determine if the currently authenticated user (user) is the same as the user who created the post (username). It sets isOwner to true if the usernames match, indicating that the current user is the owner of the post.

    useEffect(() => {
      setIsOwner(user && user.username === username);
    }, [user, username]);
    
  4. Conditional Rendering of Dropdown Menu Items: The "Edit post" and "Delete post" menu items are now conditionally rendered based on the isOwner state. They only appear if the isOwner state is true, meaning these options are only available to the user who created the post.

    {isOwner && <DropdownMenuItem>Edit post</DropdownMenuItem>}
    {isOwner && (
      <DropdownMenuItem onClick={() => deletePostById(postId)}>
        Delete post
      </DropdownMenuItem>
    )}
    

By checking whether the current user is the post's owner, the component smartly decides which actions should be available to the user. This not only prevents unauthorized users from editing or deleting posts they do not own but also makes the UI more intuitive and user-friendly by showing only relevant actions.

The use of useEffect to set the isOwner state ensures that the ownership check is performed every time there's a change in the user's authentication state or when the component receives a different post. This dynamic check is crucial for ensuring the component's actions remain accurate and relevant to the current context, especially in applications where the user state might change without the component being remounted (e.g., due to user login/logout actions).

Try the app in the browser to ensure it functions as expected. It is recommended to have a few posts from a different user in addition to those from the authenticated user.

Please commit your changes to version control:

git add .
git commit -m "Require Users to Login To Delete Post"

Step 6: Add Register Dialog to the Frontend

Let’s have users sign up to use out app! Add a new function register to the app/src/lib/api.ts file:

// Register a new user
export const register = async (
  username: string,
  password: string,
  displayName: string,
  avatar?: string,
): Promise<void> => {
  const response = await fetch(`${API_URL}/users/register`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ username, password, displayName, avatar }),
  });
  const responseJson = await response.json();

  if (!response.ok) {
    throw new Error(
      `Error: ${response.status} - ${
        responseJson.message || response.statusText
      }`,
    );
  }
};

Let’s update the app/src/hooks/use-mutations-users.tsx file:

  • Update the import statement:

    - import { login, logout } from "@/lib/api";
    + import { login, logout, register } from "@/lib/api";
    
  • Add a new function registerUser:

    const registerUser = async (
        username: string,
        password: string,
        displayName: string,
        avatar?: string,
      ) => {
        try {
          await register(username, password, displayName, avatar);
          toast({
            variant: "default",
            title: "Registration successful",
            description: "Please login with your credentials.",
          });
        } catch (error) {
          toast({
            variant: "destructive",
            title: "Failed to register",
            description:
              (error as Error).message ||
              "There was an error registering you. Please try again later.",
          });
        }
      };
    
  • Update the return statement:

    - return { loginUser, logoutUser };
    + return { loginUser, logoutUser, registerUser };
    

Add a new file app/src/components/register-dialog.tsx with the following content:

import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import useMutationUser from "@/hooks/use-mutations-users";

export const RegisterDialog = () => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [displayName, setDisplayName] = useState("");
  const [avatarUrl, setAvatarUrl] = useState("");
  const { toast } = useToast();
  const { registerUser } = useMutationUser();

  const clearFields = () => {
    setUsername("");
    setPassword("");
    setDisplayName("");
    setAvatarUrl("");
  };

  const handleSave = async () => {
    if (!username || !password || !displayName) {
      toast({
        variant: "destructive",
        title: "Sorry! Username, password, or display name cannot be empty! 🙁",
        description: `Please enter the required information to register.`,
      });
      return;
    }

    registerUser(username, password, displayName, avatarUrl);

    clearFields();
  };

  const handleCancel = () => {
    clearFields();
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button aria-label={"Click to login"} variant="outline">
          Register
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[625px]">
        <DialogHeader>
          <DialogTitle>Register</DialogTitle>
          <DialogDescription>
            Please complete this form to register.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-2 py-4">
          <div className="grid items-center grid-cols-4 gap-4">
            <Label htmlFor="username" className="text-right">
              Username
            </Label>
            <Input
              id="username"
              value={username}
              className="col-span-3"
              onChange={(e) => {
                setUsername(e.target.value);
              }}
            />
          </div>
          <div className="grid items-center grid-cols-4 gap-4">
            <Label htmlFor="password" className="text-right">
              Password
            </Label>
            <Input
              id="password"
              type="password"
              value={password}
              className="col-span-3"
              onChange={(e) => {
                setPassword(e.target.value);
              }}
            />
          </div>
          <div className="grid items-center grid-cols-4 gap-4">
            <Label htmlFor="displayName" className="text-right">
              Display Name
            </Label>
            <Input
              id="displayName"
              value={displayName}
              className="col-span-3"
              onChange={(e) => {
                setDisplayName(e.target.value);
              }}
            />
          </div>
          <div className="grid items-center grid-cols-4 gap-4">
            <Label htmlFor="avatarUrl" className="text-right">
              Avatar URL
            </Label>
            <Input
              id="avatarUrl"
              value={avatarUrl}
              className="col-span-3"
              onChange={(e) => {
                setAvatarUrl(e.target.value);
              }}
            />
          </div>
        </div>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant={"secondary"} type="reset" onClick={handleCancel}>
              Cancel
            </Button>
          </DialogClose>
          <DialogClose asChild>
            <Button type="submit" onClick={handleSave}>
              Save
            </Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

Then, update the app/src/App.tsx as follows:

  import Sidebar from "./components/sidebar";
  import Feed from "./components/feed";
  import { Toaster } from "./components/ui/toaster";
  import { LoginDialog } from "./components/login-dialog";
  import { useStore } from "./lib/store";
  import { LogoutDialog } from "./components/logout-dialog";
+ import { RegisterDialog } from "./components/register-dialog";

  function App() {
    const user = useStore((state) => state.user);

    return (
      <div className="flex justify-center min-h-screen">
        <Sidebar />
        <Feed />
        <div className="flex flex-col gap-2 p-4">
          {user ? <LogoutDialog /> : <LoginDialog />}
+         {!user && <RegisterDialog />}
        </div>
        <Toaster />
      </div>
    );
  }

  export default App;

Try the app in the browser and make sure the user registration works:

Try to login with the credential of the registered user and make a post on their behalf:

Please commit your changes to version control:

git add .
git commit -m "Add Register Dialog to the Frontend"

Step 7: Deal with Expired Token

Suppose you log in and leave the Posts app open until your JWT expires. With the current implementation, the app will still show you as logged in. However, if you try to perform an action like making a post, you will receive a 401 Unauthorized Error. While this behavior may be desired in certain scenarios, it is generally considered a better user experience to inform the user when their token or session has expired and automatically log them out, requiring them to sign in again.

In this step, we will discuss a simple strategy to handle this scenario. It is important to note that authentication is a complex process, and in real-world scenarios, you may want to implement more sophisticated strategies such as relying on the backend for token verification or using a refresh token to swap an expired token for a new one.

Let’s start by adding isTokenExpired function to the app/src/lib/auth.ts file:

// Function to check if the token is expired
export const isTokenExpired = (token: string): boolean => {
  try {
    const decodedToken: { exp: number } = jwtDecode(token);
    const currentTimestamp = Date.now() / 1000; // current time in seconds
    return decodedToken.exp < currentTimestamp;
  } catch (error) {
    // If there's an error in decoding, assume the token is invalid/expired
    return true;
  }
};

The isTokenExpired function is used to check if a JWT stored in the browser's local storage has expired:

  1. It decodes the JWT to extract the expiration timestamp (exp).
  2. It compares the expiration timestamp with the current timestamp. If the expiration timestamp is earlier than the current timestamp, it means the token has expired.
  3. If there is any error in decoding the token or if the expiration timestamp is earlier than the current timestamp, the function assumes the token is invalid/expired and returns true. Otherwise, it returns false.

Next, update the app/src/App.tsx file:

import Sidebar from "./components/sidebar";
import Feed from "./components/feed";
import { Toaster } from "./components/ui/toaster";
import { LoginDialog } from "./components/login-dialog";
import { useStore } from "./lib/store";
import { LogoutDialog } from "./components/logout-dialog";
import { RegisterDialog } from "./components/register-dialog";
import { useEffect } from "react";
import {
  getAuthenticatedUserToken,
  isTokenExpired,
  removeAuthenticatedUserToken,
} from "./lib/auth";
import { useToast } from "./components/ui/use-toast";

function App() {
  const user = useStore((state) => state.user);
  const clearUser = useStore((state) => state.clearUser);
  const { toast } = useToast();

	useEffect(() => {
    const token = getAuthenticatedUserToken();
    if (token) {
      const isExpired = isTokenExpired(token);
      if (isExpired) {
        removeAuthenticatedUserToken();
        clearUser();
        toast({
          variant: "destructive",
          title: "Session Expired",
          description: "Your session has expired. Please login again.",
        });
      }
    }
  }, []);

  return (
    <div className="flex justify-center min-h-screen">
      <Sidebar />
      <Feed />
      <div className="flex flex-col gap-2 p-4">
        {user ? <LogoutDialog /> : <LoginDialog />}
        {!user && <RegisterDialog />}
      </div>
      <Toaster />
    </div>
  );
}

export default App;

Here's a breakdown of the changes and their significance:

  1. Importing Required Functions and Hooks:

    • The isTokenExpired, getAuthenticatedUserToken, and removeAuthenticatedUserToken functions from ./lib/auth are imported, along with the useToast hook. These are crucial for checking token validity and managing user feedback.
    import {
      getAuthenticatedUserToken,
      isTokenExpired,
      removeAuthenticatedUserToken,
    } from "./lib/auth";
    import { useToast } from "./components/ui/use-toast";
    
  2. Using useEffect for Initial Token Validation:

    • A useEffect hook is added to perform an action when the component mounts (i.e., when the app starts or refreshes).
    • This is where the token expiration check is performed.
    useEffect(() => {
      // Token expiration logic
    }, []);
    
  3. Checking Token Expiration:

    • The isTokenExpired function is called to determine if the existing authentication token has expired.
    • If the token is expired (isExpired is true), several actions are taken to reset the user's authentication state.
    const isExpired = isTokenExpired();
    
  4. Handling Expired Tokens:

    • If the token is expired, removeAuthenticatedUserToken is called to clear the token from local storage.
    • clearUser is invoked to reset the user state in the global store (useStore), effectively logging out the user.
    • A toast message is displayed to inform the user that their session has expired and they need to log in again.
  5. User Interface Logic:

    • The rendering logic remains the same, displaying different dialogs based on the user's authentication status. This part is unchanged but works seamlessly with the updated authentication state.

Significance of These Updates:

  • Proactive Session Management: By checking for token expiration on app load, the application proactively manages user sessions. This prevents scenarios where users interact with the app assuming they are authenticated when their session has expired.
  • Improved User Experience: Promptly informing users about session expiration and requiring them to log in again enhances security and user experience. It prevents confusion that might arise from attempting actions with an expired token.
  • Centralized Token Management: Handling token expiration in the App.tsx component centralizes this critical functionality, ensuring it's executed regardless of the user's navigation within the app.

Try the app! Set your token expiration to 60s. Sign in, and then close the tab. Give it a minute or two and then open it again!

Although we have implemented a mechanism to handle expired tokens on app load and page refresh, it does not cover scenarios where the token expires and the user interacts with the app without refreshing or reloading it. To address these cases, we need more sophisticated strategies. However, since in this simple app the only interactions that require valid tokens are CRUD operations on posts, we can handle expired tokens specifically for those operations. For instance, we can check if the token is expired before making an API request or handle 401 errors returned by the API by invalidating (removing) the token. Let's implement the latter approach by updating fetchPosts, deletePost, and createPost operations in the app/src/lib/api.ts file as follows:

  if (!response.ok) {
-   throw new Error(
-     `Error: ${response.status} - ${
-       responseJson.message || response.statusText
-    }`,
-   );
+   handleError(response, responseJson.message);
  }

Next, add handleError function to the app/src/lib/api.ts file with the following content:

const handleError = (response: Response, message?: string) => {
  if (response.status === 401) {
    removeAuthenticatedUserToken();
    throw new Error("Your session has expired. Please login again.");
  }

  throw new Error(
    `Error: ${response.status} - ${message || response.statusText}`,
  );
};

The handleError function is used to handle errors that occur during API requests.

If the response status is 401 (Unauthorized), it signifies that the user's session has expired. In such cases, the function removes the authenticated user token from local storage and throws an error with the message "Your session has expired. Please login again."

For any other response status, the function throws an error with the message "Error: - ". The <response status> is the HTTP status code returned by the API, and <message> is an optional custom error message provided.

This function ensures that the application handles expired session errors gracefully and provides appropriate feedback to the user.

Try the app! Sign in, and a post, wait for 60 seconds and then try to delete it!

Please commit your changes to version control:

git add .
git commit -m "Deal with Expired Token"

Step 8: Clean up the Frontend

As the final step of this task, delete the app/src/lib/data.ts file. We no longer need this sample data. Make sure to run prettier to format the code. Additionally, run the linter to ensure there are no linting or typing issues.

Please commit your changes to version control:

git add .
git commit -m "Clean up the Frontend"