feat(web): email verification banner + resend + register success (TASK-1940) (#810)

Wave 5 of PLAN-1933 (DR-1 model b) — surfaces the unverified-email state
in the web UI and makes it actionable.

- AuthSession user type gains `email_verified` (owns the session user-type
  change); register response user type gains it too.
- authStore.emailVerified getter, default TRUE (mirrors `emailConfigured ??
  true`) — a missing field or a self-host instance must never show the
  banner.
- VerifyEmailBanner rendered in the workspace layout above ConnectBanner,
  shown only when `cloudMode && user && !emailVerified`, with a Resend
  button hitting POST /auth/resend-verification and a "sent" confirmation
  (enumeration-safe, always 200).
- api.auth.resendVerification client method.
- /register shows a "check your email to verify your account" state after a
  cloud self-serve signup returns an unverified user, instead of navigating
  in and implying full access; includes resend + continue actions.

Gates: make check (lint + go test + govulncheck + web-check) green;
cd web && npm run check → 0 errors.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
This commit is contained in:
xarmian
2026-07-04 04:15:46 -04:00
committed by GitHub
parent f34e66254b
commit a04091517c
5 changed files with 274 additions and 3 deletions
+19 -2
View File
@@ -423,7 +423,13 @@ export interface AuthSession {
// Absent on older servers and default false on fresh instances — treat
// absent as false.
webmcp_enabled?: boolean;
user?: { id: string; email: string; username: string; name: string; role: string; plan?: string };
// email_verified is false ONLY for a Pad Cloud self-serve signup that
// hasn't confirmed its email yet (PLAN-1933 DR-3). OAuth / invited /
// admin-created / pre-existing accounts are verified, and self-hosted
// instances never emit an unverified user. Absent on older servers —
// authStore.emailVerified treats absent as TRUE so the verification
// banner never shows on self-host or before the field lands.
user?: { id: string; email: string; username: string; name: string; role: string; plan?: string; email_verified?: boolean };
}
// ── WebMCP tool-surface (PLAN-1888 / TASK-1892) ────────────────────────────
@@ -1534,7 +1540,7 @@ export const api = {
body: JSON.stringify({ challenge_token: challengeToken, code: code || undefined, recovery_code: recoveryCode || undefined })
}),
register: (email: string, name: string, password: string, username?: string, invitation_code?: string) =>
request<{ user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/register', {
request<{ user: { id: string; email: string; username: string; name: string; role: string; email_verified?: boolean }; token: string }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, name, password, ...(username ? { username } : {}), ...(invitation_code ? { invitation_code } : {}) })
}),
@@ -1573,6 +1579,17 @@ export const api = {
method: 'POST',
body: JSON.stringify({ email })
}),
// Re-send the email-verification link for an unverified Pad Cloud
// account (PLAN-1933 DR-5 / TASK-1940). Enumeration-safe: the server
// always returns 200 with the same body whether or not the address
// maps to an unverified account, so callers should show a neutral
// "if your account still needs verification, a link was sent"
// confirmation rather than branching on the response.
resendVerification: (email: string) =>
request<{ ok: boolean; message: string }>('/auth/resend-verification', {
method: 'POST',
body: JSON.stringify({ email })
}),
resetPassword: (token: string, password: string) =>
request<{ ok: boolean; user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/reset-password', {
method: 'POST',
@@ -0,0 +1,128 @@
<script lang="ts">
import { api } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
// Shown ONLY for an unverified Pad Cloud account (PLAN-1933 DR-1 model b /
// TASK-1940). authStore.emailVerified defaults TRUE, so self-hosted
// instances, older servers (absent field), and OAuth/invited/admin-created
// accounts never match — this is a pure cloud-mode surface.
let visible = $derived(
authStore.cloudMode && !!authStore.user && !authStore.emailVerified
);
// idle → sending → sent (terminal) ; error is retryable.
let resendState = $state<'idle' | 'sending' | 'sent' | 'error'>('idle');
async function resend() {
const email = authStore.user?.email;
if (!email || resendState === 'sending') return;
resendState = 'sending';
try {
// Enumeration-safe (always 200); a resolved promise is the only
// success signal, so treat any non-throw as "sent".
await api.auth.resendVerification(email);
resendState = 'sent';
} catch {
resendState = 'error';
}
}
</script>
{#if visible}
<div class="verify-banner" role="status">
<span class="verify-icon" aria-hidden="true">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<path d="m22 6-10 7L2 6" />
</svg>
</span>
<span class="verify-text">
Verify your email to create and share. Check your inbox for the confirmation link.
</span>
<span class="verify-actions">
{#if resendState === 'sent'}
<span class="verify-sent">Verification email sent</span>
{:else}
<button
class="verify-resend"
type="button"
onclick={resend}
disabled={resendState === 'sending'}
>
{#if resendState === 'sending'}
Sending…
{:else if resendState === 'error'}
Retry
{:else}
Resend
{/if}
</button>
{/if}
</span>
</div>
{/if}
<style>
.verify-banner {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-4);
background: color-mix(in srgb, var(--accent-blue) 8%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--accent-blue) 40%, var(--border));
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 0.85em;
box-sizing: border-box;
}
.verify-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-blue);
flex-shrink: 0;
}
.verify-text {
flex: 1;
min-width: 0;
color: var(--text-secondary);
}
.verify-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.verify-sent {
color: var(--accent-blue);
font-weight: 600;
}
.verify-resend {
padding: var(--space-1) var(--space-3);
background: var(--accent-blue);
border: none;
border-radius: var(--radius);
color: #fff;
font-size: inherit;
font-family: var(--font-ui);
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.verify-resend:hover:not(:disabled) {
opacity: 0.9;
}
.verify-resend:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+8
View File
@@ -35,6 +35,14 @@ export const authStore = {
// unless the server explicitly says email is off. The /forgot-password
// page swaps to host-recovery guidance when this is false.
get emailConfigured() { return session?.email_configured ?? true; },
// emailVerified is false ONLY for a Pad Cloud self-serve signup that hasn't
// confirmed its email yet (PLAN-1933 DR-3 / TASK-1940). Defaults to TRUE
// when the field is absent — older servers, self-hosted instances (which
// never mint unverified users), OAuth/invited/admin-created accounts, and
// the pre-load window. This default is load-bearing: the verification
// banner gates on `!emailVerified`, so a missing field or self-host must
// NEVER surface it. Mirrors the `emailConfigured ?? true` pattern.
get emailVerified() { return session?.user?.email_verified ?? true; },
get loading() { return loading; },
async load() {
@@ -13,6 +13,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { registerWorkspaceTools, type WebMcpHandle } from '$lib/webmcp/register';
import ConnectBanner from '$lib/components/ConnectBanner.svelte';
import VerifyEmailBanner from '$lib/components/VerifyEmailBanner.svelte';
import BottomNav from '$lib/components/layout/BottomNav.svelte';
import MobileContextBar from '$lib/components/layout/MobileContextBar.svelte';
@@ -237,6 +238,8 @@
}
</script>
<VerifyEmailBanner />
<ConnectBanner
{wsSlug}
serverUrl={typeof window !== 'undefined' ? window.location.origin : ''}
+116 -1
View File
@@ -25,6 +25,16 @@
let setupMethod = $state<'local_cli' | 'docker_exec' | 'cloud' | 'logs_token' | 'open' | undefined>(undefined);
let loading = $state(false);
// Cloud self-serve signups (PLAN-1933 DR-1 model b / TASK-1940) create an
// UNVERIFIED account: it can log in + read but can't create/share until the
// emailed link is confirmed. When that happens we swap the form for a
// "check your email" state instead of dropping the user into the app and
// implying full access. Every other path (self-host, invited, admin-created)
// returns a verified user and navigates straight in.
let registered = $state(false);
let registeredEmail = $state('');
let resendState = $state<'idle' | 'sending' | 'sent' | 'error'>('idle');
let usernameManuallyEdited = $state(false);
let usernameChecking = $state(false);
let usernameAvailable = $state<boolean | null>(null);
@@ -151,8 +161,25 @@
loading = true;
try {
await api.auth.register(email, name, password, username || undefined);
const res = await api.auth.register(email, name, password, username || undefined);
recordAuthMethod('password');
// Refresh the store from the freshly-set session cookie (same as
// /login) BEFORE navigating. navigateToRedirectTarget uses SPA
// goto() for workspace routes, and the root layout's one-shot auth
// load won't rerun — so without this authStore.user stays null and
// VerifyEmailBanner's `cloudMode && user && !emailVerified` gate
// never fires after the user continues in.
await authStore.load();
// Cloud self-serve signup landed unverified → show the "check your
// email" state instead of navigating in. The server only returns
// email_verified === false on the cloud self-serve path, but we
// gate on cloudMode too so the verified default can never trap a
// self-host/invited registration here.
if (authStore.cloudMode && res.user?.email_verified === false) {
registeredEmail = email;
registered = true;
return;
}
await navigateToRedirectTarget(redirectTarget);
} catch (err: unknown) {
if (err instanceof Error) {
@@ -165,6 +192,25 @@
}
}
async function resendVerification() {
if (!registeredEmail || resendState === 'sending') return;
resendState = 'sending';
try {
// Enumeration-safe (always 200); treat any non-throw as "sent".
await api.auth.resendVerification(registeredEmail);
resendState = 'sent';
} catch {
resendState = 'error';
}
}
async function continueToPad() {
// The account is already signed in (register set a session cookie), so
// this drops the user into the app to read/look around — they just
// can't create or share until they verify.
await navigateToRedirectTarget(redirectTarget);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSubmit();
@@ -186,6 +232,36 @@
actionHref="/login"
actionLabel="Back to login"
/>
{:else if registered}
<div class="verify-notice">
<h2>Check your email</h2>
<p>
We sent a verification link to <strong>{registeredEmail}</strong>.
Confirm your email to start creating and sharing.
</p>
<p class="verify-hint">
You can look around now, but you'll need to verify before you can create or share.
</p>
{#if resendState === 'sent'}
<p class="verify-sent">Verification email sent.</p>
{:else}
<button
class="secondary"
type="button"
onclick={resendVerification}
disabled={resendState === 'sending'}
>
{#if resendState === 'sending'}
Sending...
{:else if resendState === 'error'}
Something went wrong — retry
{:else}
Didn't get it? Resend
{/if}
</button>
{/if}
<button type="button" onclick={continueToPad}>Continue to Pad</button>
</div>
{:else}
<p class="subtitle">Create your account</p>
@@ -441,4 +517,43 @@
.username-status.taken {
color: #ef4444;
}
.verify-notice {
display: flex;
flex-direction: column;
gap: var(--space-4);
text-align: left;
}
.verify-notice h2 {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
text-align: center;
}
.verify-notice p {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
margin: 0;
}
.verify-hint {
color: var(--text-muted) !important;
font-size: 0.82rem !important;
}
.verify-sent {
color: #22c55e !important;
font-weight: 500;
text-align: center;
}
.verify-notice button.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
}
</style>