+
+ You're using an anonymous session.
+ Sign in with an email so you don't lose access to your keys.
+
+ Sign in
+
+ {:else if account?.pending_email && !account?.email}
+
+
+ We sent a sign-in link to {account.pending_email}.
+ Check your inbox to finish signing in.
+
+ Manage
+
+ {/if}
+
+
+
+
+
+
+ {@render children()}
+
+
+
+
diff --git a/web/ui/src/routes/dashboard/+page.server.ts b/web/ui/src/routes/dashboard/+page.server.ts
new file mode 100644
index 0000000..32e2307
--- /dev/null
+++ b/web/ui/src/routes/dashboard/+page.server.ts
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async () => {
+ throw redirect(303, '/dashboard/keys');
+};
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.)
+
+ 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.
+