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 --- web/ui/src/app.css | 1170 ++++++++++++++++++++ web/ui/src/app.d.ts | 16 + web/ui/src/app.html | 42 + web/ui/src/hooks.server.ts | 16 + web/ui/src/lib/components/BrandMark.svelte | 7 + web/ui/src/lib/components/Footer.svelte | 8 + web/ui/src/lib/components/Toasts.svelte | 12 + web/ui/src/lib/datasets.ts | 46 + web/ui/src/lib/keys.ts | 24 + web/ui/src/lib/plans.ts | 82 ++ web/ui/src/lib/server/auth.ts | 153 +++ web/ui/src/lib/server/db.ts | 233 ++++ web/ui/src/lib/server/keys.ts | 79 ++ web/ui/src/lib/server/usage.ts | 33 + web/ui/src/lib/stores/toasts.ts | 21 + web/ui/src/routes/+layout.server.ts | 7 + web/ui/src/routes/+layout.svelte | 9 + web/ui/src/routes/+page.server.ts | 20 + web/ui/src/routes/auth/callback/+server.ts | 14 + web/ui/src/routes/auth/logout/+server.ts | 13 + web/ui/src/routes/dashboard/+layout.server.ts | 12 + web/ui/src/routes/dashboard/+layout.svelte | 90 ++ web/ui/src/routes/dashboard/+page.server.ts | 7 + .../src/routes/dashboard/account/+page.server.ts | 107 ++ web/ui/src/routes/dashboard/account/+page.svelte | 238 ++++ web/ui/src/routes/dashboard/keys/+page.server.ts | 68 ++ web/ui/src/routes/dashboard/keys/+page.svelte | 220 ++++ web/ui/src/routes/dashboard/usage/+page.server.ts | 13 + web/ui/src/routes/dashboard/usage/+page.svelte | 152 +++ 29 files changed, 2912 insertions(+) create mode 100644 web/ui/src/app.css create mode 100644 web/ui/src/app.d.ts create mode 100644 web/ui/src/app.html create mode 100644 web/ui/src/hooks.server.ts create mode 100644 web/ui/src/lib/components/BrandMark.svelte create mode 100644 web/ui/src/lib/components/Footer.svelte create mode 100644 web/ui/src/lib/components/Toasts.svelte create mode 100644 web/ui/src/lib/datasets.ts create mode 100644 web/ui/src/lib/keys.ts create mode 100644 web/ui/src/lib/plans.ts create mode 100644 web/ui/src/lib/server/auth.ts create mode 100644 web/ui/src/lib/server/db.ts create mode 100644 web/ui/src/lib/server/keys.ts create mode 100644 web/ui/src/lib/server/usage.ts create mode 100644 web/ui/src/lib/stores/toasts.ts create mode 100644 web/ui/src/routes/+layout.server.ts create mode 100644 web/ui/src/routes/+layout.svelte create mode 100644 web/ui/src/routes/+page.server.ts create mode 100644 web/ui/src/routes/auth/callback/+server.ts create mode 100644 web/ui/src/routes/auth/logout/+server.ts create mode 100644 web/ui/src/routes/dashboard/+layout.server.ts create mode 100644 web/ui/src/routes/dashboard/+layout.svelte create mode 100644 web/ui/src/routes/dashboard/+page.server.ts create mode 100644 web/ui/src/routes/dashboard/account/+page.server.ts create mode 100644 web/ui/src/routes/dashboard/account/+page.svelte create mode 100644 web/ui/src/routes/dashboard/keys/+page.server.ts create mode 100644 web/ui/src/routes/dashboard/keys/+page.svelte create mode 100644 web/ui/src/routes/dashboard/usage/+page.server.ts create mode 100644 web/ui/src/routes/dashboard/usage/+page.svelte (limited to 'web/ui/src') diff --git a/web/ui/src/app.css b/web/ui/src/app.css new file mode 100644 index 0000000..db2f519 --- /dev/null +++ b/web/ui/src/app.css @@ -0,0 +1,1170 @@ +/* ---------------------------------------------------------------- + Tidy Index — dashboard styles + + Shares the exact design tokens from the landing page. Adds + dashboard-specific components (tabs, cards, badges, toasts, + form inputs, danger variants). +---------------------------------------------------------------- */ + +/* ---------- self-hosted webfonts ---------- */ + +@font-face { + font-family: 'Fraunces'; + font-style: normal; + font-weight: 300 600; + font-display: swap; + src: url('/fonts/fraunces-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('/fonts/inter-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 500; + font-display: swap; + src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215, U+FEFF, U+FFFD; +} + +:root { + --c-bg: #ffffff; + --c-bg-soft: #f7f9fc; + --c-bg-tint: #eef4fb; + + --c-rule: #e3ecf5; + --c-rule-strong: #cbd6e6; + + --c-ink: #0b1f3a; + --c-ink-soft: #3b4f6b; + --c-ink-mute: #6b7c93; + + --c-blue: #2563eb; + --c-blue-deep: #1d4ed8; + --c-blue-soft: #dbeafe; + --c-blue-tint: #eef4ff; + + --c-danger: #dc2626; + --c-danger-deep: #b91c1c; + --c-danger-soft: #fee2e2; + --c-danger-tint: #fef2f2; + + --c-success: #059669; + --c-success-soft: #d1fae5; + + --c-warn: #b45309; + --c-warn-soft: #fef3c7; + + --shadow-sm: 0 1px 2px rgba(15, 38, 73, 0.04); + --shadow-md: 0 8px 28px -10px rgba(37, 99, 235, 0.18), + 0 2px 6px rgba(15, 38, 73, 0.05); + --shadow-lg: 0 24px 60px -20px rgba(37, 99, 235, 0.25), + 0 6px 16px rgba(15, 38, 73, 0.06); + + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 18px; + + --font-serif: 'Fraunces', 'Iowan Old Style', Georgia, 'Times New Roman', serif; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', + Helvetica, Arial, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, + Consolas, monospace; + + --max-w: 960px; + --max-w-wide: 1080px; + --gutter: clamp(20px, 4vw, 40px); +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + color: var(--c-ink); + background: var(--c-bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: var(--c-blue); + text-decoration: none; + transition: color 160ms ease; +} +a:hover { color: var(--c-blue-deep); } + +img, svg { display: block; max-width: 100%; } + +::selection { + background: var(--c-blue-soft); + color: var(--c-ink); +} + +/* ---------- layout ---------- */ + +.container { + width: 100%; + max-width: var(--max-w); + margin: 0 auto; + padding: 0 var(--gutter); +} + +/* ---------- typographic primitives (match landing) ---------- */ + +.serif { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 144, 'SOFT' 30; + font-weight: 400; + letter-spacing: -0.018em; + color: var(--c-ink); +} + +.serif-italic { + font-style: italic; + font-variation-settings: 'opsz' 144, 'SOFT' 100; +} + +.section-marker { + margin: 0 0 18px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + color: var(--c-blue); + text-transform: lowercase; +} + +.page-title { + margin: 0 0 8px; + font-family: var(--font-serif); + font-variation-settings: 'opsz' 144, 'SOFT' 30; + font-weight: 400; + font-size: clamp(32px, 4.6vw, 44px); + line-height: 1.1; + letter-spacing: -0.02em; + color: var(--c-ink); +} + +.page-subtitle { + margin: 0 0 32px; + font-size: 16px; + color: var(--c-ink-soft); + max-width: 60ch; +} + +/* ---------- dashboard app shell ---------- */ + +.app-header { + position: sticky; + top: 0; + z-index: 40; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: saturate(180%) blur(14px); + -webkit-backdrop-filter: saturate(180%) blur(14px); + border-bottom: 1px solid var(--c-rule); +} + +.app-header-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 66px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + font-family: var(--font-serif); + font-variation-settings: 'opsz' 24; + font-weight: 500; + font-size: 19px; + letter-spacing: -0.005em; + color: var(--c-ink); +} +.brand:hover { color: var(--c-blue); } + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + color: var(--c-blue); +} +.brand-mark svg { width: 22px; height: 22px; } + +.app-header-meta { + display: flex; + align-items: center; + gap: 22px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--c-ink-mute); +} + +.app-header-meta .plan-chip { + padding: 5px 10px; + border: 1px solid var(--c-rule); + border-radius: 999px; + color: var(--c-ink-soft); + text-transform: lowercase; + letter-spacing: 0.04em; +} + +.app-header-meta .plan-chip strong { + color: var(--c-blue); + font-weight: 500; +} + +.app-header-meta .pending-chip { + padding: 5px 10px; + border: 1px solid #fde68a; + background: var(--c-warn-soft); + border-radius: 999px; + color: var(--c-warn); + letter-spacing: 0.02em; + font-family: var(--font-mono); + font-size: 11px; +} + +.app-header-meta a { + color: var(--c-ink-soft); + border-bottom: 1px solid transparent; +} +.app-header-meta a:hover { + color: var(--c-blue); + border-color: var(--c-blue); +} + +/* ---------- tab nav ---------- */ + +.tabs { + border-bottom: 1px solid var(--c-rule); + margin-bottom: 48px; +} + +.tabs-inner { + display: flex; + gap: 4px; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; +} +.tabs-inner::-webkit-scrollbar { display: none; } + +.tab { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 18px 18px 16px; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + color: var(--c-ink-mute); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + transition: color 160ms ease, border-color 160ms ease; +} + +.tab:hover { color: var(--c-ink-soft); } + +.tab.active { + color: var(--c-ink); + border-bottom-color: var(--c-blue); +} + +.tab-num { + font-family: var(--font-mono); + font-size: 11px; + color: var(--c-blue); + font-weight: 500; +} + +/* ---------- main content ---------- */ + +main.app-main { + padding: 56px 0 120px; + min-height: calc(100vh - 66px - 64px); +} + +.entry-main { + padding: 0; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +/* ---------- cards ---------- */ + +.card { + background: #fff; + border: 1px solid var(--c-rule); + border-radius: var(--radius-md); + padding: 26px 28px; + box-shadow: var(--shadow-sm); + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.card + .card { margin-top: 14px; } + +.card.card-feature { + border-color: var(--c-blue-soft); + background: linear-gradient(180deg, #ffffff 0%, var(--c-blue-tint) 140%); + box-shadow: var(--shadow-md); +} + +.card.card-accent { + border-left: 3px solid var(--c-blue); +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; +} + +.card-title { + margin: 0; + font-family: var(--font-serif); + font-variation-settings: 'opsz' 36, 'SOFT' 30; + font-weight: 500; + font-size: 19px; + line-height: 1.2; + letter-spacing: -0.005em; + color: var(--c-ink); +} + +.card-sub { + margin: 0; + font-size: 14px; + color: var(--c-ink-mute); + line-height: 1.55; +} + +.card-body { font-size: 15px; color: var(--c-ink-soft); } + +.card-footer { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--c-rule); + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--c-ink-mute); +} + +/* ---------- buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + padding: 10px 18px; + border-radius: 999px; + border: 1px solid transparent; + cursor: pointer; + transition: all 180ms ease; + letter-spacing: -0.005em; + text-decoration: none; + background: transparent; + color: inherit; +} + +.btn svg { width: 15px; height: 15px; } + +.btn-primary { + background: var(--c-ink); + color: #fff; + box-shadow: 0 6px 18px -8px rgba(11, 31, 58, 0.5); +} +.btn-primary:hover { + background: var(--c-blue); + color: #fff; + transform: translateY(-1px); + box-shadow: 0 10px 22px -8px rgba(37, 99, 235, 0.5); +} + +.btn-accent { + background: var(--c-blue); + color: #fff; + box-shadow: 0 6px 18px -8px rgba(37, 99, 235, 0.5); +} +.btn-accent:hover { + background: var(--c-blue-deep); + color: #fff; + transform: translateY(-1px); +} + +.btn-ghost { + background: transparent; + color: var(--c-ink); + border-color: var(--c-rule); +} +.btn-ghost:hover { + background: var(--c-bg-soft); + border-color: var(--c-blue-soft); + color: var(--c-blue-deep); +} + +.btn-danger { + background: transparent; + color: var(--c-danger); + border-color: var(--c-danger-soft); +} +.btn-danger:hover { + background: var(--c-danger-tint); + border-color: var(--c-danger); + color: var(--c-danger-deep); +} + +.btn-sm { + padding: 7px 14px; + font-size: 13px; +} +.btn-lg { + padding: 14px 26px; + font-size: 15px; +} + +.btn-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + color: var(--c-ink-soft); + border: 0; + background: transparent; + cursor: pointer; + padding: 4px 0; + border-bottom: 1px solid var(--c-rule-strong); +} +.btn-link:hover { + color: var(--c-blue); + border-color: var(--c-blue); +} + +/* ---------- form inputs ---------- */ + +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 18px; +} + +.field-label { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--c-ink-mute); +} + +.input, .select, .textarea { + width: 100%; + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + color: var(--c-ink); + background: #fff; + border: 1px solid var(--c-rule); + border-radius: var(--radius-sm); + padding: 11px 14px; + transition: border-color 160ms ease, box-shadow 160ms ease; +} +.input:focus, .select:focus, .textarea:focus { + outline: none; + border-color: var(--c-blue); + box-shadow: 0 0 0 3px var(--c-blue-tint); +} + +.input.mono { + font-family: var(--font-mono); + font-size: 13px; +} + +.input.input-lg { + padding: 14px 18px; + font-size: 16px; +} + +.help { + font-size: 13px; + color: var(--c-ink-mute); + line-height: 1.5; +} + +/* ---------- chips / scope toggles ---------- */ + +.chip-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + font-family: var(--font-mono); + font-size: 12px; + padding: 7px 13px; + border-radius: 999px; + border: 1px solid var(--c-rule); + background: #fff; + color: var(--c-ink-soft); + cursor: pointer; + transition: all 140ms ease; + user-select: none; +} +.chip:hover { + border-color: var(--c-blue-soft); + color: var(--c-ink); +} +.chip.active { + background: var(--c-blue-tint); + border-color: var(--c-blue); + color: var(--c-blue-deep); +} + +.chip-all { + font-weight: 500; +} + +/* ---------- badges ---------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 4px 9px; + border-radius: 999px; + background: var(--c-blue-tint); + color: var(--c-blue-deep); + border: 1px solid var(--c-blue-soft); +} + +.badge-success { + background: var(--c-success-soft); + color: var(--c-success); + border-color: #a7f3d0; +} + +.badge-muted { + background: var(--c-bg-soft); + color: var(--c-ink-mute); + border-color: var(--c-rule); +} + +.badge-danger { + background: var(--c-danger-soft); + color: var(--c-danger-deep); + border-color: #fecaca; +} + +.badge-warn { + background: var(--c-warn-soft); + color: var(--c-warn); + border-color: #fde68a; +} + +/* ---------- banner ---------- */ + +.banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 20px; + background: var(--c-warn-soft); + border: 1px solid #fde68a; + border-radius: var(--radius-sm); + font-size: 14px; + color: var(--c-warn); + margin-bottom: 28px; +} +.banner strong { color: #78350f; font-weight: 500; } + +/* ---------- toasts ---------- */ + +.toast-container { + position: fixed; + top: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 10px; + background: #fff; + border: 1px solid var(--c-rule-strong); + border-radius: 999px; + padding: 11px 22px; + font-family: var(--font-sans); + font-size: 14px; + color: var(--c-ink); + box-shadow: var(--shadow-md); + animation: toast-in 220ms ease-out; +} + +.toast.toast-success { + border-color: #a7f3d0; + background: linear-gradient(180deg, #ffffff 0%, var(--c-success-soft) 180%); +} +.toast.toast-success .toast-dot { background: var(--c-success); } + +.toast.toast-error { + border-color: #fecaca; + background: linear-gradient(180deg, #ffffff 0%, var(--c-danger-soft) 180%); +} +.toast.toast-error .toast-dot { background: var(--c-danger); } + +.toast-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--c-blue); +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ---------- key reveal banner ---------- */ + +.key-reveal { + background: linear-gradient(180deg, #ffffff 0%, var(--c-blue-tint) 140%); + border: 1px solid var(--c-blue-soft); + border-left: 3px solid var(--c-blue); + border-radius: var(--radius-md); + padding: 22px 26px; + margin-bottom: 24px; + box-shadow: var(--shadow-md); +} + +.key-reveal-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.key-reveal-head strong { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 36, 'SOFT' 30; + font-size: 17px; + font-weight: 500; + color: var(--c-ink); +} + +.key-reveal-value { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + background: #fff; + border: 1px solid var(--c-rule); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 13px; + color: var(--c-ink); + word-break: break-all; +} + +.key-reveal-warn { + margin: 14px 0 0; + font-size: 13px; + color: var(--c-warn); +} + +/* ---------- key list ---------- */ + +.key-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; +} + +.key-row-main { + flex: 1; + min-width: 240px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.key-name { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 36, 'SOFT' 30; + font-weight: 500; + font-size: 18px; + color: var(--c-ink); + display: flex; + align-items: center; + gap: 10px; +} + +.key-mask { + font-family: var(--font-mono); + font-size: 13px; + color: var(--c-ink-soft); + letter-spacing: 0.02em; +} + +.key-meta { + display: flex; + flex-wrap: wrap; + gap: 14px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--c-ink-mute); + margin-top: 6px; +} + +.key-meta .meta-label { + color: var(--c-blue); + text-transform: lowercase; + margin-right: 4px; +} + +.scope-summary { + font-family: var(--font-mono); + font-size: 11px; + color: var(--c-ink-mute); +} + +.scope-summary code { + background: var(--c-bg-soft); + padding: 2px 6px; + border-radius: 4px; + margin-right: 4px; +} + +/* ---------- plan grid ---------- */ + +.plan-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; + margin-top: 8px; +} + +.plan-card { + position: relative; + background: #fff; + border: 1px solid var(--c-rule); + border-radius: var(--radius-md); + padding: 28px 28px 24px; + box-shadow: var(--shadow-sm); + transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease; + display: flex; + flex-direction: column; +} +.plan-card:hover { + border-color: var(--c-blue-soft); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.plan-card.current { + border-color: var(--c-blue); + box-shadow: 0 10px 30px -12px rgba(37, 99, 235, 0.25); + background: linear-gradient(180deg, #ffffff 0%, var(--c-blue-tint) 160%); +} + +.plan-card .badge { + position: absolute; + top: 20px; + right: 20px; +} + +.plan-name { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 36, 'SOFT' 30; + font-weight: 500; + font-size: 22px; + color: var(--c-ink); + margin: 0 0 4px; +} + +.plan-price { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 144, 'SOFT' 30; + font-size: 42px; + font-weight: 400; + line-height: 1; + color: var(--c-ink); + margin: 8px 0 14px; +} +.plan-price small { + font-family: var(--font-sans); + font-size: 14px; + font-weight: 400; + color: var(--c-ink-mute); +} + +.plan-features { + list-style: none; + padding: 0; + margin: 0 0 22px; + display: flex; + flex-direction: column; + gap: 8px; +} +.plan-features li { + font-size: 14px; + color: var(--c-ink-soft); + display: flex; + align-items: center; + gap: 8px; +} +.plan-features li::before { + content: "·"; + color: var(--c-blue); + font-weight: 700; + font-size: 20px; + line-height: 1; + margin-top: 2px; +} + +.plan-cta { + margin-top: auto; +} + +/* ---------- usage ---------- */ + +.usage-summary { + display: grid; + grid-template-columns: 1fr auto; + gap: 24px; + align-items: center; + margin-bottom: 14px; +} + +.usage-count { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 144, 'SOFT' 30; + font-size: 44px; + line-height: 1; + font-weight: 400; + color: var(--c-ink); +} +.usage-count small { + font-family: var(--font-sans); + font-size: 14px; + font-weight: 400; + color: var(--c-ink-mute); +} + +.usage-bar { + width: 100%; + height: 6px; + background: var(--c-bg-soft); + border: 1px solid var(--c-rule); + border-radius: 999px; + overflow: hidden; + margin-top: 18px; +} + +.usage-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--c-blue) 0%, #60a5fa 100%); + border-radius: 999px; + transition: width 300ms ease; +} + +.usage-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + margin-top: 12px; +} + +.usage-table th { + text-align: left; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--c-ink-mute); + padding: 12px 0; + border-bottom: 1px solid var(--c-rule); + font-weight: 500; +} + +.usage-table td { + padding: 14px 0; + border-bottom: 1px solid var(--c-rule); + color: var(--c-ink-soft); +} +.usage-table tr:last-child td { border-bottom: 0; } +.usage-table td:first-child { + font-family: var(--font-mono); + font-size: 13px; + color: var(--c-ink); +} +.usage-table td.num { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--c-ink); +} + +/* ---------- entry / landing page of dashboard ---------- */ + +.entry-card { + width: 100%; + max-width: 440px; + background: #fff; + border: 1px solid var(--c-rule); + border-radius: var(--radius-lg); + padding: 48px 44px 40px; + box-shadow: var(--shadow-md); + text-align: left; +} + +.entry-brand { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.22em; + color: var(--c-blue); + margin-bottom: 16px; +} + +.entry-tagline { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 144, 'SOFT' 30; + font-weight: 400; + font-size: 28px; + line-height: 1.15; + letter-spacing: -0.015em; + color: var(--c-ink); + margin: 0 0 32px; +} + +.entry-tagline em { + font-style: italic; + font-variation-settings: 'opsz' 144, 'SOFT' 100; + color: var(--c-blue); +} + +.entry-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 24px 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--c-ink-mute); + text-transform: lowercase; + letter-spacing: 0.06em; +} +.entry-divider::before, +.entry-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--c-rule); +} + +.entry-magic-note { + margin: 16px 0 0; + padding: 14px 16px; + background: var(--c-blue-tint); + border: 1px solid var(--c-blue-soft); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 11px; + color: var(--c-ink-soft); + word-break: break-all; +} +.entry-magic-note strong { + display: block; + color: var(--c-blue); + text-transform: lowercase; + letter-spacing: 0.06em; + margin-bottom: 6px; + font-weight: 500; +} +.entry-magic-note a { color: var(--c-blue-deep); } + +/* ---------- footer ---------- */ + +.app-footer { + border-top: 1px solid var(--c-rule); + background: var(--c-bg); + padding: 28px 0; + margin-top: auto; +} + +.app-footer-inner { + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + flex-wrap: wrap; + font-family: var(--font-mono); + font-size: 12px; + color: var(--c-ink-mute); +} + +.app-footer-line { + margin: 0; + letter-spacing: 0.02em; +} + +.app-footer-line a { + color: var(--c-ink-soft); + border-bottom: 1px solid var(--c-rule-strong); + padding-bottom: 1px; + transition: color 160ms ease, border-color 160ms ease; +} +.app-footer-line a:hover { + color: var(--c-blue); + border-color: var(--c-blue); +} + +/* ---------- inline flex helpers ---------- */ + +.row { display: flex; align-items: center; gap: 12px; } +.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.row-wrap { flex-wrap: wrap; } +.col { display: flex; flex-direction: column; gap: 12px; } + +.stack-sm > * + * { margin-top: 8px; } +.stack > * + * { margin-top: 14px; } +.stack-lg > * + * { margin-top: 22px; } + +.mt-0 { margin-top: 0; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mt-24 { margin-top: 24px; } +.mt-40 { margin-top: 40px; } + +.mb-0 { margin-bottom: 0; } +.mb-8 { margin-bottom: 8px; } +.mb-16 { margin-bottom: 16px; } +.mb-24 { margin-bottom: 24px; } + +.text-mute { color: var(--c-ink-mute); } +.text-soft { color: var(--c-ink-soft); } +.text-mono { font-family: var(--font-mono); font-size: 13px; } + +/* ---------- code blocks ---------- */ + +code, pre { + font-family: var(--font-mono); +} + +.code-block { + background: var(--c-bg-soft); + border: 1px solid var(--c-rule); + border-radius: var(--radius-sm); + padding: 14px 18px; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.6; + color: var(--c-ink); + overflow-x: auto; + white-space: pre; + margin: 0; +} + +/* ---------- empty state ---------- */ + +.empty-state { + text-align: center; + padding: 56px 24px; + border: 1px dashed var(--c-rule-strong); + border-radius: var(--radius-md); + color: var(--c-ink-mute); +} +.empty-state p { margin: 0 0 8px; font-size: 15px; } +.empty-state p.empty-title { + font-family: var(--font-serif); + font-variation-settings: 'opsz' 36, 'SOFT' 30; + font-weight: 500; + font-size: 18px; + color: var(--c-ink-soft); +} + +/* ---------- danger zone ---------- */ + +.danger-zone { + margin-top: 40px; + border: 1px solid var(--c-danger-soft); + background: var(--c-danger-tint); + border-radius: var(--radius-md); + padding: 22px 26px; +} +.danger-zone-title { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--c-danger); + margin: 0 0 8px; +} +.danger-zone-body { + font-size: 14px; + color: var(--c-ink-soft); + margin: 0 0 16px; +} + +/* ---------- responsive ---------- */ + +@media (max-width: 760px) { + .plan-grid { grid-template-columns: 1fr; } + .usage-summary { grid-template-columns: 1fr; } + .app-header-meta .plan-chip { display: none; } + main.app-main { padding: 40px 0 80px; } + .tabs { margin-bottom: 32px; } + .card { padding: 22px 20px; } + .entry-card { padding: 36px 28px 32px; } +} + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; } +} diff --git a/web/ui/src/app.d.ts b/web/ui/src/app.d.ts new file mode 100644 index 0000000..f609a0b --- /dev/null +++ b/web/ui/src/app.d.ts @@ -0,0 +1,16 @@ +// See https://kit.svelte.dev/docs/types#app + +import type { Account } from '$lib/server/db'; + +declare global { + namespace App { + interface Locals { + account: Account | null; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/ui/src/app.html b/web/ui/src/app.html new file mode 100644 index 0000000..beb1a42 --- /dev/null +++ b/web/ui/src/app.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/ui/src/hooks.server.ts b/web/ui/src/hooks.server.ts new file mode 100644 index 0000000..1204a94 --- /dev/null +++ b/web/ui/src/hooks.server.ts @@ -0,0 +1,16 @@ +import { redirect, type Handle } from '@sveltejs/kit'; + +import { getAccountFromCookies } from '$lib/server/auth'; + +export const handle: Handle = async ({ event, resolve }) => { + event.locals.account = getAccountFromCookies(event.cookies); + + const path = event.url.pathname; + + // /dashboard/* requires a session. Bounce to the entry page if missing. + if (path.startsWith('/dashboard') && !event.locals.account) { + throw redirect(303, '/'); + } + + return resolve(event); +}; diff --git a/web/ui/src/lib/components/BrandMark.svelte b/web/ui/src/lib/components/BrandMark.svelte new file mode 100644 index 0000000..0feab32 --- /dev/null +++ b/web/ui/src/lib/components/BrandMark.svelte @@ -0,0 +1,7 @@ + diff --git a/web/ui/src/lib/components/Footer.svelte b/web/ui/src/lib/components/Footer.svelte new file mode 100644 index 0000000..8042f64 --- /dev/null +++ b/web/ui/src/lib/components/Footer.svelte @@ -0,0 +1,8 @@ + diff --git a/web/ui/src/lib/components/Toasts.svelte b/web/ui/src/lib/components/Toasts.svelte new file mode 100644 index 0000000..3d66b3f --- /dev/null +++ b/web/ui/src/lib/components/Toasts.svelte @@ -0,0 +1,12 @@ + + +
+ {#each $toasts as t (t.id)} +
+ + {t.message} +
+ {/each} +
diff --git a/web/ui/src/lib/datasets.ts b/web/ui/src/lib/datasets.ts new file mode 100644 index 0000000..83c2e4e --- /dev/null +++ b/web/ui/src/lib/datasets.ts @@ -0,0 +1,46 @@ +/** + * The shared list of dataset slugs a user can scope an API key to. + * Used by the create-key form, the key scope display, and the usage + * breakdown. The order here is the order they show up in the UI. + */ +export const DATASETS = [ + 'irs-990', + 'irs-990pf', + 'sec-edgar', + 'sec-13f', + 'sec-form4', + 'fec-contributions', + 'lobbying-federal', + 'usaspending', + 'pacer', + 'state-corps', + 'ucc-filings', + 'fda-faers', + 'osha', + 'nih-reporter', + 'cfpb-complaints' +] as const; + +export type DatasetSlug = (typeof DATASETS)[number]; + +/** Friendly short label for a dataset slug. */ +export function datasetLabel(slug: string): string { + switch (slug) { + case 'irs-990': return 'IRS 990'; + case 'irs-990pf': return 'IRS 990-PF'; + case 'sec-edgar': return 'SEC EDGAR'; + case 'sec-13f': return 'SEC 13-F'; + case 'sec-form4': return 'SEC Form 4'; + case 'fec-contributions': return 'FEC contributions'; + case 'lobbying-federal': return 'Federal lobbying'; + case 'usaspending': return 'USAspending'; + case 'pacer': return 'PACER'; + case 'state-corps': return 'State incorporation'; + case 'ucc-filings': return 'UCC filings'; + case 'fda-faers': return 'FDA FAERS'; + case 'osha': return 'OSHA inspections'; + case 'nih-reporter': return 'NIH RePORTER'; + case 'cfpb-complaints': return 'CFPB complaints'; + default: return slug; + } +} diff --git a/web/ui/src/lib/keys.ts b/web/ui/src/lib/keys.ts new file mode 100644 index 0000000..5fddedc --- /dev/null +++ b/web/ui/src/lib/keys.ts @@ -0,0 +1,24 @@ +import { DATASETS } from './datasets'; + +/** Display mask: prefix + bullets. We don't store the full key, so we + * cannot show any trailing characters from it after creation. */ +export function maskKey(prefix: string): string { + return `${prefix}${'\u2022'.repeat(24)}`; +} + +/** Validate + normalize a scope list from user input. Used server-side + * when persisting a new key; kept in the client-safe module so the + * create-key form can mirror its logic if it wants. */ +export function normalizeScopes(input: string[]): string[] { + if (!input || input.length === 0) return ['*']; + if (input.includes('*')) return ['*']; + const allowed = new Set(DATASETS); + const out = input.filter((s) => allowed.has(s)); + return out.length === 0 ? ['*'] : out; +} + +export function scopeSummary(scopes: string[]): string { + if (scopes.length === 1 && scopes[0] === '*') return 'all datasets'; + if (scopes.length <= 3) return scopes.join(', '); + return `${scopes.slice(0, 2).join(', ')} +${scopes.length - 2} more`; +} diff --git a/web/ui/src/lib/plans.ts b/web/ui/src/lib/plans.ts new file mode 100644 index 0000000..bf6c474 --- /dev/null +++ b/web/ui/src/lib/plans.ts @@ -0,0 +1,82 @@ +export type PlanId = 'free' | 'dev' | 'pro' | 'enterprise'; + +export interface Plan { + id: PlanId; + name: string; + price: number | null; // null = custom / contact us + priceLabel: string; + period: string; + requestsPerMonth: number; // Infinity for enterprise + maxKeys: number; // Infinity for enterprise + features: string[]; + cta: string; +} + +export const PLANS: Record = { + free: { + id: 'free', + name: 'Free', + price: 0, + priceLabel: '$0', + period: 'forever', + requestsPerMonth: 1_000, + maxKeys: 1, + features: [ + '1,000 requests per month', + '1 API key', + 'JSON + JSONL access', + 'Community support' + ], + cta: 'Switch to Free' + }, + dev: { + id: 'dev', + name: 'Developer', + price: 100, + priceLabel: '$100', + period: '/ month', + requestsPerMonth: 50_000, + maxKeys: 5, + features: [ + '50,000 requests per month', + '5 API keys', + 'All response shapes', + 'Email support' + ], + cta: 'Switch to Developer' + }, + pro: { + id: 'pro', + name: 'Professional', + price: 1000, + priceLabel: '$1,000', + period: '/ month', + requestsPerMonth: 500_000, + maxKeys: 20, + features: [ + '500,000 requests per month', + '20 API keys', + 'Priority support', + 'SLA on uptime' + ], + cta: 'Switch to Pro' + }, + enterprise: { + id: 'enterprise', + name: 'Enterprise', + price: null, + priceLabel: 'Custom', + period: '', + requestsPerMonth: Number.POSITIVE_INFINITY, + maxKeys: Number.POSITIVE_INFINITY, + features: [ + 'Unlimited requests', + 'Unlimited keys', + 'Dedicated support', + 'Custom datasets and SLAs' + ], + cta: 'Contact us' + } +}; + +export const PLAN_ORDER: PlanId[] = ['free', 'dev', 'pro', 'enterprise']; diff --git a/web/ui/src/lib/server/auth.ts b/web/ui/src/lib/server/auth.ts new file mode 100644 index 0000000..6f202d3 --- /dev/null +++ b/web/ui/src/lib/server/auth.ts @@ -0,0 +1,153 @@ +import jwt from 'jsonwebtoken'; +import { randomBytes } from 'node:crypto'; +import { dev } from '$app/environment'; +import { env } from '$env/dynamic/private'; +import type { Cookies } from '@sveltejs/kit'; + +import { db, queries, newId, now, type Account } from './db'; + +function getJwtSecret(): string { + const secret = env.JWT_SECRET; + if (!secret) { + throw new Error( + 'JWT_SECRET env variable is required. Copy .env.example to .env ' + + 'and generate one with `openssl rand -base64 48`.' + ); + } + return secret; +} + +const COOKIE_NAME = 'ti_sess'; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year + +interface SessionPayload { + sub: string; // account id +} + +export function createSession(cookies: Cookies, accountId: string): void { + const token = jwt.sign( + { sub: accountId } satisfies SessionPayload, + getJwtSecret(), + { algorithm: 'HS256' } + ); + cookies.set(COOKIE_NAME, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: !dev, + maxAge: COOKIE_MAX_AGE + }); +} + +export function clearSession(cookies: Cookies): void { + cookies.delete(COOKIE_NAME, { path: '/' }); +} + +export function getAccountFromCookies(cookies: Cookies): Account | null { + const raw = cookies.get(COOKIE_NAME); + if (!raw) return null; + try { + const decoded = jwt.verify(raw, getJwtSecret(), { + algorithms: ['HS256'] + }) as SessionPayload; + if (!decoded.sub) return null; + const account = queries.accountById.get(decoded.sub); + return account ?? null; + } catch { + return null; + } +} + +// -------- account helpers -------- + +/** Create a brand-new anonymous account (no email). */ +export function createAnonymousAccount(): Account { + const id = newId(); + const ts = now(); + queries.insertAccount.run(id, null, 'free', ts); + return { id, email: null, pending_email: null, plan: 'free', created_at: ts }; +} + +/** Find an account by email or create a new one with that email. */ +export function getOrCreateAccountByEmail(email: string): Account { + const existing = queries.accountByEmail.get(email); + if (existing) return existing; + const id = newId(); + const ts = now(); + queries.insertAccount.run(id, email, 'free', ts); + return { id, email, pending_email: null, plan: 'free', created_at: ts }; +} + +// -------- magic links -------- + +export interface MagicLink { + token: string; + expires_at: number; + url: string; +} + +export function generateMagicLink( + accountId: string, + baseUrl: string +): MagicLink { + const token = randomBytes(24).toString('base64url'); + const id = newId(); + const ts = now(); + const expires = ts + 15 * 60 * 1000; // 15 minutes + queries.insertMagicLink.run(id, accountId, token, expires, ts); + const url = `${baseUrl.replace(/\/$/, '')}/auth/callback?token=${token}`; + return { token, expires_at: expires, url }; +} + +export function consumeMagicLink(token: string): Account | null { + const row = queries.magicLinkByToken.get(token); + if (!row) return null; + if (row.used === 1) return null; + if (row.expires_at < now()) return null; + queries.markMagicLinkUsed.run(row.id); + const account = queries.accountById.get(row.account_id); + return account ?? null; +} + +// -------- email attach / merge -------- + +/** + * Attach an email to an existing (anonymous) account. If another account + * already uses that email, merge this account into the other one — all + * API keys move over, the anonymous account is deleted, and the caller + * receives the surviving account id (which may be different from the + * one passed in). + */ +export function attachEmailToAccount( + accountId: string, + email: string +): { accountId: string; merged: boolean } { + const normalized = email.trim().toLowerCase(); + if (!normalized) throw new Error('Email is empty'); + + const current = queries.accountById.get(accountId); + if (!current) throw new Error('Account not found'); + + const existing = queries.accountByEmail.get(normalized); + + // Same account already has this email — no-op. + if (existing && existing.id === current.id) { + return { accountId: current.id, merged: false }; + } + + // No other account has this email — just attach it. + if (!existing) { + queries.updateAccountEmail.run(normalized, current.id); + return { accountId: current.id, merged: false }; + } + + // Another account already owns the email. Merge current into existing: + // move all keys over, then delete the current account. + const merge = db.transaction(() => { + queries.reassignKeys.run(existing.id, current.id); + queries.deleteAccount.run(current.id); + }); + merge(); + + return { accountId: existing.id, merged: true }; +} diff --git a/web/ui/src/lib/server/db.ts b/web/ui/src/lib/server/db.ts new file mode 100644 index 0000000..ee5736d --- /dev/null +++ b/web/ui/src/lib/server/db.ts @@ -0,0 +1,233 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { env } from '$env/dynamic/private'; + +import type { PlanId } from '$lib/plans'; + +const DB_PATH = resolve(env.DATABASE_PATH || './data/dashboard.db'); + +// Make sure the data/ directory exists before better-sqlite3 tries to open. +mkdirSync(dirname(DB_PATH), { recursive: true }); + +export const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// -------- schema -------- + +db.exec(` + CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE, + pending_email TEXT, + plan TEXT NOT NULL DEFAULT 'free', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + name TEXT NOT NULL, + scopes TEXT NOT NULL DEFAULT '["*"]', + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + last_used_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_api_keys_account ON api_keys(account_id); + + CREATE TABLE IF NOT EXISTS magic_links ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_magic_links_token ON magic_links(token); + + CREATE TABLE IF NOT EXISTS usage_events ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + dataset TEXT NOT NULL, + timestamp INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_events(api_key_id); + CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage_events(timestamp); + CREATE INDEX IF NOT EXISTS idx_usage_dataset ON usage_events(dataset); +`); + +// Idempotent migration: pre-existing dashboard.db files won't have +// pending_email yet. SQLite has no IF NOT EXISTS for ALTER, so check +// PRAGMA table_info first. +{ + const cols = db + .prepare(`PRAGMA table_info(accounts)`) + .all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === 'pending_email')) { + db.exec(`ALTER TABLE accounts ADD COLUMN pending_email TEXT`); + } +} + +// -------- types -------- + +export interface Account { + id: string; + email: string | null; + /** Email the user is mid-verifying. Cleared on completion or cancel. */ + pending_email: string | null; + plan: PlanId; + created_at: number; +} + +export interface ApiKeyRow { + id: string; + account_id: string; + key_hash: string; + key_prefix: string; + name: string; + scopes: string; // JSON string on disk + active: number; // 0 / 1 + created_at: number; + last_used_at: number | null; +} + +export interface ApiKey { + id: string; + account_id: string; + key_prefix: string; + name: string; + scopes: string[]; // decoded + active: boolean; + created_at: number; + last_used_at: number | null; +} + +export function rowToKey(row: ApiKeyRow): ApiKey { + return { + id: row.id, + account_id: row.account_id, + key_prefix: row.key_prefix, + name: row.name, + scopes: JSON.parse(row.scopes), + active: row.active === 1, + created_at: row.created_at, + last_used_at: row.last_used_at + }; +} + +// -------- helpers -------- + +export function now(): number { + return Date.now(); +} + +export function newId(): string { + return randomUUID(); +} + +// -------- prepared statements -------- + +const stmts = { + accountById: db.prepare( + `SELECT id, email, pending_email, plan, created_at FROM accounts WHERE id = ?` + ), + accountByEmail: db.prepare( + `SELECT id, email, pending_email, plan, created_at FROM accounts WHERE email = ?` + ), + insertAccount: db.prepare( + `INSERT INTO accounts (id, email, plan, created_at) VALUES (?, ?, ?, ?)` + ), + updateAccountEmail: db.prepare( + `UPDATE accounts SET email = ? WHERE id = ?` + ), + setPendingEmail: db.prepare( + `UPDATE accounts SET pending_email = ? WHERE id = ?` + ), + clearPendingEmail: db.prepare( + `UPDATE accounts SET pending_email = NULL WHERE id = ?` + ), + promotePendingEmail: db.prepare( + `UPDATE accounts SET email = pending_email, pending_email = NULL WHERE id = ?` + ), + updateAccountPlan: db.prepare( + `UPDATE accounts SET plan = ? WHERE id = ?` + ), + deleteAccount: db.prepare( + `DELETE FROM accounts WHERE id = ?` + ), + reassignKeys: db.prepare( + `UPDATE api_keys SET account_id = ? WHERE account_id = ?` + ), + reassignUsage: db.prepare( + `UPDATE usage_events SET api_key_id = api_key_id WHERE api_key_id IN + (SELECT id FROM api_keys WHERE account_id = ?)` + ), + keysForAccount: db.prepare( + `SELECT * FROM api_keys WHERE account_id = ? ORDER BY created_at DESC` + ), + keyById: db.prepare<[string, string], ApiKeyRow>( + `SELECT * FROM api_keys WHERE id = ? AND account_id = ?` + ), + insertKey: db.prepare( + `INSERT INTO api_keys + (id, account_id, key_hash, key_prefix, name, scopes, active, created_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?)` + ), + revokeKey: db.prepare( + `UPDATE api_keys SET active = 0 WHERE id = ? AND account_id = ?` + ), + countActiveKeys: db.prepare( + `SELECT COUNT(*) AS c FROM api_keys WHERE account_id = ? AND active = 1` + ), + insertMagicLink: db.prepare( + `INSERT INTO magic_links (id, account_id, token, expires_at, used, created_at) + VALUES (?, ?, ?, ?, 0, ?)` + ), + magicLinkByToken: db.prepare< + string, + { id: string; account_id: string; expires_at: number; used: number } + >( + `SELECT id, account_id, expires_at, used FROM magic_links WHERE token = ?` + ), + markMagicLinkUsed: db.prepare( + `UPDATE magic_links SET used = 1 WHERE id = ?` + ), + insertUsageEvent: db.prepare( + `INSERT INTO usage_events (id, api_key_id, dataset, timestamp) + VALUES (?, ?, ?, ?)` + ), + usageCountSince: db.prepare<[string, number], { c: number }>( + `SELECT COUNT(*) AS c FROM usage_events e + JOIN api_keys k ON k.id = e.api_key_id + WHERE k.account_id = ? AND e.timestamp >= ?` + ), + usageByDataset: db.prepare< + [string, number], + { dataset: string; c: number } + >( + `SELECT e.dataset AS dataset, COUNT(*) AS c + FROM usage_events e + JOIN api_keys k ON k.id = e.api_key_id + WHERE k.account_id = ? AND e.timestamp >= ? + GROUP BY e.dataset + ORDER BY c DESC` + ), + usageByKey: db.prepare< + [number, string], + { key_id: string; name: string; c: number } + >( + `SELECT k.id AS key_id, k.name AS name, COUNT(e.id) AS c + FROM api_keys k + LEFT JOIN usage_events e + ON e.api_key_id = k.id AND e.timestamp >= ? + WHERE k.account_id = ? + GROUP BY k.id + ORDER BY c DESC` + ) +}; + +export const queries = stmts; diff --git a/web/ui/src/lib/server/keys.ts b/web/ui/src/lib/server/keys.ts new file mode 100644 index 0000000..350839b --- /dev/null +++ b/web/ui/src/lib/server/keys.ts @@ -0,0 +1,79 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { queries, newId, now, rowToKey, type ApiKey } from './db'; +import { normalizeScopes } from '$lib/keys'; + +const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +/** + * Generate a new API key string (not yet persisted). + * Format: "ti_" + 32 random alphanumeric characters. + */ +export function mintKey(): { key: string; hash: string; prefix: string } { + const bytes = randomBytes(32); + let random = ''; + for (let i = 0; i < 32; i++) { + random += ALPHABET[bytes[i] % ALPHABET.length]; + } + const key = `ti_${random}`; + const hash = createHash('sha256').update(key).digest('hex'); + const prefix = key.slice(0, 8); // "ti_" + 5 chars + return { key, hash, prefix }; +} + +// -------- persistence -------- + +export interface CreateKeyInput { + accountId: string; + name: string; + scopes: string[]; +} + +export interface CreatedKey extends ApiKey { + /** The full plaintext key. Only returned on creation — never stored. */ + plaintext: string; +} + +export function createKey(input: CreateKeyInput): CreatedKey { + const { key, hash, prefix } = mintKey(); + const id = newId(); + const ts = now(); + const name = input.name.trim() || 'Untitled key'; + const scopes = normalizeScopes(input.scopes); + queries.insertKey.run( + id, + input.accountId, + hash, + prefix, + name, + JSON.stringify(scopes), + ts + ); + return { + id, + account_id: input.accountId, + key_prefix: prefix, + name, + scopes, + active: true, + created_at: ts, + last_used_at: null, + plaintext: key + }; +} + +export function listKeys(accountId: string): ApiKey[] { + return queries.keysForAccount.all(accountId).map(rowToKey); +} + +export function revokeKey(accountId: string, keyId: string): boolean { + const row = queries.keyById.get(keyId, accountId); + if (!row) return false; + queries.revokeKey.run(keyId, accountId); + return true; +} + +export function countActiveKeys(accountId: string): number { + return queries.countActiveKeys.get(accountId)?.c ?? 0; +} diff --git a/web/ui/src/lib/server/usage.ts b/web/ui/src/lib/server/usage.ts new file mode 100644 index 0000000..721cc3c --- /dev/null +++ b/web/ui/src/lib/server/usage.ts @@ -0,0 +1,33 @@ +import { queries } from './db'; + +export function startOfCurrentMonth(): number { + const d = new Date(); + d.setUTCDate(1); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); +} + +export function usageCountThisMonth(accountId: string): number { + return queries.usageCountSince.get(accountId, startOfCurrentMonth())?.c ?? 0; +} + +export function usageByDataset( + accountId: string +): Array<{ dataset: string; count: number }> { + const rows = queries.usageByDataset.all(accountId, startOfCurrentMonth()); + return rows.map((r: { dataset: string; c: number }) => ({ + dataset: r.dataset, + count: r.c + })); +} + +export function usageByKey( + accountId: string +): Array<{ keyId: string; name: string; count: number }> { + const rows = queries.usageByKey.all(startOfCurrentMonth(), accountId); + return rows.map((r: { key_id: string; name: string; c: number }) => ({ + keyId: r.key_id, + name: r.name, + count: r.c + })); +} diff --git a/web/ui/src/lib/stores/toasts.ts b/web/ui/src/lib/stores/toasts.ts new file mode 100644 index 0000000..27dd8c4 --- /dev/null +++ b/web/ui/src/lib/stores/toasts.ts @@ -0,0 +1,21 @@ +import { writable } from 'svelte/store'; + +export type ToastKind = 'info' | 'success' | 'error'; + +export interface Toast { + id: number; + kind: ToastKind; + message: string; +} + +let nextId = 1; + +export const toasts = writable([]); + +export function pushToast(message: string, kind: ToastKind = 'info'): void { + const id = nextId++; + toasts.update((list) => [...list, { id, kind, message }]); + setTimeout(() => { + toasts.update((list) => list.filter((t) => t.id !== id)); + }, 2500); +} diff --git a/web/ui/src/routes/+layout.server.ts b/web/ui/src/routes/+layout.server.ts new file mode 100644 index 0000000..37c08a0 --- /dev/null +++ b/web/ui/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { + account: locals.account + }; +}; diff --git a/web/ui/src/routes/+layout.svelte b/web/ui/src/routes/+layout.svelte new file mode 100644 index 0000000..ddd7c4c --- /dev/null +++ b/web/ui/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + + +{@render children()} diff --git a/web/ui/src/routes/+page.server.ts b/web/ui/src/routes/+page.server.ts new file mode 100644 index 0000000..2daf03d --- /dev/null +++ b/web/ui/src/routes/+page.server.ts @@ -0,0 +1,20 @@ +import { redirect } from '@sveltejs/kit'; + +import { createAnonymousAccount, createSession } from '$lib/server/auth'; +import type { PageServerLoad } from './$types'; + +/** + * The root route is just a bouncer: if you already have a session, + * go to the dashboard; if not, we mint an anonymous account on the + * spot, set a session cookie, and go to the dashboard. + * + * There is intentionally no sign-in page. Users can add an email on + * the Account tab later if they want a way to recover their keys. + */ +export const load: PageServerLoad = async ({ locals, cookies }) => { + if (!locals.account) { + const account = createAnonymousAccount(); + createSession(cookies, account.id); + } + throw redirect(303, '/dashboard'); +}; diff --git a/web/ui/src/routes/auth/callback/+server.ts b/web/ui/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..da8181b --- /dev/null +++ b/web/ui/src/routes/auth/callback/+server.ts @@ -0,0 +1,14 @@ +import { redirect, type RequestHandler } from '@sveltejs/kit'; + +import { consumeMagicLink, createSession } from '$lib/server/auth'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const token = url.searchParams.get('token'); + if (!token) throw redirect(303, '/?error=missing-token'); + + const account = consumeMagicLink(token); + if (!account) throw redirect(303, '/?error=invalid-token'); + + createSession(cookies, account.id); + throw redirect(303, '/dashboard'); +}; diff --git a/web/ui/src/routes/auth/logout/+server.ts b/web/ui/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..b17d1d5 --- /dev/null +++ b/web/ui/src/routes/auth/logout/+server.ts @@ -0,0 +1,13 @@ +import { redirect, type RequestHandler } from '@sveltejs/kit'; + +import { clearSession } from '$lib/server/auth'; + +export const POST: RequestHandler = async ({ cookies }) => { + clearSession(cookies); + throw redirect(303, '/'); +}; + +export const GET: RequestHandler = async ({ cookies }) => { + clearSession(cookies); + throw redirect(303, '/'); +}; diff --git a/web/ui/src/routes/dashboard/+layout.server.ts b/web/ui/src/routes/dashboard/+layout.server.ts new file mode 100644 index 0000000..8eebc76 --- /dev/null +++ b/web/ui/src/routes/dashboard/+layout.server.ts @@ -0,0 +1,12 @@ +import { redirect } from '@sveltejs/kit'; + +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + if (!locals.account) { + throw redirect(303, '/'); + } + return { + account: locals.account + }; +}; diff --git a/web/ui/src/routes/dashboard/+layout.svelte b/web/ui/src/routes/dashboard/+layout.svelte new file mode 100644 index 0000000..9137c75 --- /dev/null +++ b/web/ui/src/routes/dashboard/+layout.svelte @@ -0,0 +1,90 @@ + + + + Tidy Index — Dashboard + + +
+
+ + + Tidy Index + + +
+ + plan: {planInfo.name.toLowerCase()} + + {#if account?.email} + {account.email} + {:else if account?.pending_email} + + pending · {account.pending_email} + + {:else} + anonymous + {/if} +
+
+
+ +
+ {#if !account?.email && !account?.pending_email} + + {:else if account?.pending_email && !account?.email} + + {/if} + + +
+ +
+
+ {@render children()} +
+
+ +