Users and Security

Working with User-Specific Resources


Learning Objectives

  • You understand how to implement user-specific data storage and access control.

In the previous chapter, we learned how to protect a single route and how to make authenticated requests from the client. In this chapter, building on the previous chapter, we look into working with user-specific data.

In this example, we add functionality for tracking reading progress; the relevant tables that the example will build on are the books table that we created when learning about Interacting with a Database and the users table that we created in Users, Security, and Passwords. Their schemas are as follows:

CREATE TABLE books (
  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title TEXT NOT NULL CHECK (length(title) > 0),
  description TEXT,
  published_at DATE,
  page_count INTEGER CHECK (page_count > 0)
);

CREATE TABLE users (
  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX ON users (lower(trim(email)));

Server-side functionality

Server-side functionality for handling reading progress involves (1) creating a database table for storing reading progress, (2) creating a repository for managing reading progress, (3) creating a controller for handling reading progress operations, and (4) wiring the controller to routes and adding authentication middleware to protect the routes.

Creating the reading progress table

First, create a database table for reading progress. Create a migration file (e.g. V6__reading_progress.sql) and place it in the database-migrations folder:

CREATE TABLE reading_progress (
  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  book_id INTEGER NOT NULL REFERENCES books(id),
  status TEXT NOT NULL CHECK (status IN ('want_to_read', 'reading', 'finished')),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(user_id, book_id)
);

The reading_progress table stores each user’s reading list with a status for each book. Each user can only have one entry per book, enforced by the UNIQUE(user_id, book_id) constraint. Run the migration to create the table.

Loading Exercise...

Reading progress repository

Then, create a repository for managing reading progress. Create a file called readingProgressRepository.js in the server-side project, and place the following code in the file:

import postgres from "postgres";

const sql = postgres();

const create = async (userId, bookId, status) => {
  const result = await sql`
    INSERT INTO reading_progress (user_id, book_id, status)
    VALUES (${userId}, ${bookId}, ${status})
    ON CONFLICT (user_id, book_id)
    DO UPDATE SET
      status = ${status},
      updated_at = CURRENT_TIMESTAMP
    RETURNING *
  `;
  return result[0];
};

const findUserProgress = async (userId) => {
  return await sql`
    SELECT
      reading_progress.id,
      reading_progress.status,
      reading_progress.created_at,
      reading_progress.updated_at,
      books.id as book_id,
      books.title,
      books.description
    FROM reading_progress
    JOIN books ON reading_progress.book_id = books.id
    WHERE reading_progress.user_id = ${userId}
    ORDER BY reading_progress.updated_at DESC
  `;
};

const findUserProgressByStatus = async (userId, status) => {
  return await sql`
    SELECT
      reading_progress.id,
      reading_progress.status,
      reading_progress.created_at,
      reading_progress.updated_at,
      books.id as book_id,
      books.title,
      books.description
    FROM reading_progress
    JOIN books ON reading_progress.book_id = books.id
    WHERE reading_progress.user_id = ${userId} AND reading_progress.status = ${status}
    ORDER BY reading_progress.updated_at DESC
  `;
};

const findUserProgressForBook = async (userId, bookId) => {
  const result = await sql`
    SELECT * FROM reading_progress
    WHERE user_id = ${userId} AND book_id = ${bookId}
  `;

  return result[0];
};

const deleteProgress = async (userId, bookId) => {
  const result = await sql`
    DELETE FROM reading_progress
    WHERE user_id = ${userId} AND book_id = ${bookId}
    RETURNING *
  `;
  return result[0];
};

export { create, deleteProgress, findUserProgress, findUserProgressByStatus, findUserProgressForBook };

The repository provides the functionality for creating, retrieving, updating, and deleting reading progress. Notice how all operations are scoped to a specific user, ensuring users can only access their own reading lists.

Loading Exercise...

Reading progress controller

Next, create a controller for handling reading progress operations. Create a file called readingProgressController.js, and place the following code in the file:

import * as readingProgressRepository from "./readingProgressRepository.js";

const createOrUpdateProgress = async (c) => {
  const user = c.get("user");
  const bookId = parseInt(c.req.param("bookId"));
  const { status } = await c.req.json();

  if (!['want_to_read', 'reading', 'finished'].includes(status)) {
    return c.json({ error: "Invalid status" }, 400);
  }

  const progress = await readingProgressRepository.create(
    user.id,
    bookId,
    status
  );

  return c.json(progress);
};

const getUserProgressForBook = async (c) => {
  const user = c.get("user");
  const bookId = parseInt(c.req.param("bookId"));
  const progress = await readingProgressRepository.findUserProgressForBook(user.id, bookId);
  return c.json(progress || null);
};

const getUserProgress = async (c) => {
  const user = c.get("user");
  const status = c.req.query("status");

  let progress;
  if (status) {
    progress = await readingProgressRepository.findUserProgressByStatus(user.id, status);
  } else {
    progress = await readingProgressRepository.findUserProgress(user.id);
  }

  return c.json(progress);
};

const deleteProgress = async (c) => {
  const user = c.get("user");
  const bookId = parseInt(c.req.param("bookId"));

  const deleted = await readingProgressRepository.deleteProgress(user.id, bookId);
  return c.json(deleted);
};

export { createOrUpdateProgress, deleteProgress, getUserProgressForBook, getUserProgress };

The controller acts as a layer that handles HTTP requests and responses, delegating data operations to the repository. It uses the user information from the context to ensure that operations are performed for the authenticated user. However, the id is not a part of the token payload yet, unless you’ve added it as a part of an exercise, so let’s add that next.

Adding user identifier to the context

In the previous chapter, we created an authenticate middleware that verifies the JWT token and extracts user information from it. We also modified the middleware to add the user information to the context using c.set("user", payload). This makes the user information available to all route handlers that run after the middleware.

In the previous chapter, however, the middleware only included the email of the user in the payload. To perform user-specific operations, we also need the user’s unique identifier (ID). Update the login function in authController.js to include the user ID in the payload:

//...

const login = async (c) => {
  const user = await c.req.json();

  const foundUser = await authRepository.findByEmail(user.email);
  if (!foundUser) {
    return c.json({ error: "Invalid email or password" }, 401);
  }

  const isValid = verify(user.password, foundUser.password_hash);
  if (!isValid) {
    return c.json({ error: "Invalid email or password" }, 401);
  }

  const payload = { email: foundUser.email, id: foundUser.id };
  const token = await jwt.sign(payload, JWT_SECRET);

  return c.json({
    message: "Login successful",
    user: { email: foundUser.email },
    token
  });
};

//...

Now, when the user logs in for the next time, the user identifier will be available in the context.

Loading Exercise...

Wiring the controller to routes and adding middleware

Finally, let’s wire the controller to our routes and add the authenticate middleware to protect the routes. We want to protect all reading progress routes, so we can use a wildcard * to apply the middleware to all routes that start with /api/reading-progress/. Modify the app.js file as follows:

// ...
import * as readingProgressController from "./readingProgressController.js";
// ...

// middleware and new routes
app.use("/api/reading-progress/*", middlewares.authenticate);
app.get("/api/reading-progress", readingProgressController.getUserProgress);
app.get("/api/reading-progress/book/:bookId", readingProgressController.getUserProgressForBook);
app.post("/api/reading-progress/book/:bookId", readingProgressController.createOrUpdateProgress);
app.delete("/api/reading-progress/book/:bookId", readingProgressController.deleteProgress);

// ...

The /api/reading-progress/* will also match /api/reading-progress, so the middleware will run for all reading progress routes. The middleware (that we built in the previous chapter) will verify the token and add the user information to the context, which the controller can then use to perform user-specific operations.

Loading Exercise...

Testing server-side functionality

Finally, we can test the server-side functionality using curl. First, login to get a token:

$ curl -X POST -d '{"email": "user@user.com", "password": "secret"}' localhost:8000/api/auth/login
{"message":"Login successful","user":{"email":"user@user.com"},"token":"eyJhbGciOiJIU..."}

Then, ask for all reading progress for the authenticated user:

$ curl -H "Authorization: Bearer eyJhbGciOiJIU..." localhost:8000/api/reading-progress
[]

So far, no books tracked. We could either add progress using curl or create a client-side interface for managing reading progress; here, we’ll do the latter, but in practice it would be sensible to test the API already now with curl.

Loading Exercise...

Client-side functionality

Now that we have the API working, let’s create a client-side interface for handling reading progress. This involves (1) building a component that allows users to track books, (2) adding the component to the individual book page, (3) creating a component to display the user’s reading list, and (4) creating a page to display all of the user’s reading progress.

Reading progress API module

First, create a file called readingProgressApi.js in the src/lib/apis folder. It will contain the client-side API for handling reading progress, with functions that correspond to the server-side controller functions. As all requests require authentication, we use the authFetch helper that we created in the previous chapter.

Place the following code into the file:

import { PUBLIC_API_URL } from "$env/static/public";
import { authFetch } from "$lib/utils/fetchUtils.js";

const updateProgress = async (bookId, status) => {
  return await authFetch(`${PUBLIC_API_URL}/api/reading-progress/book/${bookId}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ status }),
  });
};

const getProgressForBook = async (bookId) => {
  return await authFetch(`${PUBLIC_API_URL}/api/reading-progress/book/${bookId}`);
};

const getUserProgress = async (status = null) => {
  const url = status
    ? `${PUBLIC_API_URL}/api/reading-progress?status=${status}`
    : `${PUBLIC_API_URL}/api/reading-progress`;
  return await authFetch(url);
};

const deleteProgress = async (bookId) => {
  return await authFetch(`${PUBLIC_API_URL}/api/reading-progress/book/${bookId}`, {
    method: "DELETE",
  });
};

export { updateProgress, deleteProgress, getProgressForBook, getUserProgress };
Loading Exercise...

Reading progress component

Next, we create a component for managing reading progress. The component will check whether the user is tracking this book. If yes, it will show the current status and allow the user to update or remove it. If no, it will allow the user to add the book to their reading list.

Create a new component called BookReadingProgress.svelte, and place it in the src/lib/components/books folder. Place the following code into the file:

<script>
  import * as readingProgressApi from "$lib/apis/readingProgressApi.js";

  let { bookId } = $props();
  let currentProgress = $state(null);
  let selectedStatus = $state("want_to_read");
  let error = $state("");
  let loading = $state(true);

  const statusLabels = {
    want_to_read: "Want to Read",
    reading: "Currently Reading",
    finished: "Finished"
  };

  const updateProgress = async () => {
    error = "";
    const response = await readingProgressApi.updateProgress(bookId, selectedStatus);
    if (!response.ok) {
      error = "Failed to update reading progress. Please try again.";
      return;
    }

    const data = await response.json();
    currentProgress = data;
  };

  const removeFromList = async () => {
    error = "";
    const response = await readingProgressApi.deleteProgress(bookId);
    if (!response.ok) {
      error = "Failed to remove from reading list. Please try again.";
      return;
    }

    currentProgress = null;
    selectedStatus = "want_to_read";
  };

  const loadProgress = async () => {
    loading = true;
    const response = await readingProgressApi.getProgressForBook(bookId);
    if (response.ok) {
      const data = await response.json();
      if (data) {
        currentProgress = data;
        selectedStatus = data.status;
      }
    }

    loading = false;
  };

  $effect(() => {
    loadProgress();
  });
</script>

<h2>My Reading Progress</h2>

{#if !loading}
  {#if currentProgress}
    <p>Status: <strong>{statusLabels[currentProgress.status]}</strong></p>
    <p>
      <label>
        <span>Update status:</span>
        <select bind:value={selectedStatus}>
          <option value="want_to_read">Want to Read</option>
          <option value="reading">Currently Reading</option>
          <option value="finished">Finished</option>
        </select>
      </label>
    </p>
    <button onclick={updateProgress}>Update Status</button>
    <button onclick={removeFromList}>Remove from My List</button>
  {:else}
    <p>This book is not in your reading list.</p>
    <label>
      <span>Add to list as:</span>
      <select bind:value={selectedStatus}>
        <option value="want_to_read">Want to Read</option>
        <option value="reading">Currently Reading</option>
        <option value="finished">Finished</option>
      </select>
    </label>
    <button onclick={updateProgress}>Add to My List</button>
  {/if}

  {#if error}
    <p>{error}</p>
  {/if}
{/if}
Loading Exercise...

Adding reading progress to the book page

Now, let’s add the reading progress component to the individual book page. Update the Book.svelte component in src/lib/components/books to include the BookReadingProgress component:

<script>
  import { useBookState } from "$lib/states/bookState.svelte.js";
  import { useAuthState } from "$lib/states/authState.svelte.js";
  import BookReadingProgress from "$lib/components/books/BookReadingProgress.svelte";

  let { bookId } = $props();

  let bookState = useBookState();
  let authState = useAuthState();

  let book = $derived(bookState.books.find((book) => book.id === bookId));
</script>

<h1>{book ? book.title : "Loading..."}</h1>

{#if book}
  <p><strong>Description:</strong> {book.description}</p>
  <p><strong>Published at:</strong> {book.published_at}</p>
  <p><strong>Page count:</strong> {book.page_count}</p>

  {#if authState.user}
    <BookReadingProgress {bookId} />
  {:else}
    <p>
      <a href="/auth/login">Log in</a> to track your reading progress.
    </p>
  {/if}
{/if}

Now, the component shows the reading progress tracker if the user is logged in; otherwise, it prompts the user to log in.

Loading Exercise...

Displaying user’s reading list

Next, let’s create a separate component to display all of the user’s reading progress. Create UserReadingList.svelte in the src/lib/components/books folder:

<script>
  import * as readingProgressApi from "$lib/apis/readingProgressApi.js";

  let progress = $state([]);
  let selectedFilter = $state("all");

  const statusLabels = {
    want_to_read: "Want to Read",
    reading: "Currently Reading",
    finished: "Finished"
  };

  const fetchProgress = async () => {
    const status = selectedFilter === "all" ? null : selectedFilter;
    const response = await readingProgressApi.getUserProgress(status);
    progress = await response.json();
  };

  $effect(() => {
    fetchProgress();
  });
</script>

<h2>My Reading List</h2>

<label>
  <span>Filter by status:</span>
  <select bind:value={selectedFilter}>
    <option value="all">All Books</option>
    <option value="want_to_read">Want to Read</option>
    <option value="reading">Currently Reading</option>
    <option value="finished">Finished</option>
  </select>
</label>

{#if progress.length === 0}
  <p>No books found. Start tracking your reading!</p>
{:else}
  <ul>
    {#each progress as item}
      <li>
        <a href="/books/{item.book_id}"><strong>{item.title}</strong></a>
        - {statusLabels[item.status]}
      </li>
    {/each}
  </ul>
{/if}

And then, create a page for displaying the user’s reading list. Create the file src/routes/reading-list/+page.svelte and place the following code into the file:

<script>
  import UserReadingList from "$lib/components/books/UserReadingList.svelte";
  import { useAuthState } from "$lib/states/authState.svelte.js";

  const authState = useAuthState();
</script>

{#if authState.user}
  <UserReadingList />
{:else}
  <p>Please <a href="/auth/login">log in</a> to view your reading list.</p>
{/if}
Loading Exercise...

Protecting client-side routes

As an extra trick, let’s quickly look into protecting client-side routes. We previously noticed that we can use the load function to show an error message if the path is not found. We can also use the load function to protect certain routes.

To protect the personal reading list page, we can check if the user is authenticated in the load function. If not, we can redirect them to the login page. To do this, create a +page.js file in the src/routes/reading-list folder, and add the following code to the file:

import { redirect } from "@sveltejs/kit";
import { browser } from "$app/environment";

export const load = () => {
  if (browser) {
    const token = localStorage.getItem("token");
    if (!token) {
      throw redirect(307, "/auth/login");
    }
  }

  return {};
};

Now, if an unauthenticated user tries to access /reading-list, they will be redirected to the login page. The same approach can be used to protect any other client-side routes that require authentication.

You could do this also for layouts. For example, to protect all routes under /admin, you could create a +layout.js file in the src/routes/admin folder.

Loading Exercise...

Summary

In summary:

  • User-specific resources require database tables with foreign keys to the users table to establish ownership relationships.
  • All repository operations for user-specific data should be scoped to a specific user ID to ensure users can only access their own data.
  • Controllers extract user information from the request context (set by authentication middleware) and pass it to repository functions.
  • User identifiers should be included in the JWT payload during login so they are available in authenticated requests without additional database queries.
  • Middleware can be applied to route patterns using wildcards (e.g., /api/reading-progress/*) to protect entire sections of the API.
  • The authFetch helper function should be used for all client-side requests to protected endpoints to automatically include authentication tokens.
  • Client-side routes can be protected using the load function in +page.js to check for authentication and redirect unauthenticated users.
  • The ON CONFLICT clause in SQL allows for “upsert” operations, which can create or update records in a single query while respecting unique constraints.
Loading Exercise...