An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): add EmailVerificationScreen component

Implements email token verification for the PLC rotation key claim flow.
Sends verification email on mount and accepts user-provided token.
Maps ClaimError codes to user-friendly messages for invalid token,
verification failures, and network errors.

authored by

Malpercio and committed by
Tangled
6b3b2631 e5d35851

+272
+272
apps/identity-wallet/src/lib/components/onboarding/EmailVerificationScreen.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + requestClaimVerification, 4 + signAndVerifyClaim, 5 + type VerifiedClaimOp, 6 + type ClaimError, 7 + } from '$lib/ipc'; 8 + 9 + let { 10 + did, 11 + onnext, 12 + onback, 13 + }: { 14 + did: string; 15 + onnext: (result: VerifiedClaimOp) => void; 16 + onback: () => void; 17 + } = $props(); 18 + 19 + let token = $state(''); 20 + let sending = $state(true); // true while sending verification email 21 + let sendError = $state<string | null>(null); 22 + let verifying = $state(false); // true while verifying token 23 + let verifyError = $state<string | null>(null); 24 + 25 + // ── Send verification email on mount ───────────────────────────────────── 26 + async function sendVerificationEmail() { 27 + sending = true; 28 + sendError = null; 29 + 30 + try { 31 + await requestClaimVerification(did); 32 + sending = false; 33 + } catch (raw: unknown) { 34 + sending = false; 35 + 36 + // Guard against non-ClaimError shapes 37 + if ( 38 + typeof raw === 'object' && 39 + raw !== null && 40 + 'code' in raw && 41 + typeof (raw as ClaimError).code === 'string' 42 + ) { 43 + const err = raw as ClaimError; 44 + sendError = 'Failed to send verification email. Please try again.'; 45 + } else { 46 + sendError = 'Failed to send verification email. Please try again.'; 47 + } 48 + } 49 + } 50 + 51 + // ── Verify token and sign claim ────────────────────────────────────────── 52 + async function verifyToken() { 53 + verifying = true; 54 + verifyError = null; 55 + 56 + try { 57 + const result = await signAndVerifyClaim(did, token.trim()); 58 + onnext(result); 59 + } catch (raw: unknown) { 60 + verifying = false; 61 + 62 + // Guard against non-ClaimError shapes 63 + if ( 64 + typeof raw === 'object' && 65 + raw !== null && 66 + 'code' in raw && 67 + typeof (raw as ClaimError).code === 'string' 68 + ) { 69 + const err = raw as ClaimError; 70 + switch (err.code) { 71 + case 'INVALID_TOKEN': 72 + verifyError = 'Invalid or expired verification code. Check your email and try again.'; 73 + break; 74 + case 'VERIFICATION_FAILED': 75 + verifyError = `Verification failed: ${err.message ?? 'Please try again.'}`; 76 + break; 77 + case 'NETWORK_ERROR': 78 + verifyError = 'Network error. Check your connection and try again.'; 79 + break; 80 + default: 81 + verifyError = 'An error occurred. Please try again.'; 82 + } 83 + } else { 84 + verifyError = 'An error occurred. Please try again.'; 85 + } 86 + } 87 + } 88 + 89 + // ── Initialization ─────────────────────────────────────────────────────── 90 + // Send verification email on component mount 91 + $effect.pre(() => { 92 + // This runs synchronously before rendering, allowing us to start the 93 + // async operation immediately and show the spinner 94 + sendVerificationEmail(); 95 + }); 96 + </script> 97 + 98 + <div class="screen"> 99 + {#if sending} 100 + <div class="spinner" aria-label="Loading"></div> 101 + <p class="status">Sending verification email…</p> 102 + {:else if sendError} 103 + <div class="content"> 104 + <h2>Email Verification</h2> 105 + <p class="hint">We couldn't send the verification email.</p> 106 + <p class="error-text">{sendError}</p> 107 + <div class="button-group"> 108 + <button class="primary" onclick={sendVerificationEmail}> 109 + Retry 110 + </button> 111 + <button class="secondary" onclick={onback}> 112 + Back 113 + </button> 114 + </div> 115 + </div> 116 + {:else} 117 + <div class="content"> 118 + <h2>Email Verification</h2> 119 + <p class="hint">A verification code has been sent to your email. Enter the code below.</p> 120 + 121 + <input 122 + type="text" 123 + class:error={!!verifyError} 124 + placeholder="Enter verification code" 125 + autocomplete="off" 126 + bind:value={token} 127 + disabled={verifying} 128 + /> 129 + 130 + {#if verifyError} 131 + <p class="error-text">{verifyError}</p> 132 + {/if} 133 + 134 + <div class="button-group"> 135 + <button 136 + class="primary" 137 + disabled={!token.trim() || verifying} 138 + onclick={verifyToken} 139 + > 140 + {verifying ? 'Verifying…' : 'Verify'} 141 + </button> 142 + <button class="secondary" onclick={onback} disabled={verifying}> 143 + Back 144 + </button> 145 + </div> 146 + </div> 147 + {/if} 148 + </div> 149 + 150 + <style> 151 + .screen { 152 + display: flex; 153 + flex-direction: column; 154 + align-items: center; 155 + justify-content: center; 156 + height: 100%; 157 + gap: 24px; 158 + padding: 32px; 159 + } 160 + 161 + .content { 162 + display: flex; 163 + flex-direction: column; 164 + align-items: center; 165 + gap: 1.5rem; 166 + max-width: 320px; 167 + width: 100%; 168 + } 169 + 170 + h2 { 171 + font-size: 1.5rem; 172 + font-weight: 700; 173 + margin: 0; 174 + text-align: center; 175 + } 176 + 177 + .hint { 178 + font-size: 0.95rem; 179 + color: #6b7280; 180 + text-align: center; 181 + margin: 0; 182 + line-height: 1.5; 183 + } 184 + 185 + .error-text { 186 + color: #ef4444; 187 + font-size: 0.875rem; 188 + margin: 0; 189 + text-align: center; 190 + } 191 + 192 + input { 193 + width: 100%; 194 + padding: 1rem; 195 + font-size: 1rem; 196 + border: 2px solid #d1d5db; 197 + border-radius: 12px; 198 + } 199 + 200 + input.error { 201 + border-color: #ef4444; 202 + } 203 + 204 + input:disabled { 205 + background: #f3f4f6; 206 + color: #9ca3af; 207 + } 208 + 209 + .button-group { 210 + display: flex; 211 + flex-direction: column; 212 + gap: 1rem; 213 + width: 100%; 214 + } 215 + 216 + .spinner { 217 + width: 40px; 218 + height: 40px; 219 + border: 4px solid #e5e7eb; 220 + border-top-color: #007aff; 221 + border-radius: 50%; 222 + animation: spin 0.8s linear infinite; 223 + } 224 + 225 + @keyframes spin { 226 + to { 227 + transform: rotate(360deg); 228 + } 229 + } 230 + 231 + .status { 232 + text-align: center; 233 + color: #6b7280; 234 + font-size: 1rem; 235 + margin: 0; 236 + } 237 + 238 + button { 239 + width: 100%; 240 + padding: 1rem; 241 + border: none; 242 + border-radius: 12px; 243 + font-size: 1rem; 244 + font-weight: 600; 245 + cursor: pointer; 246 + transition: background-color 0.2s; 247 + } 248 + 249 + .primary { 250 + background: #007aff; 251 + color: #fff; 252 + } 253 + 254 + .primary:active:not(:disabled) { 255 + background: #0051d5; 256 + } 257 + 258 + .secondary { 259 + background: #f3f4f6; 260 + color: #374151; 261 + } 262 + 263 + .secondary:active:not(:disabled) { 264 + background: #e5e7eb; 265 + } 266 + 267 + button:disabled { 268 + background: #9ca3af; 269 + cursor: not-allowed; 270 + color: #fff; 271 + } 272 + </style>