mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
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:
@@ -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>
|
||||
@@ -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 : ''}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user