From 493746b14c1251a45b061d2e3edd9160c929d2b9 Mon Sep 17 00:00:00 2001 From: benj Date: Fri, 10 Apr 2026 11:13:34 +0800 Subject: a basic ui and landing web interface for tidyindex.com --- .../src/routes/dashboard/account/+page.server.ts | 107 +++++++++ web/ui/src/routes/dashboard/account/+page.svelte | 238 +++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 web/ui/src/routes/dashboard/account/+page.server.ts create mode 100644 web/ui/src/routes/dashboard/account/+page.svelte (limited to 'web/ui/src/routes/dashboard/account') diff --git a/web/ui/src/routes/dashboard/account/+page.server.ts b/web/ui/src/routes/dashboard/account/+page.server.ts new file mode 100644 index 0000000..5581b52 --- /dev/null +++ b/web/ui/src/routes/dashboard/account/+page.server.ts @@ -0,0 +1,107 @@ +import { fail, redirect, type Actions } from '@sveltejs/kit'; + +import { queries } from '$lib/server/db'; +import { + attachEmailToAccount, + clearSession, + createSession +} from '$lib/server/auth'; +import { PLANS, PLAN_ORDER, type PlanId } from '$lib/plans'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const account = locals.account!; + const keys = queries.keysForAccount.all(account.id); + return { + account, + keyCount: keys.length, + plans: PLAN_ORDER.map((id) => PLANS[id]), + currentPlan: account.plan + }; +}; + +export const actions: Actions = { + /** + * Anonymous → pending. The user has typed an email; we save it as + * pending_email and (eventually) send a magic link. Email sending is + * not wired up yet — the UI just transitions to the pending state. + */ + requestSignInLink: async ({ request, locals }) => { + const account = locals.account!; + const form = await request.formData(); + const email = ((form.get('email') ?? '') as string).trim().toLowerCase(); + + if (!email || !email.includes('@') || email.length > 254) { + return fail(400, { error: 'Enter a valid email address.' }); + } + + queries.setPendingEmail.run(email, account.id); + return { linkSent: true, email }; + }, + + /** Pending → anonymous. User abandons the verification. */ + cancelPendingSignIn: async ({ locals }) => { + const account = locals.account!; + queries.clearPendingEmail.run(account.id); + return { cancelled: true }; + }, + + /** + * Pending → signed in. Dev-only shortcut so the signed-in UI is + * reachable without a working magic-link verification flow. Will be + * removed once /auth/callback promotes pending_email itself. + * + * Goes through attachEmailToAccount so we get the same conflict + * handling as real verification: if another account already owns the + * email, current account merges into it (keys move over, current is + * deleted, session cookie reissued). + */ + markVerified: async ({ locals, cookies }) => { + const account = locals.account!; + if (!account.pending_email) { + return fail(400, { error: 'No pending email to verify.' }); + } + try { + const result = attachEmailToAccount(account.id, account.pending_email); + if (result.accountId === account.id) { + // Email attached cleanly to this account — clear pending state. + queries.clearPendingEmail.run(account.id); + } else { + // Merged into a pre-existing account that already owned this + // email. The current account is gone; reissue the cookie so + // subsequent requests load the surviving account. + createSession(cookies, result.accountId); + } + return { verified: true, merged: result.accountId !== account.id }; + } catch (e) { + return fail(400, { + error: e instanceof Error ? e.message : 'Could not verify.' + }); + } + }, + + switchPlan: async ({ request, locals }) => { + const account = locals.account!; + const form = await request.formData(); + const planId = ((form.get('plan') ?? '') as string) as PlanId; + + if (!PLAN_ORDER.includes(planId)) { + return fail(400, { error: 'Unknown plan' }); + } + if (planId === 'enterprise') { + return fail(400, { + error: 'Enterprise is contact-only. Email contact@tidyindex.com.' + }); + } + + queries.updateAccountPlan.run(planId, account.id); + return { switchedTo: planId }; + }, + + deleteAccount: async ({ locals, cookies }) => { + const account = locals.account!; + queries.deleteAccount.run(account.id); + clearSession(cookies); + throw redirect(303, '/'); + } +}; diff --git a/web/ui/src/routes/dashboard/account/+page.svelte b/web/ui/src/routes/dashboard/account/+page.svelte new file mode 100644 index 0000000..287c210 --- /dev/null +++ b/web/ui/src/routes/dashboard/account/+page.svelte @@ -0,0 +1,238 @@ + + +

§ 03  ·  account

+

Account.

+

+ {#if data.account.email} + Manage your sign-in and review what we know about you. + {:else if data.account.pending_email} + Finish signing in to lock your keys to this email. + {:else} + Sign in with email so your keys follow you across browsers. + {/if} +

+ +
+
+

Sign in

+
+ + {#if data.account.email} + +

+ Signed in as {data.account.email}. +

+
+ +
+ {:else if data.account.pending_email} + +

+ We sent a sign-in link to + {data.account.pending_email}. Click it from your inbox + to finish signing in. +

+
+
+ + +
+
+ +
+
+ +
+
+ {:else} + +

+ You're using an anonymous session. Sign in with an email so you don't + lose access to your keys if you clear cookies or switch browsers. +

+
+
+ + +
+ +
+ {/if} +
+ +
+
+

Plan

+
+

+ Usage resets on the first of every month. You can change plans any time, and + we prorate mid-cycle. (Billing is stubbed in this demo — switching plans just + updates the dashboard.) +

+ +
+ {#each data.plans as plan} + {@const isCurrent = plan.id === data.currentPlan} +
+ {#if isCurrent} + Current + {/if} + +

{plan.name}

+

+ {plan.priceLabel} + {#if plan.period} {plan.period}{/if} +

+ +
    + {#each plan.features as f} +
  • {f}
  • + {/each} +
+ +
+ {#if plan.id === 'enterprise'} + + Contact us + + {:else if isCurrent} + + {:else} +
+ + +
+ {/if} +
+
+ {/each} +
+ +

+ Need something not listed here — custom datasets, on-prem deployment, higher + rate limits? Reply to any email from us, or reach out at + contact@tidyindex.com. +

+
+ +
+
+

What we know about you

+
+
+
+
account id
+
{data.account.id}
+
+
+
plan
+
{data.account.plan}
+
+
+
created
+
+ {new Date(data.account.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} +
+
+
+
keys on file
+
{data.keyCount}
+
+
+
+ +
+

Danger zone

+

+ Deleting your account will revoke every key and permanently erase your + usage history. This cannot be undone. +

+ {#if confirmDelete} +
+
+ +
+ +
+ {:else} + + {/if} +
-- cgit v1.2.3