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
+2 -3
View File
@@ -6,12 +6,11 @@ async function memberFetch<T = unknown>(
method: string,
path: string,
token: string,
famId: string,
_famId?: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
'x-device-token': token,
'x-device-famid': famId,
Authorization: `Bearer ${token}`,
};
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE_URL}${path}`, {
+22 -2
View File
@@ -10,7 +10,8 @@
<div
class="card"
style="grid-column: span {cols}; {accent ? `--card-accent: ${accent}` : ''}"
data-cols={cols}
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
class:has-accent={!!accent}
class:scroll-x={scrollX}
>
@@ -26,10 +27,14 @@
<style>
.card {
grid-column: span var(--card-cols, 1);
/* Container so card contents can react to how wide the card actually is */
container-type: inline-size;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
min-width: 0;
}
.card.scroll-x {
overflow: visible;
@@ -37,6 +42,21 @@
.card.has-accent {
border-top: 3px solid var(--card-accent, #6366f1);
}
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
@media (min-width: 640px) and (max-width: 1023px) {
.card[data-cols='3'],
.card[data-cols='4'],
.card[data-cols='5'],
.card[data-cols='6'] {
grid-column: span 2;
}
}
/* Mobile (1-col grid): every card is a full row */
@media (max-width: 639px) {
.card {
grid-column: span 1;
}
}
.card-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
@@ -54,4 +74,4 @@
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
</style>
</style>
+15 -2
View File
@@ -2,7 +2,7 @@
let { cols = 3, children }: { cols?: number; children?: any } = $props();
</script>
<div class="card-grid" style="grid-template-columns:repeat({cols}, 1fr)">
<div class="card-grid" style="--grid-cols: {cols}">
{@render children?.()}
</div>
@@ -10,5 +10,18 @@
.card-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
}
</style>
/* Tablet: settle to 2 columns */
@media (min-width: 640px) and (max-width: 1023px) {
.card-grid {
--grid-cols: 2;
}
}
/* Mobile: single column */
@media (max-width: 639px) {
.card-grid {
--grid-cols: 1;
}
}
</style>
+3 -4
View File
@@ -4,10 +4,9 @@ const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
export const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false);
export function initPbFromCookie() {
const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/);
if (match) {
pb.authStore.save(match[1], null);
export function initPb(token: string) {
if (token) {
pb.authStore.save(token, null);
return true;
}
return false;
+8 -66
View File
@@ -1,9 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public';
import { pbAdmin } from '$lib/server/pocketbase';
export function getSession(event: RequestEvent) {
return event.locals.session;
return event.locals.user;
}
export function requireAuth(event: RequestEvent) {
@@ -14,72 +14,14 @@ export function requireAuth(event: RequestEvent) {
return session;
}
export async function signup(email: string, password: string, famName: string, parentName?: string) {
const res = await fetch(`${PROXY_URL}/api/admin/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, famName, parentName }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Signup failed');
return data;
}
export async function login(email: string, password: string) {
console.log(email);
const res = await fetch(`${PROXY_URL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
return data;
}
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
const res = await fetch(`${PROXY_URL}/api/members/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode, name, deviceToken }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Join failed');
return data;
}
export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) {
event.cookies.set('session', JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30,
secure: false,
});
}
export function setDeviceTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('device_token', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
secure: false,
});
}
export function setPbTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('pb_token', token, {
httpOnly: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24,
secure: false,
});
}
export function clearSession(event: RequestEvent) {
event.cookies.delete('session', { path: '/' });
event.cookies.delete('pb_token', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
}
// Resolve the fam slug + admin display name used for the post-login redirect.
export async function getFamContext(famId: string) {
const fam = await pbAdmin.getOne('fams', famId).catch(() => null);
return { famSlug: fam?.slug || famId, famName: fam?.name || '' };
}
+4 -7
View File
@@ -2,11 +2,11 @@ import type { RequestEvent } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public';
function sessionHeaders(event: RequestEvent): Record<string, string> {
const s = event.locals.session;
if (!s) return {};
const u = event.locals.user;
if (!u) return {};
return {
'x-session-famid': s.famId,
'x-session-userid': s.userId,
'x-session-famid': u.famId,
'x-session-userid': u.id,
'Content-Type': 'application/json'
};
}
@@ -93,9 +93,6 @@ export const hono = {
async verify(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
},
async regenInvite(event: RequestEvent, famId: string) {
return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event));
},
async weeklySummary(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event));
},
-57
View File
@@ -1,57 +0,0 @@
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
import { SERVER_IP } from '$app/env/public';
export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
let token: string | null = null;
let tokenExpiry = 0;
async function ensureToken(): Promise<string> {
if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
});
const data = await res.json();
if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`);
token = data.token;
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
return token!;
}
export const pbAdmin = {
async getList(collection: string, filter = '') {
const t = await ensureToken();
const params = new URLSearchParams();
if (filter) params.set('filter', filter);
params.set('perPage', '200');
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`);
return data.items || [];
},
async getOne(collection: string, id: string) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
return data;
},
async update(collection: string, id: string, data: Record<string, unknown>) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(data),
});
const result = await res.json();
if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`);
return result;
},
};
+15 -7
View File
@@ -7,8 +7,18 @@ interface ChatInit {
actorType: 'admin' | 'member';
actorName: string;
actorColor: string;
deviceToken?: string;
memberId?: string;
pbToken?: string;
}
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
// secure context (HTTPS/localhost) — over plain HTTP on a LAN it's undefined,
// so fall back to a time+random string that's still unique enough per session.
function genClientId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
class ChatStore {
@@ -23,8 +33,7 @@ class ChatStore {
actorType = $state<'admin' | 'member'>('member');
actorName = $state('');
actorColor = $state('');
deviceToken = $state('');
memberId = $state('');
pbToken = $state('');
private unsubs: (() => void)[] = [];
private destroyed = false;
@@ -46,8 +55,7 @@ class ChatStore {
this.actorType = opts.actorType;
this.actorName = opts.actorName;
this.actorColor = opts.actorColor;
this.deviceToken = opts.deviceToken || '';
this.memberId = opts.memberId || '';
this.pbToken = opts.pbToken || '';
if (this.initialized && this.famId === opts.famId) return;
if (this.initPromise) {
@@ -185,7 +193,7 @@ class ChatStore {
async send(content: string) {
const text = content.trim();
if (!text || !this.famId) return;
const clientId = crypto.randomUUID();
const clientId = genClientId();
const temp: ChatMessage = {
id: 'temp-' + clientId,
famId: this.famId,
+15 -16
View File
@@ -13,7 +13,7 @@ import type {
} from '$lib/types';
type CollectionName =
| 'members'
| 'users'
| 'chore_templates'
| 'assigned_chores'
| 'completions'
@@ -96,9 +96,9 @@ class FamStore {
rewardsRes,
seasonsRes
] = await Promise.all([
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Member[]
>,
pb.collection('users').getFullList({
filter: `famId = '${famId}' && role = 'child'`
}) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
ChoreTemplate[]
>,
@@ -144,19 +144,18 @@ class FamStore {
}
private async subscribe() {
const subs: { collection: CollectionName; filter?: string }[] = [
{ collection: 'members', filter: this.famId },
{ collection: 'chore_templates', filter: this.famId },
{ collection: 'assigned_chores', filter: this.famId },
{ collection: 'completions', filter: this.famId },
{ collection: 'bonus_configs', filter: this.famId },
{ collection: 'bonus_templates', filter: this.famId },
{ collection: 'rewards', filter: this.famId },
{ collection: 'seasons', filter: this.famId }
const subs: { collection: CollectionName; filter: string }[] = [
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
{ collection: 'chore_templates', filter: `famId = '${this.famId}'` },
{ collection: 'assigned_chores', filter: `famId = '${this.famId}'` },
{ collection: 'completions', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_configs', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_templates', filter: `famId = '${this.famId}'` },
{ collection: 'rewards', filter: `famId = '${this.famId}'` },
{ collection: 'seasons', filter: `famId = '${this.famId}'` }
];
const promises = subs.map(({ collection, filter }) => {
const filterStr = filter ? `famId = '${filter}'` : '';
return pb
.collection(collection)
.subscribe(
@@ -165,7 +164,7 @@ class FamStore {
if (this.destroyed) return;
this.handleRealtime(collection, data.action, data.record);
},
{ filter: filterStr || undefined }
{ filter: filter || undefined }
)
.then((unsub) => {
if (this.destroyed) {
@@ -194,7 +193,7 @@ class FamStore {
};
switch (collection) {
case 'members':
case 'users':
this.members = apply(this.members);
break;
case 'chore_templates':
+2 -3
View File
@@ -40,7 +40,6 @@ export interface Fam {
id: string;
name: string;
slug: string;
inviteCode: string;
stripeCustomerId?: string;
featureFlags: Record<string, boolean>;
payday?: number;
@@ -54,10 +53,10 @@ export interface Fam {
export interface Member {
id: string;
famId: string;
username: string;
name: string;
color: string;
deviceToken: string;
deviceTokenHint: string;
role: 'child';
created: string;
updated: string;
}