migrate auth v2 code

This commit is contained in:
JCEEE
2026-08-16 10:11:39 +01:00
parent 3dd94b8a7c
commit c73ced7894
45 changed files with 1965 additions and 1519 deletions
@@ -4,20 +4,20 @@
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { COMMON_TIMEZONES } from '@shared/timezone';
import { handleOf } from '@shared/slugify';
import QRCode from 'qrcode';
let { data } = $props();
// fam is sensitive (inviteCode, stripeCustomerId, featureFlags) — never in the
// fam is sensitive (stripeCustomerId, featureFlags) — never in the
// public famStore stream. It is superadmin-only, fetched server-side by the
// layout load. Writes go through form actions; no live fam subscription.
let fam = $state(data.fam);
let famSlug = $state(page.params.fam);
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`);
let addName = $state('');
let rename = $state('');
let selectedMember = $state('');
let inviteChild = $state('');
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
let paydayTime = $state(fam?.paydayTime || '18:00');
let paydayTimes = $state([
@@ -56,12 +56,16 @@
let copied = $state(false);
let parentInviteEmail = $state('');
let selectedInviteUrl = $derived(
selectedMember ? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}` : childInviteUrl
);
let members = $state(famStore.initialized ? famStore.members : data.members || []);
let deletingSeason = $state<any>(null);
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let invitePath = $derived(
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
);
let inviteUrl = $derived(
issued ? `${page.url.origin}${invitePath}` : ''
);
function copy(url: string) {
navigator.clipboard.writeText(url);
@@ -73,6 +77,15 @@
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
}
function toggleQR() {
showQR = !showQR;
if (!showQR) {
qrDataUrl = '';
} else {
generateQR(inviteUrl);
}
}
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
}
@@ -82,37 +95,60 @@
<CardGrid>
<Card title="Family Name">
<p class="hint">
This is the name shown to your family. The address stays at
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working.
</p>
<form method="POST" action="?/renameFam" use:enhance>
<input name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<label class="field-label" for="fam-name">Display name</label>
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</Card>
<Card title="Members ({members.length})" cols={2}>
<form method="POST" action="?/addMember" use:enhance>
<input name="name" bind:value={addName} placeholder="Member name" required />
<Button type="submit" size="sm">Add</Button>
</form>
<div class="members-grid">
<div class="members-add">
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label>
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
<Button type="submit" size="sm">Add child</Button>
</form>
</div>
<ul>
{#each members as m}
<li>
<span class="dot" style="background:{m.color}"></span>
{m.name}
{m.deviceToken ? '' : ' (pending join)'}
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
type="submit"
variant="danger"
size="sm"
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</li>
{/each}
</ul>
<ul class="members-list">
{#each members as m}
<li>
<span class="member-left">
<span class="member-color" style="background:{m.color}"></span>
<span class="member-info">
<span class="member-name">{m.name}</span>
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
type="submit"
variant="danger"
size="sm"
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card>
<Card title="Payday" cols={1}>
@@ -166,54 +202,70 @@
</Card>
<Card title="Invite Children" cols={1}>
<p class="code">{fam?.inviteCode || '...'}</p>
<div class="invite-row">
<label>Member:</label>
<select bind:value={selectedMember}>
<option value="">— General link —</option>
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type === 'success' && result.data?.ok) {
showQR = false;
qrDataUrl = '';
issued = {
otp: result.data.otp,
joinUrl: result.data.joinUrl,
name: String(formData.get('name') || '')
};
} else if (result.type === 'success' && result.data?.error) {
alert(result.data.error);
}
};
}}
class="invite-form"
>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
</div>
<p class="hint">Re-joining a member will disconnect their old device.</p>
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
</form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
<p class="invite-url">{selectedInviteUrl}</p>
<div class="actions">
<Button onclick={() => copy(selectedInviteUrl)} size="sm">
{copied ? 'Copied!' : 'Copy link'}
</Button>
<Button
onclick={async () => {
showQR = !showQR;
if (!showQR) {
qrDataUrl = '';
} else {
await generateQR(selectedInviteUrl);
}
}}
size="sm"
>
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
<form method="POST" action="?/regenInvite" use:enhance class="inline">
<Button type="submit" size="sm">Renew code</Button>
</form>
</div>
{#if showQR && qrDataUrl}
<img src={qrDataUrl} alt="QR Code" class="qr" />
{#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<p class="text-xs text-slate-500">
Code for {issued.name} (valid 20 min):
</p>
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
{issued.otp}
</p>
<p class="invite-url">{invitePath}</p>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
{copied ? 'Copied!' : 'Copy URL'}
</Button>
<Button variant="secondary" size="sm" onclick={toggleQR}>
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
</div>
{#if showQR && qrDataUrl}
<div class="qr-wrap">
<img src={qrDataUrl} alt="QR Code" class="qr" />
</div>
{/if}
</div>
{/if}
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-row">
<input type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<div class="invite-form">
<label class="field-label" for="parent-email">Parent email</label>
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
@@ -222,9 +274,13 @@
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="inline">
<input name="name" placeholder="Season name" required />
<input name="color" type="color" value="#6366f1" class="color-input" />
<form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row">
<label for="season-color">Colour</label>
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
</div>
<Button type="submit" size="sm">Add</Button>
</form>
@@ -310,54 +366,74 @@
</CardGrid>
<style>
.section {
margin: 0.5rem 0;
}
.code {
font-family: monospace;
font-size: 1.2rem;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
display: inline-block;
}
.invite-url {
font-family: monospace;
font-size: 0.9rem;
word-break: break-all;
background: #f9fafb;
padding: 0.4rem;
border-radius: 4px;
}
.invite-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.5rem 0;
}
.invite-row select {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
flex: 1;
}
.invite-row input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
flex: 1;
}
.hint {
font-size: 0.85rem;
color: #9ca3af;
line-height: 1.4;
}
.payday-form {
.invite-url {
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 0.6rem;
border-radius: 8px;
}
/* ── Forms: full-width, balanced fields ── */
form {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.6rem;
margin-bottom: 1rem;
}
.payday-form button[type='submit'] {
align-self: flex-start;
form.inline {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
}
input,
select {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
font-size: 0.9rem;
}
input:focus,
select:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
border-color: #6366f1;
}
/* Buttons are rendered by <Button> (scoped in its own component) — reach
them with :global so primary CTAs stretch to full width. */
form :global(.btn) {
width: 100%;
}
form.inline :global(.btn) {
width: auto;
}
.invite-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.invite-form :global(.btn) {
width: 100%;
}
/* ── Payday ── */
.payday-row {
display: flex;
gap: 0.5rem;
@@ -373,31 +449,51 @@
font-size: 0.8rem;
color: #6b7280;
flex-shrink: 0;
font-weight: 500;
}
.payday-row + .payday-row {
margin-top: 0.5rem;
}
.actions {
/* ── Colour rows (seasons) ── */
.color-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.qr {
margin-top: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
.color-row label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
flex-shrink: 0;
}
.color-input {
width: 100%;
max-width: 160px;
height: 36px;
padding: 2px;
border: 1px solid #d1d5db;
border-radius: 8px;
cursor: pointer;
}
/* ── Lists ── */
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
padding: 0.3rem 0;
padding: 0.45rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
}
li:last-child {
border-bottom: none;
}
.dot {
display: inline-block;
@@ -410,18 +506,121 @@
font-size: 0.85rem;
color: #6366f1;
}
form {
.slug-inline {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.85em;
background: #f3f4f6;
border-radius: 4px;
padding: 0.1em 0.35em;
color: #374151;
}
.slug-line {
margin-top: 0.75rem;
}
/* ── Members: two-column (add | list) ── */
.members-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.members-add form {
margin-bottom: 0;
}
.members-list {
border-left: 1px solid #f3f4f6;
padding-left: 1.25rem;
}
.member-left,
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-color {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.member-name {
font-weight: 500;
color: #374151;
}
.member-info {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.member-handle {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72rem;
color: #9ca3af;
}
.member-actions :global(.btn) {
flex: none;
width: auto;
}
@container (max-width: 380px) {
.members-grid {
grid-template-columns: 1fr;
}
.members-list {
border-left: none;
padding-left: 0;
border-top: 1px solid #f3f4f6;
padding-top: 0.5rem;
}
}
/* ── Action rows ── */
.actions {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
input,
select,
button {
padding: 0.4rem 0.7rem;
border: 1px solid #ccc;
border-radius: 4px;
.actions.justify-center {
justify-content: center;
}
.actions :global(.btn) {
flex: 1;
}
.qr-wrap {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.qr {
border: 1px solid #e5e7eb;
border-radius: 8px;
}
/* ── Narrow cards: stack rows / actions full width (container query) ── */
@container (max-width: 380px) {
.payday-row {
flex-direction: column;
align-items: stretch;
}
.payday-row select {
width: 100%;
}
.color-row {
align-items: stretch;
}
.actions {
flex-direction: column;
}
.actions :global(.btn) {
flex: none;
width: 100%;
}
}
/* ── Overlay / modal (plain buttons live in this template) ── */
button {
background: #6366f1;
color: white;
@@ -437,18 +636,6 @@
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
}
.inline {
display: inline;
margin: 0;
}
.color-input {
width: 40px;
height: 34px;
padding: 0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.overlay {
position: fixed;
inset: 0;
@@ -462,8 +649,9 @@
background: white;
border-radius: 12px;
padding: 1.5rem;
min-width: 320px;
width: 100%;
max-width: 440px;
margin: 0 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.modal h3 {