migrate auth v2 code
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { PROXY_URL } from '$app/env/public';
|
||||
import { pbAdmin } from '$lib/server/pb-admin';
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
|
||||
const HONO_URL = PROXY_URL;
|
||||
|
||||
@@ -20,19 +20,17 @@ async function paydayCheck(famId: string, headers: Record<string, string>) {
|
||||
async function resolveChatIdentity(
|
||||
api: 'admin' | 'member',
|
||||
opts: {
|
||||
session?: { famId: string; userId: string };
|
||||
deviceToken?: string;
|
||||
famId?: string;
|
||||
session?: { famId: string; id: string };
|
||||
pbToken?: string;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (api === 'admin' && opts.session) {
|
||||
headers['x-session-famid'] = opts.session.famId;
|
||||
headers['x-session-userid'] = opts.session.userId;
|
||||
} else if (api === 'member' && opts.deviceToken && opts.famId) {
|
||||
headers['x-device-token'] = opts.deviceToken;
|
||||
headers['x-device-famid'] = opts.famId;
|
||||
headers['x-session-userid'] = opts.session.id;
|
||||
} else if (api === 'member' && opts.pbToken) {
|
||||
headers['Authorization'] = `Bearer ${opts.pbToken}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -45,46 +43,43 @@ async function resolveChatIdentity(
|
||||
}
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session || null;
|
||||
const isParent = session !== null;
|
||||
const deviceToken = event.cookies.get('device_token') || '';
|
||||
const session = event.locals.user;
|
||||
const role = session?.role || 'child';
|
||||
const isParent = role === 'parent';
|
||||
const pbToken = event.cookies.get('pb_token') || '';
|
||||
|
||||
let famId = '';
|
||||
let chat: { famId: string; actor: any } | null = null;
|
||||
|
||||
if (isParent) {
|
||||
if (session && isParent) {
|
||||
famId = session.famId;
|
||||
await paydayCheck(famId, {
|
||||
'x-session-famid': session.famId,
|
||||
'x-session-userid': session.userId
|
||||
'x-session-userid': session.id
|
||||
});
|
||||
chat = await resolveChatIdentity('admin', { session });
|
||||
} else if (deviceToken) {
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
|
||||
headers: { 'x-device-token': deviceToken }
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
famId = body.famId || '';
|
||||
}
|
||||
} catch {}
|
||||
if (famId) {
|
||||
await paydayCheck(famId, {
|
||||
'x-device-token': deviceToken,
|
||||
'x-device-famid': famId
|
||||
});
|
||||
chat = await resolveChatIdentity('member', { deviceToken, famId });
|
||||
}
|
||||
} else if (role === 'child' && pbToken && session) {
|
||||
famId = session.famId;
|
||||
await paydayCheck(famId, { Authorization: `Bearer ${pbToken}` });
|
||||
chat = await resolveChatIdentity('member', { pbToken });
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
session: session
|
||||
? {
|
||||
famId: session.famId,
|
||||
userId: session.id,
|
||||
famSlug: event.params.fam,
|
||||
memberName: session.name,
|
||||
memberColor: session.color || '',
|
||||
role: session.role
|
||||
}
|
||||
: null,
|
||||
isParent,
|
||||
role: session?.role || 'child',
|
||||
role,
|
||||
famId,
|
||||
chat,
|
||||
deviceToken,
|
||||
pbToken,
|
||||
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
|
||||
fam: famId
|
||||
? await pbAdmin.getOne('fams', famId).catch(() => null)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { initPbFromCookie } from '$lib/pocketbase';
|
||||
import { initPb } from '$lib/pocketbase';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
||||
@@ -45,7 +45,7 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
initPbFromCookie();
|
||||
initPb(page.data.pbToken || '');
|
||||
if (page.data.famId) famStore.init(page.data.famId, page.data.fam);
|
||||
const chat = page.data.chat;
|
||||
if (chat?.famId && chat?.actor) {
|
||||
@@ -55,7 +55,7 @@
|
||||
actorType: chat.actor.type,
|
||||
actorName: chat.actor.name,
|
||||
actorColor: chat.actor.color || '#6366f1',
|
||||
deviceToken: page.data.deviceToken || ''
|
||||
pbToken: page.data.pbToken || ''
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hono } from '$lib/server/hono';
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session;
|
||||
const session = event.locals.user;
|
||||
if (!session) return {};
|
||||
|
||||
const famId = session.famId;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||
import { formatDDMMYY } from '$lib/format';
|
||||
import { handleOf } from '@shared/slugify';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@@ -138,7 +139,7 @@
|
||||
<span class="dot" style="background:{s.memberColor}"></span>
|
||||
<span class="member-name">{s.memberName}</span>
|
||||
{#if m}
|
||||
<a href="/{famSlug}/{m.name}" class="kanban-link">Kanban →</a>
|
||||
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="donut-wrap">
|
||||
|
||||
@@ -5,15 +5,17 @@ import { PROXY_URL } from '$app/env/public';
|
||||
const HONO_URL = PROXY_URL;
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session;
|
||||
const session = event.locals.user;
|
||||
|
||||
// Parent (session auth) → admin overview
|
||||
if (session) {
|
||||
// Parent (role=parent, session auth) → admin overview.
|
||||
// Branch on role, NOT presence: a child who OTP-logged-in also has a
|
||||
// `users` session (locals.user) and must not hit the parent path.
|
||||
if (session && session.role === 'parent') {
|
||||
const famId = session.famId;
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
if (session.memberName && session.memberName !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.memberName}`);
|
||||
if (session.name && session.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||
}
|
||||
const [
|
||||
members,
|
||||
@@ -51,70 +53,55 @@ export async function load(event) {
|
||||
};
|
||||
}
|
||||
|
||||
// Child (device token) → kanban
|
||||
const deviceToken =
|
||||
event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
||||
// Child (users auth, role=child) → kanban
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
|
||||
if (!deviceToken) {
|
||||
return {
|
||||
role: 'child',
|
||||
token: '',
|
||||
memberId: '',
|
||||
verified: false,
|
||||
famId: '',
|
||||
templates: [],
|
||||
assigned: [],
|
||||
completions: [],
|
||||
rewards: [],
|
||||
bonusConfigs: [],
|
||||
tallies: {}
|
||||
};
|
||||
// A child should always land on their own kanban route.
|
||||
if (session?.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||
}
|
||||
|
||||
const pbToken = event.cookies.get('pb_token') || '';
|
||||
const famId = session?.famId || '';
|
||||
const childId = session?.id || '';
|
||||
|
||||
const empty = {
|
||||
role: 'child' as const,
|
||||
token: pbToken,
|
||||
memberId: childId,
|
||||
famId,
|
||||
verified: false,
|
||||
memberName: session?.name || '',
|
||||
memberColor: session?.color || '',
|
||||
templates: [] as never[],
|
||||
assigned: [] as never[],
|
||||
completions: [] as never[],
|
||||
rewards: [] as never[],
|
||||
bonusConfigs: [] as never[],
|
||||
tallies: {} as Record<string, unknown>,
|
||||
payday: 1,
|
||||
paydayTime: '18:00',
|
||||
timezone: 'auto'
|
||||
};
|
||||
|
||||
if (!pbToken || !famId) return empty;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
||||
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceToken, famSlug })
|
||||
headers: { Authorization: `Bearer ${pbToken}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.name !== username) {
|
||||
return {
|
||||
role: 'child',
|
||||
token: deviceToken,
|
||||
memberId: '',
|
||||
verified: false,
|
||||
famId: '',
|
||||
memberName: '',
|
||||
memberColor: '',
|
||||
templates: [],
|
||||
assigned: [],
|
||||
completions: [],
|
||||
rewards: [],
|
||||
bonusConfigs: [],
|
||||
tallies: {}
|
||||
};
|
||||
}
|
||||
|
||||
let chores: any = {};
|
||||
try {
|
||||
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-device-token': deviceToken, 'x-device-famid': data.famId }
|
||||
});
|
||||
chores = await choresRes.json();
|
||||
} catch {}
|
||||
|
||||
if (!choresRes.ok) return empty;
|
||||
const chores = await choresRes.json();
|
||||
return {
|
||||
role: 'child',
|
||||
token: deviceToken,
|
||||
memberId: data.memberId,
|
||||
famId: data.famId,
|
||||
token: pbToken,
|
||||
memberId: childId,
|
||||
famId,
|
||||
verified: true,
|
||||
memberName: data.name,
|
||||
memberColor: data.color,
|
||||
memberName: session?.name || '',
|
||||
memberColor: session?.color || '',
|
||||
templates: chores.templates || [],
|
||||
assigned: chores.assigned || [],
|
||||
completions: chores.completions || [],
|
||||
@@ -127,29 +114,14 @@ export async function load(event) {
|
||||
simulateEow: !!chores.simulateEow
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
role: 'child',
|
||||
token: deviceToken,
|
||||
memberId: '',
|
||||
verified: false,
|
||||
famId: '',
|
||||
templates: [],
|
||||
assigned: [],
|
||||
completions: [],
|
||||
rewards: [],
|
||||
bonusConfigs: [],
|
||||
tallies: {},
|
||||
payday: 1,
|
||||
paydayTime: '18:00',
|
||||
timezone: 'auto'
|
||||
};
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
setEow: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const on = fd.get('on') === 'true';
|
||||
try {
|
||||
@@ -161,8 +133,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
previewEow: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
try {
|
||||
const preview = await hono.admin.eowPreview(event, famId);
|
||||
await hono.admin.updateSettings(event, famId, { simulateEow: true });
|
||||
@@ -173,8 +145,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
claim: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const rewardId = fd.get('id') as string;
|
||||
try {
|
||||
@@ -186,8 +158,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
issueAll: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const memberId = fd.get('memberId') as string;
|
||||
try {
|
||||
@@ -199,8 +171,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
revoke: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const completionId = fd.get('id') as string;
|
||||
try {
|
||||
@@ -212,8 +184,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
trigger: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const configId = fd.get('configId') as string;
|
||||
const memberId = fd.get('memberId') as string;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||
import { handleOf } from '@shared/slugify';
|
||||
import {
|
||||
weekStart as tzWeekStart,
|
||||
addDaysStr,
|
||||
@@ -149,7 +150,7 @@
|
||||
|
||||
// ─── Child View (kanban) ───
|
||||
let memberId = $state(data.memberId || '');
|
||||
let deviceToken = $state(data.token || '');
|
||||
let pbToken = $state(data.token || '');
|
||||
let famId = $state(data.famId || '');
|
||||
let memberName = $state(data.memberName || '');
|
||||
let memberColor = $state(data.memberColor || '#6366f1');
|
||||
@@ -209,9 +210,9 @@
|
||||
$effect(() => {
|
||||
if (role !== 'child' || !isPaydayToday) return;
|
||||
if (secondsLeft > 0 || eowFired) return;
|
||||
if (!deviceToken || !famId) return;
|
||||
if (!pbToken || !famId) return;
|
||||
eowFired = true;
|
||||
memberApi.payday(deviceToken, famId).catch(() => {});
|
||||
memberApi.payday(pbToken, famId).catch(() => {});
|
||||
});
|
||||
|
||||
function paydayWeekStart(): string {
|
||||
@@ -476,26 +477,13 @@
|
||||
onMount(async () => {
|
||||
if (role === 'parent') return;
|
||||
|
||||
if (!deviceToken) {
|
||||
deviceToken = localStorage.getItem('deviceToken') || '';
|
||||
} else {
|
||||
localStorage.setItem('deviceToken', deviceToken);
|
||||
}
|
||||
|
||||
if (!deviceToken) {
|
||||
error = 'No device token found. Use the link from your invite.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.verified && data.famId && data.memberId) {
|
||||
memberId = data.memberId;
|
||||
famId = data.famId;
|
||||
loading = false;
|
||||
} else {
|
||||
error = 'Invalid device token. Use the link from your invite.';
|
||||
error = 'Please open your invite link to get access.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -558,7 +546,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild);
|
||||
await memberApi.toggleCompletion(pbToken, famId, chore.id, todayChild);
|
||||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||||
if (optimistic) {
|
||||
famStore.applyRecord('completions', optimistic, 'delete');
|
||||
@@ -644,7 +632,7 @@
|
||||
<div class="card-header">
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
<span class="member-name">{m.name}</span>
|
||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
||||
<a href="/{famSlug}/{handleOf(m.username)}" class="link">Kanban</a>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||||
@@ -976,8 +964,7 @@
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-device-token': deviceToken,
|
||||
'x-device-famid': famId
|
||||
Authorization: `Bearer ${pbToken}`
|
||||
},
|
||||
body: JSON.stringify({ name: nameInput })
|
||||
});
|
||||
@@ -1039,8 +1026,7 @@
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-device-token': deviceToken,
|
||||
'x-device-famid': famId
|
||||
Authorization: `Bearer ${pbToken}`
|
||||
},
|
||||
body: JSON.stringify({ color })
|
||||
});
|
||||
@@ -1267,7 +1253,7 @@
|
||||
class="wr-cta"
|
||||
onclick={async () => {
|
||||
try {
|
||||
await memberApi.claimReward(deviceToken, famId, r.id);
|
||||
await memberApi.claimReward(pbToken, famId, r.id);
|
||||
} catch (e) {
|
||||
claimError = e instanceof Error ? e.message : 'Claim failed';
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { fail, redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const [configs, templates, members, progress, rewards] = await Promise.all([
|
||||
hono.admin.bonusConfigs(event, famId),
|
||||
hono.admin.list(event, 'bonus-templates', famId),
|
||||
@@ -16,8 +16,8 @@ export async function load(event) {
|
||||
|
||||
export const actions = {
|
||||
createTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const data: Record<string, unknown> = {
|
||||
name: fd.get('name'),
|
||||
@@ -42,8 +42,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
updateTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const data: Record<string, unknown> = {};
|
||||
@@ -75,8 +75,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
deleteTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
@@ -88,8 +88,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
createConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const startMode = fd.get('startMode') as string;
|
||||
const status = startMode === 'disabled' ? 'disabled' : 'active';
|
||||
@@ -120,8 +120,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
updateConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const data: Record<string, unknown> = {};
|
||||
@@ -155,8 +155,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
deleteConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
@@ -168,8 +168,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
createFromTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const startMode = fd.get('startMode') as string;
|
||||
const status = startMode === 'disabled' ? 'disabled' : 'active';
|
||||
@@ -198,8 +198,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
assignConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const target = fd.get('target') as string;
|
||||
@@ -216,8 +216,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
completeConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
@@ -229,8 +229,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
destroyConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
@@ -242,8 +242,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
toggleConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const currentStatus = fd.get('currentStatus') as string;
|
||||
@@ -259,8 +259,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
evaluate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
try {
|
||||
await hono.admin.evaluateBonusConfig(event, famId);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { pbUser } from '$lib/server/pocketbase';
|
||||
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const pb = pbUser(event);
|
||||
const [templates, members, assigned, seasons, completions] = await Promise.all([
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
hono.admin.list(event, 'seasons', famId),
|
||||
hono.admin.completions(event, famId),
|
||||
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
||||
pb.collection('users').getFullList({
|
||||
filter: `famId = '${famId}' && role = 'child'`
|
||||
}),
|
||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }),
|
||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
||||
]);
|
||||
return { templates, members, assigned, seasons, completions };
|
||||
return {
|
||||
templates: templates as unknown as ChoreTemplate[],
|
||||
members: members as unknown as Member[],
|
||||
assigned: assigned as unknown as AssignedChore[],
|
||||
seasons: seasons as unknown as Season[],
|
||||
completions: completions as unknown as Completion[]
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
createTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const data = {
|
||||
name: fd.get('name'),
|
||||
defaultFrequency: fd.get('defaultFrequency'),
|
||||
defaultType: fd.get('defaultType'),
|
||||
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0,
|
||||
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0
|
||||
};
|
||||
if (!data.name || !data.defaultFrequency || !data.defaultType) {
|
||||
return fail(400, { error: 'Name, frequency, and type are required' });
|
||||
}
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'chore-templates', famId, data);
|
||||
const record = await pbUser(event).collection('chore_templates').create({ famId, ...data });
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
||||
@@ -37,8 +47,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
updateTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const defaultFrequency = fd.get('defaultFrequency') as string;
|
||||
@@ -48,20 +58,25 @@ export const actions = {
|
||||
name: fd.get('name'),
|
||||
defaultFrequency,
|
||||
defaultType,
|
||||
defaultValue,
|
||||
defaultValue
|
||||
};
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'chore-templates', famId, id, data);
|
||||
const allAssigned = await hono.admin.list(event, 'assigned-chores', famId);
|
||||
const pb = pbUser(event);
|
||||
const record = await pb.collection('chore_templates').update(id, data);
|
||||
const allAssigned = await pb
|
||||
.collection('assigned_chores')
|
||||
.getFullList({ filter: `famId = '${famId}'` });
|
||||
if (Array.isArray(allAssigned)) {
|
||||
const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
|
||||
await Promise.all(toUpdate.map((a: any) =>
|
||||
hono.admin.update(event, 'assigned-chores', famId, a.id, {
|
||||
frequency: defaultFrequency,
|
||||
type: defaultType,
|
||||
value: defaultValue,
|
||||
})
|
||||
));
|
||||
await Promise.all(
|
||||
toUpdate.map((a: any) =>
|
||||
pb.collection('assigned_chores').update(a.id, {
|
||||
frequency: defaultFrequency,
|
||||
type: defaultType,
|
||||
value: defaultValue
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
return { record };
|
||||
} catch (e) {
|
||||
@@ -70,21 +85,21 @@ export const actions = {
|
||||
},
|
||||
|
||||
deleteTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
const record = await hono.admin.remove(event, 'chore-templates', famId, id);
|
||||
return { record };
|
||||
await pbUser(event).collection('chore_templates').delete(id);
|
||||
return {};
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
||||
}
|
||||
},
|
||||
|
||||
updateAssignedChore: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const isTodo = fd.get('isTodo') === '1';
|
||||
@@ -116,7 +131,7 @@ export const actions = {
|
||||
}
|
||||
}
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
|
||||
const record = await pbUser(event).collection('assigned_chores').update(id, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
|
||||
@@ -124,8 +139,8 @@ export const actions = {
|
||||
},
|
||||
|
||||
createTodo: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const memberId = fd.get('memberId') as string;
|
||||
const name = fd.get('name') as string;
|
||||
@@ -167,15 +182,14 @@ export const actions = {
|
||||
customName: name,
|
||||
isTodo: true,
|
||||
startDate,
|
||||
completeBy,
|
||||
completeBy
|
||||
};
|
||||
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
|
||||
const record = await pbUser(event).collection('assigned_chores').create({ famId, ...data });
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' });
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
||||
hono.admin.rewards(event, famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
@@ -16,8 +16,8 @@ export async function load(event) {
|
||||
|
||||
export const actions = {
|
||||
claim: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const fd = await event.request.formData();
|
||||
const rewardId = fd.get('id') as string;
|
||||
try {
|
||||
|
||||
@@ -5,15 +5,15 @@ import { PROXY_URL } from '$app/env/public';
|
||||
const HONO_URL = PROXY_URL;
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session;
|
||||
const session = event.locals.user;
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
|
||||
// Parent (session auth) — profile lives in fam_admins
|
||||
// Parent (session auth) — profile lives on the users record
|
||||
if (session) {
|
||||
const famId = session.famId;
|
||||
if (session.memberName && session.memberName !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.memberName}/preferences`);
|
||||
if (session.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}/preferences`);
|
||||
}
|
||||
try {
|
||||
const me = await hono.admin.getProfile(event, famId);
|
||||
@@ -54,7 +54,7 @@ export async function load(event) {
|
||||
|
||||
export const actions = {
|
||||
update: async (event) => {
|
||||
const session = event.locals.session;
|
||||
const session = event.locals.user;
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
const color = fd.get('color') as string;
|
||||
|
||||
@@ -1,55 +1,81 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { pbUser } from '$lib/server/pocketbase';
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { issueAccess, createChild } from '$lib/server/member-otp';
|
||||
import { slugify } from '@shared/slugify';
|
||||
|
||||
function famIdOf(event: RequestEvent): string {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
return event.locals.user.famId;
|
||||
}
|
||||
|
||||
export async function load(event: RequestEvent) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
const pb = pbUser(event);
|
||||
const [members, fam, seasons] = await Promise.all([
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.fam(event, famId),
|
||||
hono.admin.list(event, 'seasons', famId),
|
||||
pb.collection('users').getFullList({
|
||||
filter: `famId = '${famId}' && role = 'child'`
|
||||
}),
|
||||
pbAdmin.getOne('fams', famId),
|
||||
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
|
||||
]);
|
||||
return { members, fam, seasons };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
regenInvite: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode };
|
||||
addMember: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
const famSlug: string = event.params.fam as string;
|
||||
const fd = await event.request.formData();
|
||||
const name = (fd.get('name') as string) || '';
|
||||
const colour = (fd.get('colour') as string) || '#6366f1';
|
||||
if (!name) return { error: 'Name required' };
|
||||
try {
|
||||
await createChild({ famId, famSlug, name, colour });
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to add member' };
|
||||
}
|
||||
},
|
||||
|
||||
addMember: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
issueAccess: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
const famSlug: string = event.params.fam as string;
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
if (!name) return { error: 'Name required' };
|
||||
await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' });
|
||||
const name = (fd.get('name') || '').toString().trim();
|
||||
if (!name) return { error: 'Select a child to invite' };
|
||||
try {
|
||||
const result = await issueAccess({ famId, famSlug, name });
|
||||
return { ok: true, ...result };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to issue access' };
|
||||
}
|
||||
},
|
||||
|
||||
renameFam: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
if (!name) return { error: 'Name required' };
|
||||
return await hono.admin.renameFam(event, famId, name);
|
||||
const record = await pbUser(event)
|
||||
.collection('fams')
|
||||
.update(famId, { name, slug: slugify(name) });
|
||||
return { name: record.name, slug: record.slug };
|
||||
},
|
||||
|
||||
deleteMember: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
if (!id) return { error: 'Member ID required' };
|
||||
await hono.admin.remove(event, 'members', famId, id);
|
||||
await pbUser(event).collection('users').delete(id);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
updatePayday: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
const payday = parseInt(fd.get('payday') as string, 10);
|
||||
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
||||
@@ -59,43 +85,48 @@ export const actions = {
|
||||
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
|
||||
return { error: 'Timezone must be an IANA name or auto' };
|
||||
}
|
||||
return await hono.admin.updatePayday(event, famId, payday, paydayTime, timezone || undefined);
|
||||
const patch: Record<string, unknown> = { payday };
|
||||
if (paydayTime) patch.paydayTime = paydayTime;
|
||||
if (timezone) patch.timezone = timezone;
|
||||
const record = await pbUser(event).collection('fams').update(famId, patch);
|
||||
return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone };
|
||||
},
|
||||
|
||||
createSeason: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
const color = fd.get('color') as string;
|
||||
if (!name) return { error: 'Name required' };
|
||||
return await hono.admin.create(event, 'seasons', famId, { name, color: color || '#6366f1', active: true });
|
||||
return await pbUser(event)
|
||||
.collection('seasons')
|
||||
.create({ famId, name, color: color || '#6366f1', active: true });
|
||||
},
|
||||
|
||||
deleteSeason: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
if (!id) return { error: 'Season ID required' };
|
||||
|
||||
const assigned = await hono.admin.list(event, 'assigned-chores', famId);
|
||||
const toDelete = (Array.isArray(assigned) ? assigned : [])
|
||||
.filter((a: any) => a.seasonIds?.includes(id));
|
||||
const pb = pbUser(event);
|
||||
const assigned = await pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` });
|
||||
const toDelete = (Array.isArray(assigned) ? assigned : []).filter(
|
||||
(a: any) => a.seasonIds?.includes(id)
|
||||
);
|
||||
const deletedIds = toDelete.map((a: any) => a.id);
|
||||
|
||||
await Promise.all(toDelete.map((a: any) =>
|
||||
hono.admin.remove(event, 'assigned-chores', famId, a.id)
|
||||
));
|
||||
|
||||
await hono.admin.remove(event, 'seasons', famId, id);
|
||||
await Promise.all(
|
||||
toDelete.map((a: any) => pb.collection('assigned_chores').delete(a.id))
|
||||
);
|
||||
await pb.collection('seasons').delete(id);
|
||||
|
||||
return { deletedChoreIds: deletedIds };
|
||||
},
|
||||
|
||||
// Compute endpoints — still proxied to Hono.
|
||||
completeWeek: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
try {
|
||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
|
||||
return { success: true, result };
|
||||
@@ -105,15 +136,14 @@ export const actions = {
|
||||
},
|
||||
|
||||
generateData: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
const days = parseInt(fd.get('days') as string || '7', 10);
|
||||
const days = parseInt((fd.get('days') as string) || '7', 10);
|
||||
try {
|
||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
|
||||
return { success: true, result };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user