migrate auth v2 code
This commit is contained in:
@@ -1,35 +1,94 @@
|
||||
import { fail, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
import { createPbClient } from '$lib/server/pocketbase';
|
||||
import { setSessionCookie } from '$lib/server/session';
|
||||
import { issueAccess } from '$lib/server/member-otp';
|
||||
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
|
||||
|
||||
class SignupError extends Error {}
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
// Step 1 — create the family + parent (admin) user, mint their session.
|
||||
// The parent's human-entered name is kept as the display `name`; their PB
|
||||
// `username` is `{famSlug}:{handle}` (globally unique, handle = no whitespace).
|
||||
signup: async (event) => {
|
||||
const fd = await event.request.formData();
|
||||
const famName = fd.get('familyName') as string;
|
||||
const yourName = (fd.get('yourName') as string) || '';
|
||||
const email = fd.get('email') as string;
|
||||
const password = fd.get('password') as string;
|
||||
const famName = fd.get('famName') as string;
|
||||
const parentName = fd.get('parentName') as string;
|
||||
|
||||
if (!email || !password || !famName) {
|
||||
return fail(400, { error: 'All fields required', email, famName });
|
||||
if (!famName || !yourName || !email || !password) {
|
||||
return fail(400, { message: 'All fields required', famName, email });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return fail(400, { error: 'Password must be at least 8 characters', email, famName });
|
||||
return fail(400, { message: 'Password must be at least 8 characters', famName, email });
|
||||
}
|
||||
|
||||
const parentName = yourName.trim();
|
||||
const slug = slugify(famName);
|
||||
const handleName = handle(parentName) || 'admin';
|
||||
const username = famUsername(slug, handleName);
|
||||
|
||||
try {
|
||||
const result = await signup(email, password, famName, parentName || email.split('@')[0]);
|
||||
setSessionCookie(event, {
|
||||
famId: result.famId,
|
||||
userId: result.userId,
|
||||
famSlug: result.famSlug,
|
||||
memberName: result.memberName,
|
||||
role: result.role,
|
||||
const fam = await pbAdmin.create('fams', {
|
||||
name: famName,
|
||||
slug,
|
||||
timezone: 'auto'
|
||||
});
|
||||
setPbTokenCookie(event, result.token);
|
||||
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
|
||||
const user = await pbAdmin.create('users', {
|
||||
username,
|
||||
name: parentName,
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: false,
|
||||
famId: fam.id,
|
||||
role: 'parent'
|
||||
});
|
||||
await pbAdmin.create('settings', { famId: fam.id });
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName });
|
||||
throw new SignupError(
|
||||
`Could not create account — ${e instanceof Error ? e.message : 'please try again'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Auth as the new parent to mint their JWT, then move to the child step.
|
||||
const authResult = await createPbClient()
|
||||
.collection('users')
|
||||
.authWithPassword(email, password)
|
||||
.catch(() => null);
|
||||
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
|
||||
|
||||
// `username` here is the handle (URL segment), not the composite.
|
||||
return { success: true, famSlug: slug, username: handleName };
|
||||
},
|
||||
|
||||
// Step 2 — optionally add a child now; issues their OTP join code.
|
||||
child: async (event: RequestEvent) => {
|
||||
const user = requireUser(event);
|
||||
const fd = await event.request.formData();
|
||||
const name = ((fd.get('member') as string) || '').trim();
|
||||
|
||||
const fam = await pbAdmin.getOne('fams', user.famId);
|
||||
const famSlug = fam?.slug || user.famId;
|
||||
|
||||
if (!name) {
|
||||
return { success: true, famSlug, username: handleOf(user.username) };
|
||||
}
|
||||
|
||||
const { otp, joinUrl } = await issueAccess({
|
||||
famId: user.famId,
|
||||
famSlug,
|
||||
name
|
||||
});
|
||||
|
||||
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username) };
|
||||
}
|
||||
};
|
||||
|
||||
function requireUser(event: RequestEvent) {
|
||||
if (!event.locals.user) throw redirect(303, '/signup');
|
||||
return event.locals.user;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import AuthShell from '$lib/components/AuthShell.svelte';
|
||||
import { slugify, handle } from '@shared/slugify';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let step = $state(1);
|
||||
let famName = $state('');
|
||||
let yourName = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let famName = $state('');
|
||||
let parentName = $state('');
|
||||
let childName = $state('');
|
||||
let submitting = $state(false);
|
||||
let localError = $state('');
|
||||
|
||||
let famSlugPreview = $derived(slugify(famName) || 'your-family');
|
||||
let handlePreview = $derived(handle(yourName) || 'your-name');
|
||||
|
||||
const enhanceForm = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- canary $types lacks SubmitFunction
|
||||
return () =>
|
||||
async ({ update, result }: any) => {
|
||||
submitting = true;
|
||||
localError = '';
|
||||
try {
|
||||
await update();
|
||||
} catch (e) {
|
||||
localError = e instanceof Error ? e.message : 'Something went wrong. Please try again.';
|
||||
}
|
||||
submitting = false;
|
||||
if (result.type !== 'failure' && result.type !== 'error') step++;
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
|
||||
{#if form?.error}
|
||||
<p class="form-error">{form.error}</p>
|
||||
{#if form?.message}
|
||||
<p class="form-error">{form.message}</p>
|
||||
{/if}
|
||||
{#if localError}
|
||||
<p class="form-error">{localError}</p>
|
||||
{/if}
|
||||
|
||||
<form method="POST">
|
||||
<label>
|
||||
Family name
|
||||
<input name="famName" bind:value={famName} placeholder="The Smiths" required />
|
||||
</label>
|
||||
<label>
|
||||
Your name
|
||||
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
bind:value={password}
|
||||
placeholder="8+ characters"
|
||||
minlength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Create my family</button>
|
||||
</form>
|
||||
{#if step === 1}
|
||||
<form method="POST" action="?/signup" use:enhance={enhanceForm()}>
|
||||
<label>
|
||||
Family name
|
||||
<input name="familyName" bind:value={famName} placeholder="The Smiths" required />
|
||||
{#if famName}
|
||||
<span class="preview">Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code></span>
|
||||
{/if}
|
||||
</label>
|
||||
<label>
|
||||
Your name
|
||||
<input name="yourName" bind:value={yourName} placeholder="Mum / Dad" required />
|
||||
{#if yourName}
|
||||
<span class="preview">
|
||||
Your address: <code>/</code><code class="inline-code">{famSlugPreview}/{handlePreview}</code>
|
||||
<small class="preview-hint">(no spaces — {yourName.trim()} → {handlePreview})</small>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="preview-hint">No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span>
|
||||
{/if}
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
bind:value={password}
|
||||
placeholder="8+ characters"
|
||||
minlength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={submitting}>Create my family</button>
|
||||
</form>
|
||||
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
|
||||
{/if}
|
||||
|
||||
<p class="alt">
|
||||
Already have a family? <a href="/login">Log in</a>
|
||||
</p>
|
||||
{#if step === 2}
|
||||
<h3 class="step-title">Add a child now?</h3>
|
||||
<p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
|
||||
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
|
||||
<label>
|
||||
Child's name
|
||||
<input
|
||||
type="text"
|
||||
name="member"
|
||||
bind:value={childName}
|
||||
placeholder="Their first name"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={submitting}>Create join code</button>
|
||||
</form>
|
||||
<p class="alt">
|
||||
<a href="/{form?.famSlug}">Skip for now →</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if step === 3}
|
||||
<h3 class="step-title">{childName ? `Nice — share this code with ${childName}:` : 'Your family is ready!'}</h3>
|
||||
{#if form?.code}
|
||||
<div class="code">
|
||||
<span class="code-text">{form.code}</span>
|
||||
</div>
|
||||
<p class="step-note">
|
||||
They open <code class="inline-code">{form?.joinUrl}</code> and enter this code.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<a href="/{form?.famSlug}" class="btn-primary">Go to dashboard</a>
|
||||
</div>
|
||||
{/if}
|
||||
</AuthShell>
|
||||
|
||||
<style>
|
||||
@@ -81,6 +157,17 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #3730a3; }
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
.step-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: #111827;
|
||||
}
|
||||
.step-note {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.form-error {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
@@ -90,6 +177,23 @@
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.preview {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
.preview-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
.preview .inline-code, .preview-hint .inline-code {
|
||||
font-family: ui-monospace, monospace;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
padding: 0.05em 0.3em;
|
||||
color: #374151;
|
||||
}
|
||||
.alt {
|
||||
margin: 1.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -97,4 +201,42 @@
|
||||
text-align: center;
|
||||
}
|
||||
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
||||
</style>
|
||||
.code {
|
||||
background: #eef2ff;
|
||||
border: 1px dashed #a5b4fc;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.code-text {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 0.35em;
|
||||
font-weight: 700;
|
||||
color: #4338ca;
|
||||
}
|
||||
.inline-code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85em;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
color: #374151;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.btn-primary {
|
||||
display: block;
|
||||
text-align: center;
|
||||
background: #4338ca;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary:hover { background: #3730a3; }
|
||||
</style>
|
||||
@@ -1,159 +0,0 @@
|
||||
<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