add guide docs
This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
|||||||
|
# Auth architecture
|
||||||
|
|
||||||
|
Current implementation: **SvelteKit directly + PocketBase**. Hono is **not** part of
|
||||||
|
auth today — it is reserved for future services (email, etc.). The `/api` proxy target
|
||||||
|
still points at it, but no auth traffic flows through it.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ BROWSER (Svelte) │
|
||||||
|
│ /login /signup forms · $app/forms · use:enhance │
|
||||||
|
└──────────────────────────┬─────────────────────────────┘
|
||||||
|
│ 1. form action POST /login
|
||||||
|
│ /signup (returns result)
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ SVELTEKIT SERVER (Node) │
|
||||||
|
│ │
|
||||||
|
│ hooks.server.ts (runs once per request, first) │
|
||||||
|
│ · read httpOnly cookie pb_session │
|
||||||
|
│ · createPbClient(token) → pb.authRefresh() │
|
||||||
|
│ · → { id, username, role, famId } │
|
||||||
|
│ · event.locals.user / event.locals.pbToken │
|
||||||
|
│ · route guards: public vs authed vs /admin │
|
||||||
|
│ │
|
||||||
|
│ +page.server.ts (actions / loads) │
|
||||||
|
│ login.ts / signup.ts / session.ts │
|
||||||
|
│ · createPbClient / createSuperPbClient │
|
||||||
|
│ (ABSOLUTE PB URL - server-to-server, no proxy) │
|
||||||
|
└──────────────────────────┬─────────────────────────────┘
|
||||||
|
│ 2. PB API (REST) │
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ POCKETBASE (100.103.22.104:8090) │
|
||||||
|
│ collections: users (auth) · fams · members · ... │
|
||||||
|
│ │
|
||||||
|
│ PB is the SOURCE OF TRUTH: │
|
||||||
|
│ · password hashing (bcrypt-style) │
|
||||||
|
│ · JWT token issue + expiry (exp claim) │
|
||||||
|
│ · authRefresh / token rotation │
|
||||||
|
│ · per-collection API rules (authz boundary) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Future (NOT auth): SvelteKit → /api proxy → Hono → email & other services
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request lifecycle (authenticated)
|
||||||
|
|
||||||
|
1. Browser sends request; sends cookie `pb_session` (httpOnly, sameSite=lax, secure in prod).
|
||||||
|
2. `hooks.server.ts` extracts the token.
|
||||||
|
3. `createPbClient(token)` builds a PB client pre-authenticated as that user.
|
||||||
|
4. `pb.collection('users').authRefresh()`:
|
||||||
|
- validates the token (PB JWTs can't be checked offline),
|
||||||
|
- returns the fresh record → `event.locals.user = { id, username, role, famId }`,
|
||||||
|
- returns a fresh token; if it changed, the cookie is rolled forward.
|
||||||
|
5. Route guard runs (public / authed / admin-only).
|
||||||
|
6. `+page.server.ts` / `+server.ts` use `locals.user` for identity; use the token-backed
|
||||||
|
PB client for any CRUD so PB's collection rules apply.
|
||||||
|
|
||||||
|
## Key decisions to replicate in another project
|
||||||
|
|
||||||
|
- **Server-only PB client** lives in `src/lib/server/`; never imported by browser code.
|
||||||
|
Secrets (superuser creds) stay server-side.
|
||||||
|
- **Absolute PB base URL** in the SDK (e.g. `http://host:8090`), NOT a relative `/pb`.
|
||||||
|
All calls run in Node where relative URLs fail. The Vite `/pb` proxy is a browser-only
|
||||||
|
convenience and is unnecessary for server-side calls.
|
||||||
|
- **Env vars**: read via `$app/env/private` for private vars (declared in `src/env.ts`),
|
||||||
|
never `$app/env/public`.
|
||||||
|
- **`createSuperPbClient`** = anonymous client + `_superusers` auth, used for admin-style
|
||||||
|
creates (signup). Superusers bypass PB collection rules.
|
||||||
|
- **Session cookie**: PB JWT in `pb_session`; expiry governed by the token's `exp`, cookie
|
||||||
|
`maxAge` is just a ceiling; `authRefresh` rolls it forward.
|
||||||
|
- **Authz boundary** lives in PocketBase collection rules (famId scoping), not just app code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Signup flow (`/signup`)
|
||||||
|
|
||||||
|
Multi-step form on a single route. All steps run as **form actions** on the server; the
|
||||||
|
`use:enhance` handler only advances the step on a non-failure result.
|
||||||
|
|
||||||
|
```
|
||||||
|
ADMIN USER
|
||||||
|
+-----------------------------------------+ 1. POST ?/signup {familyName,email,password}
|
||||||
|
| +-----------+ +-----------+ +--------+ | ─────────────────────────────►
|
||||||
|
| | step 1 | | step 2 | | step 3 | | (use:enhance advances step on success)
|
||||||
|
| | family+ | | username | | child | |
|
||||||
|
| | email+pass| | | | name | |
|
||||||
|
| +-----------+ +-----------+ +--------+ |
|
||||||
|
+-----------------------------------------+
|
||||||
|
▲ 3. set httpOnly cookie pb_session
|
||||||
|
│ return {success:true}
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ /signup/+page.server.ts actions: signup · username · child │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
?/signup │ ?/username ?/child
|
||||||
|
─────────────────────┼───────────────────┬───────────────────────
|
||||||
|
signupAdmin() │ setUsername() │ createChild()
|
||||||
|
─────────────────────┼───────────────────┴───────────────────────
|
||||||
|
│ ▼ (famId from locals.user)
|
||||||
|
▼
|
||||||
|
createSuperPbClient() ── superuser-authenticated PB client
|
||||||
|
│
|
||||||
|
├─(1) fams.create({ name, slug }) → family.id
|
||||||
|
├─(2) users.create({ email,password,name, → user, role not set
|
||||||
|
│ famId: family.id })
|
||||||
|
├─(3) users.authWithPassword(email,password) → token
|
||||||
|
└─(4) setSessionCookie(cookies, token)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
hooks.server.ts picks up the cookie next request
|
||||||
|
→ authRefresh → event.locals.user populated → user is "logged in"
|
||||||
|
|
||||||
|
Failure path: any PB error → throw SignupError(msg)
|
||||||
|
→ action returns fail(status, { message })
|
||||||
|
→ FE renders form.message, does NOT advance step
|
||||||
|
```
|
||||||
|
|
||||||
|
## Signup step detail
|
||||||
|
|
||||||
|
| Step | Action | Server fn | Writes | Returns |
|
||||||
|
| ---- | ------------ | ------------- | --------------------- | ---------------------------- |
|
||||||
|
| 1 | `?/signup` | `signupAdmin` | fams + users | cookie set, `{success:true}` |
|
||||||
|
| 2 | `?/username` | `setUsername` | users.name | `{success:true, username}` |
|
||||||
|
| 3 | `?/child` | `createChild` | members (name, famId) | `{success:true, code}` |
|
||||||
|
|
||||||
|
## Notes / caveats in the current code
|
||||||
|
|
||||||
|
- `users` collection has **no `role`/`username`** fields yet — `setUsername` writes to
|
||||||
|
`name`, and `role` isn't persisted (hooks reads it as `undefined`). Add `role` +
|
||||||
|
`username` to `users` if you need role-based guards (`/admin` relies on `role==='admin'`).
|
||||||
|
- `createChild` writes a `members` record with `inviteCode`, but `members` has no
|
||||||
|
`inviteCode` field (it's dropped). The member is created without a `users` auth record,
|
||||||
|
so it can't log in yet — see "Extending — Child / device accounts (Option C)" in the
|
||||||
|
root `readme.md`.
|
||||||
|
- `fams.createRule` is `null` (admin-only), `users.createRule` is `""` (public); signup
|
||||||
|
uses a superuser client, so both work regardless.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
import AuthShell from '$lib/layouts/AuthShell.svelte';
|
||||||
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|
||||||
|
let step1 = $state({ familyName: '', email: '', password: '' });
|
||||||
|
let step2 = $state({ username: '' });
|
||||||
|
let step3 = $state({ child: '' });
|
||||||
|
let submitting = $state(false);
|
||||||
|
let step = $state(1);
|
||||||
|
|
||||||
|
const enhanceForm = () => {
|
||||||
|
return async ({ update, result }: { update: () => Promise<void> }) => {
|
||||||
|
submitting = true;
|
||||||
|
await update();
|
||||||
|
submitting = false;
|
||||||
|
if (result.type !== 'failure') {
|
||||||
|
step++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AuthShell title="Signup" subtitle="Create your family.">
|
||||||
|
{#if form?.message}
|
||||||
|
<h3 class="form-error">{form.message}</h3>
|
||||||
|
{/if}
|
||||||
|
{#if step === 1}
|
||||||
|
<form method="POST" action="?/signup" use:enhance={enhanceForm}>
|
||||||
|
<label>
|
||||||
|
Family Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="familyName"
|
||||||
|
bind:value={step1.familyName}
|
||||||
|
placeholder="Family Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
bind:value={step1.email}
|
||||||
|
placeholder="you@email.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" name="password" bind:value={step1.password} required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Sign up</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="alt">
|
||||||
|
Don't have a family yet? <a href="/signup">Create one</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if step === 2}
|
||||||
|
<h3 class="form-error">Step 2</h3>
|
||||||
|
<p>Now for something more personal:</p>
|
||||||
|
<form method="POST" action="?/username" use:enhance={enhanceForm}>
|
||||||
|
<label
|
||||||
|
>Username
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
bind:value={step2.username}
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Next</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if step === 3}
|
||||||
|
<h3 class="form-error">Step 3</h3>
|
||||||
|
<p>Would you like to add a child device now?</p>
|
||||||
|
<form method="POST" action="?/child" use:enhance={enhanceForm}>
|
||||||
|
<label>
|
||||||
|
Add childs name:
|
||||||
|
<input type="text" name="member" bind:value={step3.child} />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Next</button>
|
||||||
|
</form>
|
||||||
|
<p>Or skip straight to admin</p>
|
||||||
|
<a href="/admin">admin dashboard</a>
|
||||||
|
{/if}
|
||||||
|
{#if step === 4}
|
||||||
|
<h3 class="form-error">Step 4</h3>
|
||||||
|
<p>Nice - now share this device login code with {step3.child}:</p>
|
||||||
|
<div class="code">
|
||||||
|
<span class="code-text">{form.code}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</AuthShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4338ca;
|
||||||
|
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #3730a3;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.alt {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.alt a {
|
||||||
|
color: #4338ca;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user