Appearance
5 · Using Auth & Structuring Your App
20. 10. 2026 · Slides
Materials for this seminar accumulate here: the task, the homework, links.
Two fixes for your repo
The template has two bugs. Fix both before you start; each is one change.
1 · Sign-in sends you back to the login page. Open /login directly, sign in, and you end up on /login again until you reload. invalidateQueries only refetches data that some component is using right now. Nothing on the login page uses the session, so it stays "signed out". refetchType: "all" refetches it anyway. In apps/frontend/src/features/auth/hooks/useSignIn.ts:
ts
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: authKeys.session(),
refetchType: "all",
})
},2 · The link in the verification e-mail ends on a 404. Without a callbackURL BetterAuth redirects to / on the backend, which has no page. In apps/frontend/src/features/auth/hooks/useSignUp.ts, add to authClient.signUp.email({ … }):
ts
callbackURL: `${window.location.origin}/quacks`,Demo story
Copy the whole block and paste it to your agent. We run in parallel. The Czech version below is the same story for anyone prompting in Czech.
text
Implement this user story.
As an author, I want to delete my own quacks and see only mine, so I can clean up what I posted.
Acceptance criteria
Deleting
- Each of my quacks has a Delete action. Quacks by other people don't.
- Before deleting, the app asks me to confirm. After deleting, the quack disappears from the feed.
- Nobody can delete someone else's quack, not even by calling the API directly.
Filter
- Above the feed there are two tabs: All and Mine. Mine shows only my quacks.
- When I have no quacks, Mine says so and offers to write the first one.
- Signed-out visitors can't reach the feed, same as today.text
Implementuj tento user story.
Jako autor chci smazat své příspěvky a vidět jen ty svoje, abych mohl uklidit, co jsem napsal.
Akceptační kritéria
Mazání
- U každého mého příspěvku je akce Smazat. U cizích není.
- Před smazáním se mě aplikace zeptá. Po smazání příspěvek zmizí z feedu.
- Cizí příspěvek nesmaže nikdo, ani přímým voláním API.
Filtr
- Nad feedem jsou dvě záložky: Vše a Moje. Moje ukazuje jen moje příspěvky.
- Když nemám žádný příspěvek, Moje to řekne a nabídne napsat první.
- Nepřihlášený se k feedu nedostane, stejně jako dnes.Review checklist
Apply the two fixes first, otherwise the second account gets sent back to the login page.
Two browser windows, normal and private, signed in as two different accounts:
- As the author: do you see Delete on your quack? Does the app ask first? Does the quack disappear?
- As the other account: is there no Delete on someone else's quack?
- Try it through the API: in Swagger (
localhost:4050/api/docs) find someone else's quack id withGET /quacks, then sendDELETEto it. Expect 403. - Signed out: sign out and send the same
DELETEagain. Expect 401. - Mine: does it show only yours? Look for
userIdin the URL or the request body; it shouldn't be there.
Then open the code: where exactly is the ownership check? If it's only in React, the review failed.
E2E tests with Playwright
Setup, once per project:
shell
pnpm frontend add -D @playwright/test
pnpm frontend exec playwright install chromiumapps/frontend/playwright.config.ts:
ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./e2e",
use: { baseURL: "http://localhost:3050" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
})Apply the sign-in fix first, or the tests that sign in will fail. Add "e2e": "playwright test" to the scripts in apps/frontend/package.json. Tests live in apps/frontend/e2e/ and run against the app from pnpm dev:
shell
pnpm frontend e2e # all tests
pnpm frontend e2e --ui # watch every step
pnpm frontend exec playwright codegen localhost:3050 # click, Playwright writes the codeReview a test the same way as a feature: break the feature and the test has to fail. A test that always passes tests nothing. Remove the ownership check and the last test fails with Received: 200 (or 204, depending on what your endpoint returns).
The three files from the slides, complete. The button names (Delete quack, Delete, Quack) are the ones from our reference; yours depend on what your agent generated.
apps/frontend/e2e/signIn.ts:
ts
import { expect, type Page } from "@playwright/test"
export async function signIn(page: Page, email: string, password: string) {
await page.goto("/login")
await page.getByLabel("Email").fill(email)
await page.getByLabel("Password").fill(password)
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible()
}apps/frontend/e2e/auth.spec.ts:
ts
import { expect, test } from "@playwright/test"
import { signIn } from "./signIn"
test("signed-out visitor is sent to the login page", async ({ page }) => {
await page.goto("/quacks")
await expect(page).toHaveURL(/\/login/)
})
test("user can sign in", async ({ page }) => {
await signIn(page, "caffeinatedduck@example.com", "password1")
})apps/frontend/e2e/quacks.spec.ts:
ts
import { expect, test, type Page } from "@playwright/test"
import { signIn } from "./signIn"
const API = "http://localhost:4050/api"
async function findQuackNotBy(page: Page, username: string): Promise<{ id: string }> {
const quacks: { id: string; user: { username: string } }[] = await (
await page.request.get(`${API}/quacks`)
).json()
return quacks.find((quack) => quack.user.username !== username)!
}
test("author can post a quack and delete it", async ({ page }) => {
const text = `E2E quack ${Date.now()}`
await signIn(page, "caffeinatedduck@example.com", "password1")
await page.goto("/quacks")
await page.getByLabel("New quack").fill(text)
await page.getByRole("button", { name: "Quack", exact: true }).click()
const quack = page.getByRole("article").filter({ hasText: text })
await expect(quack).toBeVisible()
await quack.getByRole("button", { name: "Delete quack" }).click()
await page.getByRole("dialog").getByRole("button", { name: "Delete" }).click()
await expect(quack).toHaveCount(0)
})
test("nobody can delete someone else's quack", async ({ page }) => {
await signIn(page, "deepduckthoughts@example.com", "password2")
await page.goto("/quacks")
const othersQuack = page.getByRole("article").filter({ hasNotText: "@DeepDuckThoughts" }).first()
await expect(othersQuack.getByRole("button", { name: "Delete quack" })).toHaveCount(0)
// The button is hidden. Now skip the UI and ask the server directly.
const notMine = await findQuackNotBy(page, "DeepDuckThoughts")
const response = await page.request.delete(`${API}/quacks/${notMine.id}`)
expect(response.status()).toBe(403)
})Add test-results/ and playwright-report/ to apps/frontend/.gitignore.
E-mails with Resend
The app already sends e-mails: BetterAuth sends the verification e-mail after sign-up and the password reset. Your code can send one too: inject @Inject('EmailService') and call sendEmail(to, subject, html). The admin's address is config.superadminEmail (SUPERADMIN_EMAIL in apps/backend/.env, locally admin@example.com). Templates are React components in apps/backend/src/core/email/templates/.
Where an e-mail ends up depends on the backend env: RESEND_API_KEY set → Resend; SMTP_HOST, SMTP_USERNAME and SMTP_PASSWORD set → SMTP; nothing → it's only logged. Locally you need nothing: sign up and find the e-mail in the backend terminal. On production, in the live logs.
Real e-mails on your live app (bonus)
Your live app runs on our server, and you can't set environment variables there. So the key goes into the Docker image when GitHub Actions builds it: GitHub secret → build argument → environment variable in the backend image. Four steps, then a merge.
Without your own domain, Resend sends from onboarding@resend.dev and only to the e-mail address of your Resend account. That's enough to see it work. Sending to anyone needs your own domain, which this course doesn't cover.
1 · Resend account and key. Sign up at resend.com with the e-mail you want the test e-mails in. API Keys → Create API Key, permission Sending access. Copy the key (re_…); Resend shows it only once.
2 · The key into GitHub. In your repo on GitHub: Settings → Secrets and variables → Actions → New repository secret. Name RESEND_API_KEY, value the key. The name must match exactly.
3 · Pass it to the build. In .github/workflows/deploy.yml, find the step Build & push backend and add build-args. The whole step then looks like this:
yaml
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: .
file: apps/backend/Dockerfile.deploy
push: true
build-args: |
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
tags: ${{ env.REGISTRY }}/${{ steps.slug.outputs.slug }}-backend:latestOnly Build & push backend. The frontend step has its own build-args for the API URL; leave them alone.
4 · Into the image. In apps/backend/Dockerfile.deploy, at the end of the last stage, add the two RESEND_API_KEY lines next to CI_COMMIT_SHA, before USER node:
dockerfile
ARG CI_COMMIT_SHA
ENV CI_COMMIT_SHA=$CI_COMMIT_SHA
ARG RESEND_API_KEY=""
ENV RESEND_API_KEY=$RESEND_API_KEY
USER node
WORKDIR /app/apps/backendIt has to be the last stage (the one after the last FROM). An ARG in an earlier stage doesn't reach the running app.
5 · Merge into main. Like any change: branch, PR, merge. Actions builds the images with the key and your app is redeployed in a few minutes.
Check it works: sign up on your live app with the same e-mail as your Resend account. The verification e-mail lands in your inbox (check spam too). The link ends on a 404? Apply fix 2.
It doesn't arrive?
- Open your live logs and look for
Resend delivery failed. The message says why. You can only send testing emails to your own email address: you signed up with a different e-mail than your Resend account.- No
Resenderror, but the e-mail HTML is in the log: the key didn't make it into the image. Check the secret name, and that step 4 is in the last stage. - The Actions run is red: open it and read the failing step.
Roles
Every user has exactly one role: user or admin (the UserRole enum in schema.prisma, the role field in the BetterAuth config). BetterAuth puts it into the session: user.role from @User() on the backend, useSession().user.role on the frontend.
- Nobody can give themselves a role. The field has
input: false; a sign-up withrole: "admin"getsuser. - The admin comes from the seed:
admin@example.com/adminpassword. Try moderation locally only. "Pond Admin" from the seed is not an admin. - Owner or role is one check in the service:
if (!isOwner && !isAdmin) throw new ForbiddenException(). - Ownership in the service, roles may use a guard. Ownership needs the data, and the service has it. If a whole endpoint is for one role only, a guard is enough.
- A whole endpoint for admins only: a
@Roles('admin')decorator plus aRolesGuardthat runs afterAuthenticatedUserGuard. Neither is in the template yet; put them inshared/auth/:
apps/backend/src/shared/auth/decorators/roles.decorator.ts:
ts
import { CustomDecorator, SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
// Marks a controller or method as allowed only for these roles; RolesGuard enforces it.
export const Roles = (...roles: string[]): CustomDecorator<string> =>
SetMetadata(ROLES_KEY, roles);apps/backend/src/shared/auth/guards/roles.guard.ts:
ts
import { ROLES_KEY } from '@/shared/auth/decorators/roles.decorator';
import { Identity } from '@/shared/auth/domain/identity';
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
// Runs after AuthenticatedUserGuard, so the session is already on the request.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<string[] | undefined>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!roles) return true;
const user: Identity = context.switchToHttp().getRequest().session.user;
if (!roles.includes(user.role)) {
// 403: signed in, but not allowed
throw new ForbiddenException();
}
return true;
}
}On the controller: @UseGuards(AuthenticatedUserGuard, RolesGuard) on the class, @Roles('admin') on the method.
- Need more later? Add
moderatorto the enum first. For permissions instead of role names, BetterAuth has an admin plugin with access control. For rules that depend on the data ("may edit within an hour"), a library like CASL.
How the app is structured
text
apps/frontend/src/
├── features/ one folder per feature: auth/, quack/ (api · components · hooks)
├── components/ shared: Header, ui/
├── lib/ shared: api-client, utils
└── routes/ pages; they put features together
apps/backend/src/
├── modules/ one folder per feature: quack/ (controller · service · repository)
├── shared/auth/ guards, @User(), your @Roles()
└── core/ prisma, e-mail- One feature, one folder. Deleting a feature means deleting its folder. Five people each in their own folder means fewer merge conflicts.
- Frontend: a feature never imports another feature.
pnpm lintchecks it (import/no-restricted-paths). Whatever a feature needs from elsewhere, the route passes in as a prop, likecurrentUserId. - Backend: a module may use another module's service. Export the service from its module and add that module to
imports.
Your task: moderation
"Someone started posting nasty quacks. Their author won't delete them and right now we can't do anything about it. We need a moderator who can delete anything. Nothing more for now."
The moderator is admin; the role already exists, and so does the account: admin@example.com / adminpassword from the seed.
- Delete from class works? The task builds on it. If the demo didn't get finished, paste the demo story to your agent first and go through the review checklist.
- Story. "Ask me questions until the story has no open decisions." In class, 2–3 questions are enough. Save it as
story.md. - Server, then UI. First the check in the service (the owner, or an admin), then the Delete button for the admin.
- Review with three accounts: the author, someone else,
admin@example.com. Then Swagger. Commit. - E2E test. Break the check and make sure the test fails.
In class:
- an admin deletes someone else's quack; everyone else still gets 403
- the admin sees Delete on every quack
By Sunday:
- an E2E test: the admin deletes someone else's quack, a regular user gets 403
- everything merged and live
Finished early? A Stats page for the admin only (@Roles('admin') on the API, the link hidden in the UI). A moderator role. A unit test for the service: another user gets ForbiddenException.
Hints
Server: one condition in the service, see Roles:
if (!isOwner && !isAdmin) throw new ForbiddenException().user.rolecomes from@User().UI: the route already reads
useSession()and passescurrentUserIddown. Pass the role the same way, for exampleisAdmin={user?.role === "admin"}, and show Delete whenisMine || isAdmin. Or computecanDeletein the route and pass just that. Hiding the button is convenience; the server check is what counts.E2E test with two users: one page can be signed in as one user only;
/loginsends a signed-in user straight to the feed. Open a second page for the second user withbrowser.newPage(). Let the author create the quack the test deletes, so you don't eat up the seed:tstest("admin can delete someone else's quack", async ({ browser }) => { const text = `Spam ${Date.now()}` const author = await browser.newPage() await signIn(author, "caffeinatedduck@example.com", "password1") await author.request.post(`${API}/quacks`, { data: { text } }) const admin = await browser.newPage() await signIn(admin, "admin@example.com", "adminpassword") // … find the quack by its text, delete it, expect it gone })The "regular user gets 403" half is the
nobody can delete someone else's quacktest from above; it must still pass.moderatorrole: add it to theUserRoleenum inschema.prisma, runpnpm backend prisma:migrations:run, then extend the check. Give someone the role in your local database (pnpm --filter backend exec prisma studioopens a table editor).Unit test:
apps/backend/src/modules/quack/services/quacks.service.spec.tsis already in your repo; copy its setup and mock the repository.Stats page:
@Roles()andRolesGuardare under Roles. Put@Get('stats')above any@Get(':id')in the controller: Nest matches routes in order, and below itstatsis taken as an id. Or give stats its own controller. On the frontend, hide the link unlessuser?.role === "admin"; the API returns 403 to everyone else anyway.
Done means
Permissions are right when…
- every check is on the server (service or guard), not only in React
- as a regular user you can't delete someone else's quack, even through Swagger: 403
- signed out: 401
- "me" comes from the session; no
userIdin the URL or the body - the E2E test passes, and fails when you switch the check off
Your feature is done when…
- locally:
admin@example.comdeletes someone else's quack,deepduckthoughtscan't pnpm lintpasses- you can explain every change
- a new rule in
CLAUDE.mdfrom the moderation work, next to the one from class - delete, Mine and moderation merged into
main, live, posted in Seminars
Homework
Everything above merged and live by Sunday midnight. Two PRs, one after the other: merge delete + Mine first, then start a new branch for moderation, so they don't fight over the same files. Post in the Seminars channel on Teams:
text
Live app: <URL>
Delete + Mine PR: <link>
Moderation PR: <link>
Where I check owner or role: <file + line>
E2E test: <link to the spec file>
My rule: <the rule>