migrate auth v2 code
This commit is contained in:
@@ -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}`, {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '' };
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user