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.
Reading progress table
0 / 5 points
Which statement best describes the reading_progress table created in the migration?
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.
User-specific repository operations
0 / 5 points
Which statements are true about implementing user-specific repository operations? (Select all that apply)
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.
Controller and user context
0 / 5 points
How does the controller access the authenticated user’s information to perform user-specific operations?
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 routesapp.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.
Protecting routes
0 / 5 points
Which code correctly applies authentication middleware to all routes starting with /api/reading-progress/?
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.
API for personalized todos and tasks
0 / 45 points
At the end of the chapter “Hierarchical Resources and APIs” of the part on “Server-Side Functionality with a Database”, you implemented a todos and tasks application where both the todos and the tasks were backed by an API. The API was as follows:
- POST /api/todos -> todoController.create
- GET /api/todos -> todoController.readAll
- GET /api/todos/:todoId -> todoController.readOne
- PUT /api/todos/:todoId -> todoController.update
- DELETE /api/todos/:todoId -> todoController.deleteOne
- POST /api/todos/:todoId/tasks -> taskController.create
- GET /api/todos/:todoId/tasks -> taskController.readAll
- GET /api/todos/:todoId/tasks/:taskId -> taskController.readOne
- PUT /api/todos/:todoId/tasks/:taskId -> taskController.update
- DELETE /api/todos/:todoId/tasks/:taskId -> taskController.deleteOne
In this exercise, you’ll modify the application so that todos (and their tasks) are personal to each user. By the end of this exercise, each user will only be able to see and manage their own todos and tasks through an authenticated API.
Modifying the database schema
First, add a migration to associate todos with users. Create a new migration file in your database-migrations folder:
DELETE FROM todo_tasks;DELETE FROM todos;ALTER TABLE todos ADD COLUMN user_id INTEGER NOT NULL REFERENCES users(id);Here, we assume that you have a users table. If not, create it as well. Run the migration to update your database schema.
Authentication functionality
Copy in your authentication functionality from the earlier exercises. You should have the following endpoints:
POST /api/auth/loginfor user loginPOST /api/auth/registerfor user registration
Use the same data format as in the earlier exercises. Make sure that the JWT secret is “jwt_secret”.
Modifying the todo repository
Modify the todoRepository.js file so that the repository functions are passed the identifier of the user who is logged in. The functions should only operate on todos that belong to the user with the given identifier.
For example, the readAll function should be as follows:
const readAll = async (userId) => { return await sql` SELECT * FROM todos WHERE user_id = ${userId} ORDER BY created_at DESC `;};Modifying the todo controller
Then, modify the todoController.js file so that the controller functions retrieve the user from the context using c.get("user") and pass the user’s identifier to the repository functions. For example, the readAll function should be as follows:
const readAll = async (c) => { const user = c.get("user"); const todos = await todoRepository.readAll(user.id); return c.json(todos);};NOT modifying the task repository and controller
Do not modify the taskRepository.js and taskController.js files.
Note! This intentionally leaves a security issue in the program. For instance, an authenticated user could create a task for a todo that belongs to another user.
This is left as an exercise for you to fix later. In a real-world application, you would want to ensure that users can only manage tasks for their own todos.
Updating routes
Finally, update app.js to include authentication routes and protect the todo and task routes using the authenticate-middleware.
Testing and submission
Once ready, test the API by curling it. When you observe that it works, zip the contents of your server-side project folder and return the zip here. The zip should include the following files:
.├── app.js├── app-run.js├── authController.js├── authRepository.js├── middlewares.js├── taskController.js├── taskRepository.js├── todoController.js└── todoRepository.jsAdditional files (e.g., deno.json, migration files) can also be included, but the above files must be present. If you omit files that are in your own setup, make sure that they are not necessary for running the application.
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 };Reading progress API module
0 / 5 points
What is the primary role of readingProgressApi.js on the client?
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}BookReadingProgress component
0 / 5 points
How does BookReadingProgress.svelte handle existing and new reading progress?
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.
Reading progress component behavior
0 / 5 points
A logged in user visits a book page where they have already marked the book as “Currently Reading”. What should the BookReadingProgress component display?
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}Displaying user reading list
0 / 5 points
What does UserReadingList.svelte do when mounted?
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.jsfile in thesrc/routes/adminfolder.
Client-side route protection
0 / 5 points
How does the /reading-list page guard unauthenticated access on the client?
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
authFetchhelper 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
loadfunction in+page.jsto check for authentication and redirect unauthenticated users. - The
ON CONFLICTclause in SQL allows for “upsert” operations, which can create or update records in a single query while respecting unique constraints.
AI Study Aid
Create a chapter diagram
We're looking into to what extent AI-generated diagrams could help with studying.
Use this study aid to generate an AI-generated visual summary of the material you just studied.
Each diagram style emphasizes the content in a different way, so you can choose the focus
that feels most useful.
Using the diagram generator is voluntary and does not affect your course progress. We encourage
you to try it out and see if it helps, but it's totally up to you! Your answers help us understand
how to make better study aids in the future.
Client-side personalized todos and tasks
0 / 30 points
In this exercise, focusing on client-side functionality, you’ll integrate authentication into your todos application so that users can only see and manage their own todos and tasks. This requires modifying your API modules to use authenticated requests and updating your pages to handle authentication.
Starting point
As a starting point, combine your solutions from the exercises “Client-side authorization” and “Todos and tasks, tasks with an API”. Jointly, your files the src folder of the client-side project should be as follows:
.├── lib│ ├── apis│ │ ├── authApi.js│ │ ├── tasksApi.js│ │ └── todosApi.js│ ├── components│ │ └── todos│ │ ├── Task.svelte│ │ ├── TaskForm.svelte│ │ ├── TaskList.svelte│ │ ├── Todo.svelte│ │ ├── TodoForm.svelte│ │ └── TodoList.svelte│ ├── states│ │ ├── authState.svelte.js│ │ ├── taskState.svelte.js│ │ └── todoState.svelte.js│ └── utils│ └── fetchUtils.js└── routes ├── auth │ └── [action] │ ├── +page.js │ └── +page.svelte ├── todos │ ├── [todoId] │ │ ├── tasks │ │ │ └── [taskId] │ │ │ └── +page.svelte │ │ └── +page.svelte │ └── +page.svelte ├── +layout.svelte └── +page.svelteAt the root path of the application, when not authenticated, you should have “Login” and “Register” links and the text “Hello anonymous!”.
Updating API modules
First, update the API modules todosApi.js and tasksApi.js to use authenticated requests. Use the authFetch helper function from the fetchUtils.js module.
Guarding against unauthenticated access
Then, update the pages to guard against unauthenticated access. If a user tries to access any of the /todos routes while not authenticated, they should be redirected to the login page. Implement this by adding a +page.js files to routes/todos and the folders below it.
The load function in the files should check if the user is authenticated (check if localStorage has an item called token if running in browser). If not, redirect to /auth/login, and otherwise proceed as normal.
Modifying main page text
Finally, modify the text shown at the root path of the application. When a user is logged in, the user should be shown a link to /todos with the text “Go to your todos”. Keep the “Hello, (email)!” and the Logout button that come from the layout.
Submission format
Once ready, test your application with the API for personalized todos and tasks, and when you observe that it works, zip the contents of the folder client/src and return the zip here. The zip should not contain the client/src folder, but the contents of the client/src folder. The contents of the zip should be as follows.
.├── lib│ ├── apis│ │ ├── authApi.js│ │ ├── tasksApi.js│ │ └── todosApi.js│ ├── components│ │ └── todos│ │ ├── Task.svelte│ │ ├── TaskForm.svelte│ │ ├── TaskList.svelte│ │ ├── Todo.svelte│ │ ├── TodoForm.svelte│ │ └── TodoList.svelte│ ├── states│ │ ├── authState.svelte.js│ │ ├── taskState.svelte.js│ │ └── todoState.svelte.js│ └── utils│ └── fetchUtils.js└── routes ├── auth │ └── [action] │ ├── +page.js │ └── +page.svelte ├── todos │ ├── [todoId] │ │ ├── tasks │ │ │ └── [taskId] │ │ │ ├── +page.js │ │ │ └── +page.svelte │ │ ├── +page.js │ │ └── +page.svelte │ ├── +page.js │ └── +page.svelte ├── +layout.svelte └── +page.svelte