Skip to content

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:

  1. As the author: do you see Delete on your quack? Does the app ask first? Does the quack disappear?
  2. As the other account: is there no Delete on someone else's quack?
  3. Try it through the API: in Swagger (localhost:4050/api/docs) find someone else's quack id with GET /quacks, then send DELETE to it. Expect 403.
  4. Signed out: sign out and send the same DELETE again. Expect 401.
  5. Mine: does it show only yours? Look for userId in 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 chromium

apps/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 code

Review 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:latest

Only 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/backend

It 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 Resend error, 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 with role: "admin" gets user.
  • 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 a RolesGuard that runs after AuthenticatedUserGuard. Neither is in the template yet; put them in shared/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 moderator to 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 lint checks it (import/no-restricted-paths). Whatever a feature needs from elsewhere, the route passes in as a prop, like currentUserId.
  • 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.

  1. 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.
  2. Story. "Ask me questions until the story has no open decisions." In class, 2–3 questions are enough. Save it as story.md.
  3. Server, then UI. First the check in the service (the owner, or an admin), then the Delete button for the admin.
  4. Review with three accounts: the author, someone else, admin@example.com. Then Swagger. Commit.
  5. 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.role comes from @User().

  • UI: the route already reads useSession() and passes currentUserId down. Pass the role the same way, for example isAdmin={user?.role === "admin"}, and show Delete when isMine || isAdmin. Or compute canDelete in 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; /login sends a signed-in user straight to the feed. Open a second page for the second user with browser.newPage(). Let the author create the quack the test deletes, so you don't eat up the seed:

    ts
    test("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 quack test from above; it must still pass.

  • moderator role: add it to the UserRole enum in schema.prisma, run pnpm backend prisma:migrations:run, then extend the check. Give someone the role in your local database (pnpm --filter backend exec prisma studio opens a table editor).

  • Unit test: apps/backend/src/modules/quack/services/quacks.service.spec.ts is already in your repo; copy its setup and mock the repository.

  • Stats page: @Roles() and RolesGuard are under Roles. Put @Get('stats') above any @Get(':id') in the controller: Nest matches routes in order, and below it stats is taken as an id. Or give stats its own controller. On the frontend, hide the link unless user?.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 userId in 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.com deletes someone else's quack, deepduckthoughts can't
  • pnpm lint passes
  • you can explain every change
  • a new rule in CLAUDE.md from 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>