chore: auto-commit uncommitted changes
This commit is contained in:
parent
9694a30f6c
commit
041227dfd7
|
|
@ -8,3 +8,4 @@ vault1984
|
|||
*.pem
|
||||
oss/app/backups/
|
||||
commercial/website/vault1984-web
|
||||
commercial/account/vault1984-account
|
||||
|
|
|
|||
|
|
@ -0,0 +1,802 @@
|
|||
/* vault1984 account — 2027 */
|
||||
|
||||
:root {
|
||||
--bg: #060a10;
|
||||
--bg-subtle: #0a1018;
|
||||
--surface: rgba(255,255,255,0.03);
|
||||
--surface-hover: rgba(255,255,255,0.06);
|
||||
--surface-active: rgba(255,255,255,0.08);
|
||||
--glass: rgba(12,18,30,0.7);
|
||||
--glass-border: rgba(255,255,255,0.06);
|
||||
--glass-border-hover: rgba(255,255,255,0.12);
|
||||
|
||||
--text: #f1f5f9;
|
||||
--text-secondary: #b0bec5;
|
||||
--text-tertiary: #8a9bb0;
|
||||
|
||||
--accent: #4ade80;
|
||||
--accent-dim: rgba(74,222,128,0.12);
|
||||
--accent-glow: rgba(74,222,128,0.25);
|
||||
--gold: #D4AF37;
|
||||
--gold-dim: rgba(212,175,55,0.12);
|
||||
--red: #ef4444;
|
||||
--red-dim: rgba(239,68,68,0.12);
|
||||
|
||||
--radius: 1rem;
|
||||
--radius-sm: 0.625rem;
|
||||
--radius-xs: 0.375rem;
|
||||
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Ambient background ── */
|
||||
|
||||
.ambient {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ambient::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
top: -200px;
|
||||
right: -200px;
|
||||
background: radial-gradient(circle, rgba(74,222,128,0.04) 0%, transparent 70%);
|
||||
animation: drift 25s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.ambient::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
bottom: -100px;
|
||||
left: -150px;
|
||||
background: radial-gradient(circle, rgba(212,175,55,0.03) 0%, transparent 70%);
|
||||
animation: drift 30s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
100% { transform: translate(60px, 40px) scale(1.1); }
|
||||
}
|
||||
|
||||
/* ── Grid lines (subtle background texture) ── */
|
||||
|
||||
.gridlines {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
|
||||
background-size: 80px 80px;
|
||||
mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 100%);
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.container-narrow {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 0.875rem 0;
|
||||
background: rgba(6,10,16,0.8);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.topbar-brand .port {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.topbar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.topbar-nav a {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: all 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.topbar-nav a:hover { color: var(--text); background: var(--surface-hover); }
|
||||
.topbar-nav a.active { color: var(--text); background: var(--surface-active); }
|
||||
|
||||
.topbar-nav .separator {
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background: var(--glass-border);
|
||||
margin: 0 0.375rem;
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.75rem, 3.5vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.label.accent { color: var(--accent); }
|
||||
.label.gold { color: var(--gold); }
|
||||
.label.red { color: var(--red); }
|
||||
|
||||
.mono { font-family: var(--mono); }
|
||||
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-accent { color: var(--accent); }
|
||||
.text-gold { color: var(--gold); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 550;
|
||||
padding: 0.6875rem 1.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s var(--ease);
|
||||
outline: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #0a0f14;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #6ee7a0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red-dim);
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(239,68,68,0.15);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239,68,68,0.18);
|
||||
border-color: rgba(239,68,68,0.3);
|
||||
}
|
||||
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.875rem 1.75rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.btn .icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s var(--ease);
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--glass-border-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.card-interactive:active { transform: translateY(0); }
|
||||
|
||||
.card-glow:hover {
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px var(--accent-dim);
|
||||
}
|
||||
|
||||
.card-gold {
|
||||
border-color: rgba(212,175,55,0.15);
|
||||
}
|
||||
|
||||
.card-gold:hover {
|
||||
border-color: rgba(212,175,55,0.3);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px var(--gold-dim);
|
||||
}
|
||||
|
||||
/* ── Glass panels ── */
|
||||
|
||||
.glass {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.3);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Forms ── */
|
||||
|
||||
.field { margin-bottom: 1.25rem; }
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
font-family: var(--font);
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: all 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.input::placeholder { color: var(--text-secondary); }
|
||||
|
||||
.input:hover { border-color: var(--glass-border-hover); }
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.input-mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Status indicators ── */
|
||||
|
||||
.status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.live {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
animation: pulse-live 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.expired {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 8px rgba(239,68,68,0.3);
|
||||
}
|
||||
|
||||
.status-dot.pending {
|
||||
background: var(--gold);
|
||||
box-shadow: 0 0 8px rgba(212,175,55,0.3);
|
||||
animation: pulse-live 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-live {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ── Region cards ── */
|
||||
|
||||
.region-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.region-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.125rem;
|
||||
}
|
||||
|
||||
.region-card .region-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.region-card .region-location {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.region-card .region-flag {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.region-card.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
box-shadow: 0 0 0 1px var(--accent-dim);
|
||||
}
|
||||
|
||||
.region-card.hq {
|
||||
border-color: rgba(212,175,55,0.2);
|
||||
}
|
||||
|
||||
.region-card.hq .label { color: var(--gold); }
|
||||
|
||||
/* ── Vault list ── */
|
||||
|
||||
.vault-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
transition: background 0.15s var(--ease);
|
||||
}
|
||||
|
||||
.vault-row:last-child { border-bottom: none; }
|
||||
.vault-row:hover { background: var(--surface-hover); }
|
||||
|
||||
.vault-id {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vault-region {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vault-meta {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Dividers ── */
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--glass-border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* ── Section spacing ── */
|
||||
|
||||
.section {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Login page ── */
|
||||
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2.5rem;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-header .brand {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--glass-border);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Checkout ── */
|
||||
|
||||
.checkout-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.checkout-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 2.5rem;
|
||||
}
|
||||
|
||||
.price-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.price-period {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.price-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.375rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.feature-list .check {
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Settings ── */
|
||||
|
||||
.settings-section {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.settings-section + .settings-section {
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Danger zone ── */
|
||||
|
||||
.danger-zone {
|
||||
border: 1px solid rgba(239,68,68,0.15);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.danger-zone h3 {
|
||||
color: var(--red);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Passkey button ── */
|
||||
|
||||
.btn-passkey {
|
||||
background: linear-gradient(135deg, rgba(74,222,128,0.1) 0%, rgba(74,222,128,0.05) 100%);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(74,222,128,0.2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-passkey:hover {
|
||||
background: linear-gradient(135deg, rgba(74,222,128,0.15) 0%, rgba(74,222,128,0.08) 100%);
|
||||
border-color: rgba(74,222,128,0.35);
|
||||
box-shadow: 0 4px 20px rgba(74,222,128,0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-passkey:active { transform: translateY(0); }
|
||||
|
||||
.passkey-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Toast / notification ── */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
transform: translateY(120%);
|
||||
opacity: 0;
|
||||
transition: all 0.35s var(--ease);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(74,222,128,0.2);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--red-dim);
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(239,68,68,0.2);
|
||||
}
|
||||
|
||||
/* ── Skeleton loading ── */
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 0 1rem; }
|
||||
.login-card, .checkout-card { padding: 1.75rem; }
|
||||
.region-grid { grid-template-columns: 1fr; }
|
||||
.vault-row { flex-wrap: wrap; }
|
||||
.vault-meta { margin-left: 0; width: 100%; justify-content: flex-start; }
|
||||
.settings-row { flex-direction: column; align-items: flex-start; gap: 0.75rem; }
|
||||
.topbar-nav { gap: 0; }
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.12); }
|
||||
|
||||
/* ── Selection ── */
|
||||
|
||||
::selection { background: var(--accent-dim); color: var(--text); }
|
||||
|
||||
/* ── Focus management ── */
|
||||
|
||||
:focus:not(:focus-visible) { outline: none; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ── Transitions for page content ── */
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s var(--ease-out) both;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-in-delay-1 { animation-delay: 0.05s; }
|
||||
.fade-in-delay-2 { animation-delay: 0.1s; }
|
||||
.fade-in-delay-3 { animation-delay: 0.15s; }
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,306 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func randomToken(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b)
|
||||
}
|
||||
|
||||
func jsonResp(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, status int, msg string) {
|
||||
jsonResp(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// Get email from session cookie
|
||||
func authEmail(r *http.Request) string {
|
||||
c, err := r.Cookie("v1984_session")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
email, err := sessionGet(c.Value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "v1984_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "v1984_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/auth/email — send login code (stubbed: always 123456)
|
||||
func apiAuthEmail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
|
||||
jsonErr(w, 400, "email required")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
// Auto-create account if it doesn't exist
|
||||
exists, _, _ := accountGet(email)
|
||||
if !exists {
|
||||
if err := accountCreate(email); err != nil {
|
||||
log.Printf("account auto-create error: %v", err)
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
log.Printf("account auto-created: %s", email)
|
||||
}
|
||||
|
||||
// Store code (stub: always 123456)
|
||||
code := "123456"
|
||||
if err := loginCodeSet(email, code); err != nil {
|
||||
log.Printf("login code error: %v", err)
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("login code for %s: %s (stub — not emailed)", email, code)
|
||||
jsonResp(w, 200, map[string]string{"status": "sent"})
|
||||
}
|
||||
|
||||
// POST /api/auth/verify — verify login code
|
||||
func apiAuthVerify(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" || req.Code == "" {
|
||||
jsonErr(w, 400, "email and code required")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
ok, err := loginCodeVerify(email, req.Code)
|
||||
if err != nil || !ok {
|
||||
jsonErr(w, 401, "invalid or expired code")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := sessionCreate(email)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
setSessionCookie(w, token)
|
||||
jsonResp(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// POST /api/auth/logout
|
||||
func apiAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("v1984_session"); err == nil {
|
||||
sessionDelete(c.Value)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
jsonResp(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// POST /api/checkout — create account (stub: no Stripe yet)
|
||||
func apiCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
|
||||
jsonErr(w, 400, "email required")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
// Create account (idempotent)
|
||||
if err := accountCreate(email); err != nil {
|
||||
log.Printf("account create error: %v", err)
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-login after checkout (stub — real flow goes through Stripe first)
|
||||
token, err := sessionCreate(email)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, token)
|
||||
|
||||
log.Printf("account created (stub): %s", email)
|
||||
// In production: return Stripe checkout URL
|
||||
// For now: redirect to regions page
|
||||
jsonResp(w, 200, map[string]string{"url": basePath + "/regions"})
|
||||
}
|
||||
|
||||
// GET /api/vaults — list vaults for authenticated user
|
||||
func apiVaults(w http.ResponseWriter, r *http.Request) {
|
||||
email := authEmail(r)
|
||||
if email == "" {
|
||||
jsonErr(w, 401, "not authenticated")
|
||||
return
|
||||
}
|
||||
vaults, err := vaultList(email)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
if vaults == nil {
|
||||
vaults = []Vault{}
|
||||
}
|
||||
count, _ := vaultCount(email)
|
||||
jsonResp(w, 200, map[string]any{
|
||||
"email": email,
|
||||
"vaults": vaults,
|
||||
"count": count,
|
||||
"max": 1, // consumer: 1 vault
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/vault/create
|
||||
func apiVaultCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
email := authEmail(r)
|
||||
if email == "" {
|
||||
jsonErr(w, 401, "not authenticated")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
VaultID string `json:"vault_id"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Region == "" {
|
||||
jsonErr(w, 400, "region required")
|
||||
return
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
count, _ := vaultCount(email)
|
||||
if count >= 1 {
|
||||
jsonErr(w, 409, "vault limit reached")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate vault ID if not provided (normally comes from PRF on the POP)
|
||||
vaultID := req.VaultID
|
||||
if vaultID == "" {
|
||||
vaultID = randomToken(4)[:6] // 6 chars, stub
|
||||
}
|
||||
|
||||
if err := vaultCreate(vaultID, email, req.Region); err != nil {
|
||||
log.Printf("vault create error: %v", err)
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("vault created: %s for %s in %s", vaultID, email, req.Region)
|
||||
jsonResp(w, 201, map[string]string{
|
||||
"vault_id": vaultID,
|
||||
"region": req.Region,
|
||||
"expires_at": time.Now().AddDate(1, 0, 0).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/vault/{id}/delete
|
||||
func apiVaultDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
email := authEmail(r)
|
||||
if email == "" {
|
||||
jsonErr(w, 401, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract vault ID from path: /api/vault/{id}/delete
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
var vaultID string
|
||||
for i, p := range parts {
|
||||
if p == "vault" && i+1 < len(parts) {
|
||||
vaultID = parts[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if vaultID == "" {
|
||||
jsonErr(w, 400, "vault_id required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := vaultDelete(vaultID, email); err != nil {
|
||||
jsonErr(w, 404, "vault not found")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("vault deleted: %s for %s", vaultID, email)
|
||||
jsonResp(w, 200, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// GET /api/account — account info
|
||||
func apiAccount(w http.ResponseWriter, r *http.Request) {
|
||||
email := authEmail(r)
|
||||
if email == "" {
|
||||
jsonErr(w, 401, "not authenticated")
|
||||
return
|
||||
}
|
||||
exists, stripeID, _ := accountGet(email)
|
||||
if !exists {
|
||||
jsonErr(w, 404, "account not found")
|
||||
return
|
||||
}
|
||||
count, _ := vaultCount(email)
|
||||
jsonResp(w, 200, map[string]any{
|
||||
"email": email,
|
||||
"stripe_id": stripeID,
|
||||
"vaults": count,
|
||||
"max": 1,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func initDB(path string) {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", path+"?_journal=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
log.Fatalf("db open: %v", err)
|
||||
}
|
||||
|
||||
migrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
email TEXT PRIMARY KEY,
|
||||
stripe_id TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS vaults (
|
||||
vault_id TEXT PRIMARY KEY,
|
||||
account_email TEXT NOT NULL REFERENCES accounts(email),
|
||||
region TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL REFERENCES accounts(email),
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS login_codes (
|
||||
email TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, err := db.Exec(m); err != nil {
|
||||
log.Fatalf("migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println(" database ready")
|
||||
}
|
||||
|
||||
// Accounts
|
||||
|
||||
func accountGet(email string) (bool, string, error) {
|
||||
var stripeID string
|
||||
err := db.QueryRow("SELECT stripe_id FROM accounts WHERE email = ?", email).Scan(&stripeID)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return true, stripeID, nil
|
||||
}
|
||||
|
||||
func accountCreate(email string) error {
|
||||
_, err := db.Exec("INSERT OR IGNORE INTO accounts (email) VALUES (?)", email)
|
||||
return err
|
||||
}
|
||||
|
||||
// Vaults
|
||||
|
||||
func vaultList(email string) ([]Vault, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT vault_id, region, expires_at, created_at FROM vaults WHERE account_email = ? ORDER BY created_at",
|
||||
email,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vaults []Vault
|
||||
for rows.Next() {
|
||||
var v Vault
|
||||
if err := rows.Scan(&v.ID, &v.Region, &v.ExpiresAt, &v.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vaults = append(vaults, v)
|
||||
}
|
||||
return vaults, nil
|
||||
}
|
||||
|
||||
func vaultCount(email string) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM vaults WHERE account_email = ?", email).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func vaultCreate(id, email, region string) error {
|
||||
expires := time.Now().AddDate(1, 0, 0).UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO vaults (vault_id, account_email, region, expires_at) VALUES (?, ?, ?, ?)",
|
||||
id, email, region, expires,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func vaultDelete(id, email string) error {
|
||||
res, err := db.Exec("DELETE FROM vaults WHERE vault_id = ? AND account_email = ?", id, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sessions
|
||||
|
||||
func sessionCreate(email string) (string, error) {
|
||||
token := randomToken(32)
|
||||
expires := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO sessions (token, email, expires_at) VALUES (?, ?, ?)",
|
||||
token, email, expires,
|
||||
)
|
||||
return token, err
|
||||
}
|
||||
|
||||
func sessionGet(token string) (string, error) {
|
||||
var email string
|
||||
var expires string
|
||||
err := db.QueryRow("SELECT email, expires_at FROM sessions WHERE token = ?", token).Scan(&email, &expires)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, expires)
|
||||
if time.Now().After(t) {
|
||||
db.Exec("DELETE FROM sessions WHERE token = ?", token)
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func sessionDelete(token string) {
|
||||
db.Exec("DELETE FROM sessions WHERE token = ?", token)
|
||||
}
|
||||
|
||||
// Login codes
|
||||
|
||||
func loginCodeSet(email, code string) error {
|
||||
expires := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(
|
||||
"INSERT OR REPLACE INTO login_codes (email, code, expires_at) VALUES (?, ?, ?)",
|
||||
email, code, expires,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func loginCodeVerify(email, code string) (bool, error) {
|
||||
var stored string
|
||||
var expires string
|
||||
err := db.QueryRow("SELECT code, expires_at FROM login_codes WHERE email = ?", email).Scan(&stored, &expires)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, expires)
|
||||
if time.Now().After(t) {
|
||||
db.Exec("DELETE FROM login_codes WHERE email = ?", email)
|
||||
return false, nil
|
||||
}
|
||||
if stored != code {
|
||||
return false, nil
|
||||
}
|
||||
db.Exec("DELETE FROM login_codes WHERE email = ?", email)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Types
|
||||
|
||||
type Vault struct {
|
||||
ID string `json:"vault_id"`
|
||||
Region string `json:"region"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#0A1628"/>
|
||||
<text y=".9em" font-size="72" x="14" fill="#22C55E" font-family="monospace" font-weight="bold">v</text>
|
||||
<text y=".9em" font-size="72" x="44" fill="#22C55E" font-family="monospace" font-weight="bold" opacity="0.7">84</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 355 B |
|
|
@ -0,0 +1,5 @@
|
|||
module vault1984.com/account
|
||||
|
||||
go 1.23.6
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates/*.tmpl account.css favicon.svg
|
||||
var embedded embed.FS
|
||||
|
||||
var devMode bool
|
||||
var basePath string
|
||||
|
||||
type PageData struct {
|
||||
Page string
|
||||
Title string
|
||||
Desc string
|
||||
ActiveNav string
|
||||
Base string
|
||||
Data any
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8098"
|
||||
}
|
||||
|
||||
devMode = os.Getenv("DEV") == "1"
|
||||
basePath = strings.TrimRight(os.Getenv("BASE_PATH"), "/")
|
||||
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "account.db"
|
||||
}
|
||||
initDB(dbPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Pages
|
||||
mux.HandleFunc("/", handleIndex)
|
||||
mux.HandleFunc("/login", handleLogin)
|
||||
mux.HandleFunc("/verify", handleVerify)
|
||||
mux.HandleFunc("/dashboard", handleDashboard)
|
||||
mux.HandleFunc("/checkout", handleCheckout)
|
||||
mux.HandleFunc("/regions", handleRegions)
|
||||
mux.HandleFunc("/settings", handleSettings)
|
||||
|
||||
// API
|
||||
mux.HandleFunc("/api/auth/email", apiAuthEmail)
|
||||
mux.HandleFunc("/api/auth/verify", apiAuthVerify)
|
||||
mux.HandleFunc("/api/auth/logout", apiAuthLogout)
|
||||
mux.HandleFunc("/api/checkout", apiCheckout)
|
||||
mux.HandleFunc("/api/vaults", apiVaults)
|
||||
mux.HandleFunc("/api/vault/create", apiVaultCreate)
|
||||
mux.HandleFunc("/api/vault/", apiVaultDelete) // /api/vault/{id}/delete
|
||||
mux.HandleFunc("/api/account", apiAccount)
|
||||
|
||||
// Static assets
|
||||
mux.HandleFunc("/account.css", serveStatic("account.css", "text/css"))
|
||||
mux.HandleFunc("/favicon.svg", serveStatic("favicon.svg", "image/svg+xml"))
|
||||
|
||||
// Strip .html extensions
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".html") {
|
||||
http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, ".html"), http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
log.Printf("vault1984 account · :%s", port)
|
||||
if devMode {
|
||||
log.Println(" dev mode: templates reload from disk")
|
||||
}
|
||||
log.Fatal(http.ListenAndServe(":"+port, handler))
|
||||
}
|
||||
|
||||
func loadTemplates(page string) (*template.Template, error) {
|
||||
if devMode {
|
||||
base := filepath.Join("templates", "base.tmpl")
|
||||
pg := filepath.Join("templates", page+".tmpl")
|
||||
return template.ParseFiles(base, pg)
|
||||
}
|
||||
return template.ParseFS(embedded, "templates/base.tmpl", "templates/"+page+".tmpl")
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, page string, data PageData) {
|
||||
data.Base = basePath
|
||||
tmpl, err := loadTemplates(page)
|
||||
if err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "internal error", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveStatic(name, contentType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var data []byte
|
||||
var err error
|
||||
if devMode {
|
||||
data, err = os.ReadFile(name)
|
||||
} else {
|
||||
data, err = fs.ReadFile(embedded, name)
|
||||
}
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Already logged in? Go to dashboard
|
||||
if authEmail(r) != "" {
|
||||
http.Redirect(w, r, basePath+"/dashboard", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
render(w, "login", PageData{
|
||||
Page: "login",
|
||||
Title: "Sign in — vault1984",
|
||||
Desc: "Sign in to your vault1984 account",
|
||||
})
|
||||
}
|
||||
|
||||
func handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, "verify", PageData{
|
||||
Page: "verify",
|
||||
Title: "Enter code — vault1984",
|
||||
Desc: "Verify your login code",
|
||||
Data: r.URL.Query().Get("email"),
|
||||
})
|
||||
}
|
||||
|
||||
func handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if authEmail(r) == "" {
|
||||
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
render(w, "dashboard", PageData{
|
||||
Page: "dashboard",
|
||||
Title: "Dashboard — vault1984",
|
||||
Desc: "Manage your vaults",
|
||||
ActiveNav: "dashboard",
|
||||
})
|
||||
}
|
||||
|
||||
func handleCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, "checkout", PageData{
|
||||
Page: "checkout",
|
||||
Title: "Get started — vault1984",
|
||||
Desc: "Create your vault1984 account",
|
||||
})
|
||||
}
|
||||
|
||||
func handleRegions(w http.ResponseWriter, r *http.Request) {
|
||||
if authEmail(r) == "" {
|
||||
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
render(w, "regions", PageData{
|
||||
Page: "regions",
|
||||
Title: "Choose your region — vault1984",
|
||||
Desc: "Pick where your vault lives",
|
||||
ActiveNav: "dashboard",
|
||||
})
|
||||
}
|
||||
|
||||
func handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if authEmail(r) == "" {
|
||||
http.Redirect(w, r, basePath+"/login", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
render(w, "settings", PageData{
|
||||
Page: "settings",
|
||||
Title: "Settings — vault1984",
|
||||
Desc: "Account settings",
|
||||
ActiveNav: "settings",
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<meta name="description" content="{{.Desc}}">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="icon" href="{{.Base}}/favicon.svg" type="image/svg+xml">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;550;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{.Base}}/account.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient"></div>
|
||||
<div class="gridlines"></div>
|
||||
<div class="page">
|
||||
{{template "page" .}}
|
||||
</div>
|
||||
{{template "scripts" .}}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
{{define "page"}}
|
||||
<div class="checkout-wrapper">
|
||||
<div class="checkout-card glass fade-in">
|
||||
<span class="label accent">Get started</span>
|
||||
<h1 style="margin-top:0.5rem">Your vault awaits</h1>
|
||||
|
||||
<div class="price-display">
|
||||
<span class="price-amount">$12</span>
|
||||
<span class="price-period">/year</span>
|
||||
</div>
|
||||
<div class="price-note">
|
||||
30-day money-back guarantee. No questions asked.
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<ul class="feature-list">
|
||||
<li>
|
||||
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
|
||||
Zero-knowledge encryption — we cannot read your vault
|
||||
</li>
|
||||
<li>
|
||||
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
|
||||
Field-level AI visibility — agents see what you allow
|
||||
</li>
|
||||
<li>
|
||||
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
|
||||
23 global regions — pick where your data lives
|
||||
</li>
|
||||
<li>
|
||||
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
|
||||
WebAuthn PRF — hardware key is the only key
|
||||
</li>
|
||||
<li>
|
||||
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
|
||||
FIPS 140-3 certified cryptography
|
||||
</li>
|
||||
<li>
|
||||
<svg class="check" viewBox="0 0 16 16" width="16" height="16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 1 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>
|
||||
MCP, CLI, browser extension, REST API
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<form id="checkout-form" onsubmit="return startCheckout(event)">
|
||||
<div class="field">
|
||||
<label for="checkout-email">Email address</label>
|
||||
<input type="email" id="checkout-email" class="input" placeholder="you@example.com" required autocomplete="email" autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-lg" id="checkout-btn">
|
||||
Continue to payment
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
Already have an account? <a href="{{.Base}}/login">Sign in</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:1rem">
|
||||
<span style="font-size:0.75rem;color:var(--text-secondary)">
|
||||
Payments processed by Stripe. Cancel anytime.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
async function startCheckout(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('checkout-email').value;
|
||||
const btn = document.getElementById('checkout-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Redirecting to Stripe…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{.Base}}/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
if (!resp.ok) throw new Error('Could not start checkout');
|
||||
const { url } = await resp.json();
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Continue to payment';
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.className = 'toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type;
|
||||
requestAnimationFrame(() => t.classList.add('show'));
|
||||
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
{{define "page"}}
|
||||
<header class="topbar">
|
||||
<div class="container">
|
||||
<div class="topbar-inner">
|
||||
<a href="{{.Base}}/dashboard" class="topbar-brand">vault1984 <span class="port">account</span></a>
|
||||
<nav class="topbar-nav">
|
||||
<a href="{{.Base}}/dashboard" class="{{if eq .ActiveNav "dashboard"}}active{{end}}">Vaults</a>
|
||||
<a href="{{.Base}}/settings" class="{{if eq .ActiveNav "settings"}}active{{end}}">Settings</a>
|
||||
<div class="separator"></div>
|
||||
<a href="#" id="logout-link">Sign out</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container" style="flex:1">
|
||||
<section class="section fade-in">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<span class="label accent">Account</span>
|
||||
<h1 style="margin-top:0.5rem">Your vaults</h1>
|
||||
</div>
|
||||
<a href="{{.Base}}/regions" class="btn btn-primary" id="new-vault-btn">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New vault
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Vault list (loaded dynamically) -->
|
||||
<section class="fade-in fade-in-delay-1">
|
||||
<div class="card" id="vault-list" style="padding:0; overflow:hidden">
|
||||
<div class="vault-row" style="justify-content:center;color:var(--text-tertiary);padding:2rem">
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Plan info -->
|
||||
<section class="section fade-in fade-in-delay-2">
|
||||
<div class="card" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap">
|
||||
<div>
|
||||
<span class="label" style="margin-bottom:0.25rem;display:block">Plan</span>
|
||||
<div style="font-weight:600;font-size:1.0625rem">Consumer</div>
|
||||
<div class="text-secondary" style="font-size:0.8125rem;margin-top:0.125rem" id="plan-info">$12/year</div>
|
||||
</div>
|
||||
<a href="{{.Base}}/settings" class="btn btn-ghost">Manage billing</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<section class="fade-in fade-in-delay-3" style="padding-bottom:3rem">
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem">
|
||||
<div class="card" style="text-align:center;padding:1.25rem">
|
||||
<div style="font-size:1.75rem;font-weight:700;letter-spacing:-0.03em" id="stat-vaults">-</div>
|
||||
<div class="label" style="margin-top:0.375rem">Vaults</div>
|
||||
</div>
|
||||
<div class="card" style="text-align:center;padding:1.25rem">
|
||||
<div style="font-size:1.75rem;font-weight:700;letter-spacing:-0.03em;color:var(--accent)" id="stat-days">-</div>
|
||||
<div class="label" style="margin-top:0.375rem">Days left</div>
|
||||
</div>
|
||||
<div class="card" style="text-align:center;padding:1.25rem">
|
||||
<div style="font-size:1.75rem;font-weight:700;letter-spacing:-0.03em" id="stat-regions">-</div>
|
||||
<div class="label" style="margin-top:0.375rem">Regions</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const BASE = '{{.Base}}';
|
||||
const REGIONS = {
|
||||
zurich:'Zürich, Switzerland', virginia:'Virginia, US', sanfrancisco:'San Francisco, US',
|
||||
montreal:'Montréal, Canada', mexico:'Mexico City, Mexico', bogota:'Bogotá, Colombia',
|
||||
saopaulo:'São Paulo, Brazil', santiago:'Santiago, Chile', buenosaires:'Buenos Aires, Argentina',
|
||||
london:'London, UK', madrid:'Madrid, Spain',
|
||||
istanbul:'Istanbul, Turkey', dubai:'Dubai, UAE', capetown:'Cape Town, South Africa',
|
||||
mumbai:'Mumbai, India', singapore:'Singapore', hongkong:'Hong Kong',
|
||||
seoul:'Seoul, South Korea', tokyo:'Tokyo, Japan', sydney:'Sydney, Australia',
|
||||
almaty:'Almaty, Kazakhstan'
|
||||
};
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const resp = await fetch(BASE + '/api/vaults');
|
||||
if (resp.status === 401) { window.location.href = BASE + '/login'; return; }
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
const data = await resp.json();
|
||||
|
||||
const list = document.getElementById('vault-list');
|
||||
const vaults = data.vaults || [];
|
||||
|
||||
if (vaults.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:3rem 2rem;gap:1rem;text-align:center">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<h2 style="color:var(--text)">You have no vaults yet</h2>
|
||||
<p style="color:var(--text-secondary);max-width:320px;font-size:0.9375rem;line-height:1.6">
|
||||
Pick a region and create your first vault. Your data stays in that region — encrypted, zero-knowledge, yours.
|
||||
</p>
|
||||
<a href="${BASE}/regions" class="btn btn-primary btn-lg" style="margin-top:0.5rem">
|
||||
Choose a region and create your vault
|
||||
</a>
|
||||
</div>`;
|
||||
} else {
|
||||
list.innerHTML = vaults.map(v => {
|
||||
const expires = new Date(v.expires_at);
|
||||
const days = Math.ceil((expires - new Date()) / 86400000);
|
||||
const isHQ = v.region === 'zurich';
|
||||
const status = days > 0 ? 'live' : 'expired';
|
||||
const regionName = REGIONS[v.region] || v.region;
|
||||
return `
|
||||
<div class="vault-row">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem">
|
||||
<span class="status-dot ${status}"></span>
|
||||
<div>
|
||||
<div class="vault-id">vault1984-${v.vault_id}</div>
|
||||
<div class="vault-region">${regionName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-meta">
|
||||
${isHQ ? '<span class="label gold">HQ</span>' : ''}
|
||||
<span>${days > 0 ? 'Expires' : 'Expired'} ${expires.toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'})}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Stats
|
||||
document.getElementById('stat-vaults').textContent = vaults.length;
|
||||
const regions = new Set(vaults.map(v => v.region));
|
||||
document.getElementById('stat-regions').textContent = regions.size;
|
||||
|
||||
if (vaults.length > 0) {
|
||||
const earliest = vaults.map(v => new Date(v.expires_at)).sort((a,b) => a-b)[0];
|
||||
const days = Math.max(0, Math.ceil((earliest - new Date()) / 86400000));
|
||||
document.getElementById('stat-days').textContent = days;
|
||||
} else {
|
||||
document.getElementById('stat-days').textContent = '-';
|
||||
}
|
||||
|
||||
// Hide "New vault" if at max
|
||||
if (data.count >= data.max) {
|
||||
document.getElementById('new-vault-btn').style.display = 'none';
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('vault-list').innerHTML =
|
||||
'<div class="vault-row" style="justify-content:center;color:var(--red)">Failed to load vaults</div>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('logout-link')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch(BASE + '/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = BASE + '/login';
|
||||
});
|
||||
|
||||
loadDashboard();
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
{{define "page"}}
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card glass fade-in">
|
||||
<div class="login-header">
|
||||
<div class="brand">vault1984</div>
|
||||
<p>Sign in to manage your vaults</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-passkey btn-block btn-lg" onclick="passkeyLogin()">
|
||||
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z"/>
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>
|
||||
</svg>
|
||||
Sign in with passkey
|
||||
</button>
|
||||
|
||||
<div class="login-divider">or</div>
|
||||
|
||||
<form id="email-form" onsubmit="return emailLogin(event)">
|
||||
<div class="field">
|
||||
<label for="email">Email address</label>
|
||||
<input type="email" id="email" class="input" placeholder="you@example.com" required autocomplete="email" autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-ghost btn-block">
|
||||
Continue with email
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
No account? <a href="{{.Base}}/checkout">Get started</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
async function passkeyLogin() {
|
||||
const btn = document.querySelector('.btn-passkey');
|
||||
const orig = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span style="opacity:0.6">Waiting for authenticator…</span>';
|
||||
|
||||
try {
|
||||
// WebAuthn authentication
|
||||
const resp = await fetch('{{.Base}}/api/auth/passkey/options', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Server error');
|
||||
const options = await resp.json();
|
||||
|
||||
// TODO: navigator.credentials.get() with options
|
||||
// For now, simulate
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
|
||||
// On success, redirect to dashboard
|
||||
window.location.href = '{{.Base}}/dashboard';
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Authentication failed', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}
|
||||
}
|
||||
|
||||
async function emailLogin(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('email').value;
|
||||
const btn = e.target.querySelector('button');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending link…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{.Base}}/api/auth/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
if (!resp.ok) throw new Error('Could not send login code');
|
||||
// Redirect to code entry page
|
||||
window.location.href = '{{.Base}}/verify?email=' + encodeURIComponent(email);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Continue with email';
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.className = 'toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type;
|
||||
requestAnimationFrame(() => t.classList.add('show'));
|
||||
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
{{define "page"}}
|
||||
<header class="topbar">
|
||||
<div class="container">
|
||||
<div class="topbar-inner">
|
||||
<a href="{{.Base}}/dashboard" class="topbar-brand">vault1984 <span class="port">account</span></a>
|
||||
<nav class="topbar-nav">
|
||||
<a href="{{.Base}}/dashboard">Vaults</a>
|
||||
<a href="{{.Base}}/settings">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container" style="flex:1">
|
||||
<section class="section fade-in">
|
||||
<span class="label accent">New vault</span>
|
||||
<h1 style="margin-top:0.5rem">Choose your region</h1>
|
||||
<p class="text-secondary" style="margin-top:0.5rem;max-width:480px">
|
||||
Your vault lives in one region. All data stays there. Pick the closest location for the best performance.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="fade-in fade-in-delay-1" style="padding-bottom:1.5rem">
|
||||
<div class="label gold" style="margin-bottom:0.75rem">Headquarters</div>
|
||||
<div class="region-grid">
|
||||
<div class="card card-interactive card-gold region-card" data-region="zurich" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇨🇭</span>
|
||||
<span class="region-name">Zürich</span>
|
||||
<span class="region-location">Switzerland</span>
|
||||
<span class="label gold">HQ · Swiss jurisdiction</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fade-in fade-in-delay-2" style="padding-bottom:1.5rem">
|
||||
<div class="label" style="margin-bottom:0.75rem">Americas</div>
|
||||
<div class="region-grid">
|
||||
<div class="card card-interactive region-card" data-region="virginia" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇺🇸</span>
|
||||
<span class="region-name">Virginia</span>
|
||||
<span class="region-location">US East</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="sanfrancisco" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇺🇸</span>
|
||||
<span class="region-name">San Francisco</span>
|
||||
<span class="region-location">US West</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="montreal" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇨🇦</span>
|
||||
<span class="region-name">Montréal</span>
|
||||
<span class="region-location">Canada</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="mexico" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇲🇽</span>
|
||||
<span class="region-name">Mexico City</span>
|
||||
<span class="region-location">Mexico</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="bogota" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇨🇴</span>
|
||||
<span class="region-name">Bogotá</span>
|
||||
<span class="region-location">Colombia</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="saopaulo" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇧🇷</span>
|
||||
<span class="region-name">São Paulo</span>
|
||||
<span class="region-location">Brazil</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="santiago" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇨🇱</span>
|
||||
<span class="region-name">Santiago</span>
|
||||
<span class="region-location">Chile</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="buenosaires" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇦🇷</span>
|
||||
<span class="region-name">Buenos Aires</span>
|
||||
<span class="region-location">Argentina</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fade-in fade-in-delay-3" style="padding-bottom:1.5rem">
|
||||
<div class="label" style="margin-bottom:0.75rem">Europe</div>
|
||||
<div class="region-grid">
|
||||
<div class="card card-interactive region-card" data-region="london" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇬🇧</span>
|
||||
<span class="region-name">London</span>
|
||||
<span class="region-location">United Kingdom</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="madrid" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇪🇸</span>
|
||||
<span class="region-name">Madrid</span>
|
||||
<span class="region-location">Spain</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fade-in fade-in-delay-3" style="padding-bottom:1.5rem">
|
||||
<div class="label" style="margin-bottom:0.75rem">Middle East & Africa</div>
|
||||
<div class="region-grid">
|
||||
<div class="card card-interactive region-card" data-region="istanbul" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇹🇷</span>
|
||||
<span class="region-name">Istanbul</span>
|
||||
<span class="region-location">Turkey</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="dubai" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇦🇪</span>
|
||||
<span class="region-name">Dubai</span>
|
||||
<span class="region-location">UAE</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="capetown" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇿🇦</span>
|
||||
<span class="region-name">Cape Town</span>
|
||||
<span class="region-location">South Africa</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fade-in fade-in-delay-3" style="padding-bottom:3rem">
|
||||
<div class="label" style="margin-bottom:0.75rem">Asia Pacific</div>
|
||||
<div class="region-grid">
|
||||
<div class="card card-interactive region-card" data-region="mumbai" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇮🇳</span>
|
||||
<span class="region-name">Mumbai</span>
|
||||
<span class="region-location">India</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="singapore" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇸🇬</span>
|
||||
<span class="region-name">Singapore</span>
|
||||
<span class="region-location">Singapore</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="hongkong" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇭🇰</span>
|
||||
<span class="region-name">Hong Kong</span>
|
||||
<span class="region-location">China</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="seoul" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇰🇷</span>
|
||||
<span class="region-name">Seoul</span>
|
||||
<span class="region-location">South Korea</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="tokyo" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇯🇵</span>
|
||||
<span class="region-name">Tokyo</span>
|
||||
<span class="region-location">Japan</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="sydney" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇦🇺</span>
|
||||
<span class="region-name">Sydney</span>
|
||||
<span class="region-location">Australia</span>
|
||||
</div>
|
||||
<div class="card card-interactive region-card" data-region="almaty" onclick="selectRegion(this)">
|
||||
<span class="region-flag">🇰🇿</span>
|
||||
<span class="region-name">Almaty</span>
|
||||
<span class="region-location">Kazakhstan</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sticky bottom bar -->
|
||||
<div id="region-confirm" style="position:fixed;bottom:0;left:0;right:0;transform:translateY(100%);transition:transform 0.35s cubic-bezier(0.16,1,0.3,1);z-index:50">
|
||||
<div style="background:rgba(6,10,16,0.9);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-top:1px solid var(--glass-border);padding:1rem 0">
|
||||
<div class="container" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;max-width:960px">
|
||||
<div>
|
||||
<span id="selected-name" style="font-weight:600"></span>
|
||||
<span id="selected-location" class="text-secondary" style="font-size:0.875rem;margin-left:0.5rem"></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="confirmRegion()">
|
||||
Create vault here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
let selectedRegion = null;
|
||||
|
||||
function selectRegion(el) {
|
||||
document.querySelectorAll('.region-card.selected').forEach(c => c.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
selectedRegion = el.dataset.region;
|
||||
|
||||
const name = el.querySelector('.region-name').textContent;
|
||||
const loc = el.querySelector('.region-location').textContent;
|
||||
document.getElementById('selected-name').textContent = name;
|
||||
document.getElementById('selected-location').textContent = loc;
|
||||
document.getElementById('region-confirm').style.transform = 'translateY(0)';
|
||||
}
|
||||
|
||||
async function confirmRegion() {
|
||||
if (!selectedRegion) return;
|
||||
const btn = document.querySelector('#region-confirm .btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating vault…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{.Base}}/api/vault/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ region: selectedRegion })
|
||||
});
|
||||
if (resp.status === 401) { window.location.href = '{{.Base}}/login'; return; }
|
||||
if (resp.status === 409) throw new Error('Vault limit reached');
|
||||
if (!resp.ok) throw new Error('Could not create vault');
|
||||
window.location.href = '{{.Base}}/dashboard';
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Could not create vault', 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create vault here';
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.className = 'toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type;
|
||||
requestAnimationFrame(() => t.classList.add('show'));
|
||||
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
|
||||
}
|
||||
|
||||
// Auto-detect closest region
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(pos => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lon = pos.coords.longitude;
|
||||
const regions = {
|
||||
zurich: [47.37, 8.54], virginia: [38.95, -77.45], sanfrancisco: [37.77, -122.42],
|
||||
montreal: [45.50, -73.57], mexico: [19.43, -99.13], bogota: [4.71, -74.07],
|
||||
saopaulo: [-23.55, -46.63], santiago: [-33.45, -70.65], buenosaires: [-34.60, -58.38],
|
||||
london: [51.51, -0.13], madrid: [40.42, -3.70],
|
||||
istanbul: [41.01, 28.98], dubai: [25.20, 55.27], capetown: [-33.92, 18.42],
|
||||
mumbai: [19.08, 72.88], singapore: [1.35, 103.82], hongkong: [22.32, 114.17],
|
||||
seoul: [37.57, 126.98], tokyo: [35.68, 139.69], sydney: [-33.87, 151.21],
|
||||
almaty: [43.24, 76.95]
|
||||
};
|
||||
let closest = null, minDist = Infinity;
|
||||
for (const [id, [rlat, rlon]] of Object.entries(regions)) {
|
||||
const d = Math.hypot(lat - rlat, lon - rlon);
|
||||
if (d < minDist) { minDist = d; closest = id; }
|
||||
}
|
||||
if (closest) {
|
||||
const el = document.querySelector(`[data-region="${closest}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.style.boxShadow = '0 0 0 2px var(--accent-dim)';
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'label accent';
|
||||
hint.textContent = 'Nearest to you';
|
||||
hint.style.marginTop = '0.25rem';
|
||||
el.appendChild(hint);
|
||||
}
|
||||
}
|
||||
}, () => {}, { timeout: 5000 });
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
{{define "page"}}
|
||||
<header class="topbar">
|
||||
<div class="container">
|
||||
<div class="topbar-inner">
|
||||
<a href="{{.Base}}/dashboard" class="topbar-brand">vault1984 <span class="port">account</span></a>
|
||||
<nav class="topbar-nav">
|
||||
<a href="{{.Base}}/dashboard" class="{{if eq .ActiveNav "dashboard"}}active{{end}}">Vaults</a>
|
||||
<a href="{{.Base}}/settings" class="{{if eq .ActiveNav "settings"}}active{{end}}">Settings</a>
|
||||
<div class="separator"></div>
|
||||
<a href="{{.Base}}/login" id="logout-link">Sign out</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container" style="flex:1">
|
||||
<section class="section fade-in">
|
||||
<span class="label">Account</span>
|
||||
<h1 style="margin-top:0.5rem">Settings</h1>
|
||||
</section>
|
||||
|
||||
<section class="card fade-in fade-in-delay-1">
|
||||
<!-- Email -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-label">Email</div>
|
||||
<div class="settings-desc">johan@example.com</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-label">Authentication</div>
|
||||
<div class="settings-desc">
|
||||
Passkey registered ·
|
||||
<span class="text-accent" style="font-size:0.8125rem">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" onclick="registerPasskey()">Add passkey</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-label">Billing</div>
|
||||
<div class="settings-desc">
|
||||
$12/year · Next charge Mar 20, 2027
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" onclick="manageBilling()">Manage in Stripe</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoices -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-label">Invoices</div>
|
||||
<div class="settings-desc">View and download past invoices</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" onclick="viewInvoices()">View invoices</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<section class="section fade-in fade-in-delay-2" style="padding-bottom:3rem">
|
||||
<div class="danger-zone">
|
||||
<h3>Danger zone</h3>
|
||||
<p>
|
||||
Deleting your account cancels your subscription, deletes all vaults,
|
||||
and removes all data. This cannot be undone.
|
||||
</p>
|
||||
<button class="btn btn-danger" onclick="deleteAccount()">
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function registerPasskey() {
|
||||
// TODO: WebAuthn registration flow for account site
|
||||
showToast('Passkey registration not yet implemented', 'error');
|
||||
}
|
||||
|
||||
async function manageBilling() {
|
||||
try {
|
||||
const resp = await fetch('{{.Base}}/api/billing/portal', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Could not open billing portal');
|
||||
const { url } = await resp.json();
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function viewInvoices() {
|
||||
manageBilling(); // Stripe portal handles invoices too
|
||||
}
|
||||
|
||||
function deleteAccount() {
|
||||
// Two-step confirmation
|
||||
const zone = document.querySelector('.danger-zone');
|
||||
if (zone.dataset.confirming) {
|
||||
performDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
zone.dataset.confirming = 'true';
|
||||
const btn = zone.querySelector('.btn-danger');
|
||||
btn.textContent = 'Confirm — delete everything';
|
||||
btn.style.background = 'rgba(239,68,68,0.25)';
|
||||
|
||||
// Add email confirmation input
|
||||
const field = document.createElement('div');
|
||||
field.className = 'field';
|
||||
field.style.marginBottom = '1rem';
|
||||
field.innerHTML = `
|
||||
<label style="color:var(--red)">Type your email to confirm</label>
|
||||
<input type="email" id="delete-confirm-email" class="input" placeholder="you@example.com" style="border-color:rgba(239,68,68,0.3)">
|
||||
`;
|
||||
zone.insertBefore(field, btn);
|
||||
document.getElementById('delete-confirm-email').focus();
|
||||
}
|
||||
|
||||
async function performDelete() {
|
||||
const email = document.getElementById('delete-confirm-email')?.value;
|
||||
if (!email) {
|
||||
showToast('Please enter your email to confirm', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.querySelector('.danger-zone .btn-danger');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Deleting…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{.Base}}/api/account/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
if (!resp.ok) throw new Error('Could not delete account');
|
||||
window.location.href = '/login?deleted=1';
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Confirm — delete everything';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('logout-link')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = '{{.Base}}/login';
|
||||
});
|
||||
|
||||
function showToast(msg, type) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.className = 'toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type;
|
||||
requestAnimationFrame(() => t.classList.add('show'));
|
||||
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
{{define "page"}}
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card glass fade-in">
|
||||
<div class="login-header">
|
||||
<div class="brand">vault1984</div>
|
||||
<p>Enter the code sent to your email</p>
|
||||
</div>
|
||||
|
||||
<form id="verify-form" onsubmit="return verifyCode(event)">
|
||||
<input type="hidden" id="verify-email" value="{{.Data}}">
|
||||
<div class="field">
|
||||
<label for="code">Verification code</label>
|
||||
<input type="text" id="code" class="input input-mono" placeholder="123456"
|
||||
required autocomplete="one-time-code" autofocus
|
||||
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
|
||||
style="text-align:center;font-size:1.5rem;letter-spacing:0.3em">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-lg" id="verify-btn">
|
||||
Verify
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<a href="{{.Base}}/login">Back to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
async function verifyCode(e) {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('verify-email').value;
|
||||
const code = document.getElementById('code').value;
|
||||
const btn = document.getElementById('verify-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Verifying…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{.Base}}/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, code })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
throw new Error(data.error || 'Invalid code');
|
||||
}
|
||||
window.location.href = '{{.Base}}/dashboard';
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Verify';
|
||||
document.getElementById('code').value = '';
|
||||
document.getElementById('code').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.className = 'toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type;
|
||||
requestAnimationFrame(() => t.classList.add('show'));
|
||||
setTimeout(() => t.classList.remove('show'), type === 'error' ? 6000 : 3000);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
@ -258,6 +258,7 @@ func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request)
|
|||
PublicKey []byte `json:"public_key"`
|
||||
PRFSalt []byte `json:"prf_salt"`
|
||||
Name string `json:"name"`
|
||||
L1Key []byte `json:"l1_key"` // first 8 bytes of master, for vault DB naming
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
||||
|
|
@ -269,10 +270,17 @@ func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// First passkey → create DB, derive vault key
|
||||
// First passkey → create DB
|
||||
if db == nil && len(req.PublicKey) > 0 {
|
||||
// DB named from L1 key: vault1984-XXXXXX (base64url of first 4 bytes, no extension)
|
||||
var dbName string
|
||||
if len(req.L1Key) >= 4 {
|
||||
dbName = "vault1984-" + base64UrlEncode(req.L1Key[:4])
|
||||
} else {
|
||||
// Fallback: derive from public key hash (legacy compat)
|
||||
hash := sha256.Sum256(req.PublicKey)
|
||||
dbName := hex.EncodeToString(hash[:4]) + ".db" // first 8 hex chars
|
||||
dbName = "vault1984-" + base64UrlEncode(hash[:4])
|
||||
}
|
||||
dbPath := filepath.Join(h.Cfg.DataDir, dbName)
|
||||
newDB, err := lib.OpenDB(dbPath)
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Reference in New Issue