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 RecoveryOverrideScreen component

Create the recovery override screen following patterns from ReviewOperationScreen
and AlertDetailScreen. The screen displays:
- The counter-operation diff (keys being restored, services being restored)
- Recovery deadline countdown with urgency coloring
- Confirm and Cancel buttons
- Loading state during operation building
- Error display on failure

The component calls buildRecoveryOverride on mount to fetch and display the
pending recovery operation, then submitRecoveryOverride on confirmation to
submit it to plc.directory.

Fixes: plc-key-management.AC7.6

+511 -3
+14 -3
apps/identity-wallet/src/lib/components/home/AlertDetailScreen.svelte
··· 8 8 did, 9 9 changes, 10 10 onback, 11 + onoverride, 11 12 }: { 12 13 did: string; 13 14 changes: UnauthorizedChange[]; 14 15 onback: () => void; 16 + onoverride: (cid: string, createdAt: string) => void; 15 17 } = $props(); 16 18 17 19 let now = $state(Date.now()); ··· 69 71 <span class="alert-value">{deadline.toLocaleString()}</span> 70 72 </div> 71 73 72 - <button class="action-button" disabled> 73 - Review & Override 74 + <button 75 + class="action-button" 76 + disabled={urgency === 'expired'} 77 + onclick={() => onoverride(change.cid, change.createdAt)} 78 + > 79 + {urgency === 'expired' ? 'Recovery Window Expired' : 'Review & Override'} 74 80 </button> 75 81 </div> 76 82 {/each} ··· 240 246 border-radius: 12px; 241 247 font-size: 0.9rem; 242 248 font-weight: 600; 249 + cursor: pointer; 250 + opacity: 1; 251 + margin-top: 0.5rem; 252 + } 253 + 254 + .action-button:disabled { 243 255 cursor: not-allowed; 244 256 opacity: 0.5; 245 - margin-top: 0.5rem; 246 257 } 247 258 248 259 .action-button:active:not(:disabled) {
+478
apps/identity-wallet/src/lib/components/home/RecoveryOverrideScreen.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import { 4 + buildRecoveryOverride, 5 + submitRecoveryOverride, 6 + type SignedRecoveryOp, 7 + type RecoveryError, 8 + type ClaimResult, 9 + } from '$lib/ipc'; 10 + import { getDeadline, formatCountdown, getUrgency } from '$lib/utils/deadline'; 11 + import { isCodedError, truncateDid } from '$lib/did-doc-utils'; 12 + 13 + let { 14 + did, 15 + operationCid, 16 + createdAt, 17 + onback, 18 + onsuccess, 19 + }: { 20 + did: string; 21 + operationCid: string; 22 + createdAt: string; 23 + onback: () => void; 24 + onsuccess: () => void; 25 + } = $props(); 26 + 27 + let loading = $state(false); 28 + let submitting = $state(false); 29 + let error = $state<string | null>(null); 30 + let signedOp = $state<SignedRecoveryOp | null>(null); 31 + let now = $state(Date.now()); 32 + let timer: ReturnType<typeof setInterval> | null = null; 33 + 34 + const deadline = getDeadline(createdAt); 35 + 36 + onMount(async () => { 37 + // Start the countdown timer 38 + timer = setInterval(() => { 39 + now = Date.now(); 40 + }, 60_000); 41 + 42 + // Build the recovery operation 43 + loading = true; 44 + error = null; 45 + 46 + try { 47 + const op = await buildRecoveryOverride(did, operationCid); 48 + signedOp = op; 49 + } catch (raw: unknown) { 50 + console.error('Failed to build recovery override:', raw); 51 + 52 + if (isCodedError(raw)) { 53 + const err = raw as RecoveryError; 54 + switch (err.code) { 55 + case 'RECOVERY_WINDOW_EXPIRED': 56 + error = 'Recovery window has expired. No longer possible to recover this identity.'; 57 + break; 58 + case 'SIGNING_FAILED': 59 + error = `Signing failed: ${err.message || 'unknown error'}`; 60 + break; 61 + case 'IDENTITY_NOT_FOUND': 62 + error = `Identity not found: ${err.message || 'unknown error'}`; 63 + break; 64 + case 'UNAUTHORIZED_CHANGE_NOT_FOUND': 65 + error = 'Unauthorized change not found in audit log.'; 66 + break; 67 + case 'PLC_DIRECTORY_ERROR': 68 + error = `PLC directory error: ${err.message || 'unknown error'}`; 69 + break; 70 + case 'NETWORK_ERROR': 71 + error = `Network error: ${err.message || 'unknown error'}`; 72 + break; 73 + default: 74 + error = `Operation build failed (${err.code}). Please try again.`; 75 + } 76 + } else { 77 + error = 'Failed to build recovery operation. Please try again.'; 78 + } 79 + } finally { 80 + loading = false; 81 + } 82 + }); 83 + 84 + onDestroy(() => { 85 + if (timer) clearInterval(timer); 86 + }); 87 + 88 + async function handleSubmit() { 89 + submitting = true; 90 + error = null; 91 + 92 + try { 93 + const result = await submitRecoveryOverride(did); 94 + onsuccess(); 95 + } catch (raw: unknown) { 96 + console.error('Recovery submission failed:', raw); 97 + 98 + if (isCodedError(raw)) { 99 + const err = raw as RecoveryError; 100 + switch (err.code) { 101 + case 'RECOVERY_WINDOW_EXPIRED': 102 + error = 'Recovery window has expired. No longer possible to submit this recovery.'; 103 + break; 104 + case 'SIGNING_FAILED': 105 + error = `Signing failed: ${err.message || 'unknown error'}`; 106 + break; 107 + case 'PLC_DIRECTORY_ERROR': 108 + error = `PLC directory rejected the operation: ${err.message || 'unknown error'}`; 109 + break; 110 + case 'NETWORK_ERROR': 111 + error = `Network error: ${err.message || 'unknown error'}`; 112 + break; 113 + case 'IDENTITY_NOT_FOUND': 114 + error = `Identity not found: ${err.message || 'unknown error'}`; 115 + break; 116 + case 'UNAUTHORIZED_CHANGE_NOT_FOUND': 117 + error = 'Unauthorized change not found in audit log.'; 118 + break; 119 + default: 120 + error = `Submission failed (${err.code}). Please try again.`; 121 + } 122 + } else { 123 + error = 'Submission failed. Please try again.'; 124 + } 125 + submitting = false; 126 + } 127 + } 128 + </script> 129 + 130 + <div class="screen"> 131 + <div class="header"> 132 + <button class="back-btn" onclick={onback} aria-label="Back" disabled={loading || submitting} 133 + >‹ Back</button 134 + > 135 + <h2 class="title">Recovery Override</h2> 136 + </div> 137 + 138 + <!-- Identity section --> 139 + <div class="section"> 140 + <p class="section-label">Identity</p> 141 + <p class="mono-value">{truncateDid(did)}</p> 142 + </div> 143 + 144 + <!-- Deadline section --> 145 + <div class="section"> 146 + <p class="section-label">Recovery Deadline</p> 147 + <span class="deadline-status deadline-status--{getUrgency(deadline, now)}"> 148 + <span class="badge-dot"></span> 149 + {formatCountdown(deadline, now)} 150 + </span> 151 + <p class="deadline-text">{deadline.toLocaleString()}</p> 152 + </div> 153 + 154 + {#if loading} 155 + <div class="loading-section"> 156 + <p>Building recovery operation...</p> 157 + </div> 158 + {:else if signedOp} 159 + <!-- Keys section --> 160 + <div class="section"> 161 + <p class="section-label">Keys</p> 162 + {#if signedOp.diff.addedKeys.length > 0 || signedOp.diff.removedKeys.length > 0} 163 + {#if signedOp.diff.addedKeys.length > 0} 164 + <div class="subsection-label">Keys being restored</div> 165 + {#each signedOp.diff.addedKeys as key} 166 + <div class="diff-entry added"> 167 + <span class="diff-prefix">+</span> 168 + <code class="diff-value">{key.slice(0, 20)}…</code> 169 + </div> 170 + {/each} 171 + {/if} 172 + 173 + {#if signedOp.diff.removedKeys.length > 0} 174 + <div class="subsection-label">Keys being removed</div> 175 + {#each signedOp.diff.removedKeys as key} 176 + <div class="diff-entry removed"> 177 + <span class="diff-prefix">−</span> 178 + <code class="diff-value">{key.slice(0, 20)}…</code> 179 + </div> 180 + {/each} 181 + {/if} 182 + {:else} 183 + <p class="no-changes">No key changes</p> 184 + {/if} 185 + </div> 186 + 187 + <!-- Services section --> 188 + <div class="section"> 189 + <p class="section-label">Services</p> 190 + {#if signedOp.diff.changedServices.length > 0} 191 + {#each signedOp.diff.changedServices as service} 192 + {#if service.changeType === 'added'} 193 + <div class="diff-entry added"> 194 + <span class="diff-prefix">+</span> 195 + <span class="service-text">Restoring service: {service.id} → {service.newEndpoint}</span> 196 + </div> 197 + {:else if service.changeType === 'removed'} 198 + <div class="diff-entry removed"> 199 + <span class="diff-prefix">−</span> 200 + <span class="service-text">Removing service: {service.id} (was: {service.oldEndpoint})</span> 201 + </div> 202 + {:else if service.changeType === 'modified'} 203 + <div class="diff-entry modified"> 204 + <span class="diff-prefix">~</span> 205 + <span class="service-text">Modifying service: {service.id}: {service.oldEndpoint} → {service.newEndpoint}</span> 206 + </div> 207 + {/if} 208 + {/each} 209 + {:else} 210 + <p class="no-changes">No service changes</p> 211 + {/if} 212 + </div> 213 + {/if} 214 + 215 + <!-- Error display --> 216 + {#if error} 217 + <div class="error-box"> 218 + <p class="error-text">{error}</p> 219 + </div> 220 + {/if} 221 + 222 + <!-- Action buttons --> 223 + <div class="button-group"> 224 + {#if !loading && signedOp} 225 + <button 226 + class="cta cta--primary" 227 + onclick={handleSubmit} 228 + disabled={submitting || getUrgency(deadline, now) === 'expired'} 229 + > 230 + {submitting ? 'Submitting…' : 'Confirm & Submit'} 231 + </button> 232 + {/if} 233 + <button class="cta cta--secondary" onclick={onback} disabled={loading || submitting}> 234 + Cancel 235 + </button> 236 + </div> 237 + </div> 238 + 239 + <style> 240 + .screen { 241 + display: flex; 242 + flex-direction: column; 243 + height: 100%; 244 + padding: 2rem 1.5rem; 245 + gap: 1.25rem; 246 + overflow-y: auto; 247 + } 248 + 249 + .header { 250 + display: flex; 251 + align-items: center; 252 + gap: 0.75rem; 253 + } 254 + 255 + .back-btn { 256 + background: none; 257 + border: none; 258 + font-size: 1rem; 259 + color: #007aff; 260 + cursor: pointer; 261 + padding: 0; 262 + font-weight: 500; 263 + white-space: nowrap; 264 + } 265 + 266 + .back-btn:disabled { 267 + opacity: 0.5; 268 + cursor: not-allowed; 269 + } 270 + 271 + .title { 272 + font-size: 1.2rem; 273 + font-weight: 700; 274 + color: #111827; 275 + margin: 0; 276 + } 277 + 278 + .section { 279 + background: #f9fafb; 280 + border: 1px solid #d1d5db; 281 + border-radius: 12px; 282 + padding: 1rem 1.25rem; 283 + display: flex; 284 + flex-direction: column; 285 + gap: 0.5rem; 286 + } 287 + 288 + .section-label { 289 + font-size: 0.75rem; 290 + font-weight: 600; 291 + color: #6b7280; 292 + margin: 0; 293 + text-transform: uppercase; 294 + letter-spacing: 0.05em; 295 + } 296 + 297 + .mono-value { 298 + font-family: monospace; 299 + font-size: 0.8rem; 300 + color: #374151; 301 + margin: 0; 302 + word-break: break-all; 303 + } 304 + 305 + .deadline-status { 306 + display: flex; 307 + align-items: center; 308 + gap: 0.4rem; 309 + padding: 0.4rem 0.8rem; 310 + border-radius: 6px; 311 + font-size: 0.75rem; 312 + font-weight: 600; 313 + white-space: nowrap; 314 + width: fit-content; 315 + } 316 + 317 + .badge-dot { 318 + width: 6px; 319 + height: 6px; 320 + border-radius: 50%; 321 + flex-shrink: 0; 322 + } 323 + 324 + .deadline-status--safe { 325 + background: #dcfce7; 326 + color: #166534; 327 + } 328 + 329 + .deadline-status--safe .badge-dot { 330 + background: #16a34a; 331 + } 332 + 333 + .deadline-status--warning { 334 + background: #fef3c7; 335 + color: #92400e; 336 + } 337 + 338 + .deadline-status--warning .badge-dot { 339 + background: #f59e0b; 340 + } 341 + 342 + .deadline-status--critical, 343 + .deadline-status--expired { 344 + background: #fef2f2; 345 + color: #991b1b; 346 + } 347 + 348 + .deadline-status--critical .badge-dot, 349 + .deadline-status--expired .badge-dot { 350 + background: #ef4444; 351 + } 352 + 353 + .deadline-text { 354 + font-size: 0.85rem; 355 + color: #6b7280; 356 + margin: 0.5rem 0 0 0; 357 + } 358 + 359 + .loading-section { 360 + display: flex; 361 + align-items: center; 362 + justify-content: center; 363 + padding: 2rem 1.5rem; 364 + color: #6b7280; 365 + font-size: 0.9rem; 366 + } 367 + 368 + .subsection-label { 369 + font-size: 0.8rem; 370 + font-weight: 600; 371 + color: #374151; 372 + margin: 0.5rem 0 0.25rem 0; 373 + } 374 + 375 + .no-changes { 376 + font-size: 0.85rem; 377 + color: #6b7280; 378 + margin: 0.5rem 0 0; 379 + font-style: italic; 380 + } 381 + 382 + .diff-entry { 383 + display: flex; 384 + align-items: flex-start; 385 + gap: 0.5rem; 386 + padding: 0.5rem; 387 + border-radius: 8px; 388 + margin: 0.25rem 0; 389 + font-size: 0.85rem; 390 + } 391 + 392 + .diff-entry.added { 393 + background: rgba(34, 197, 94, 0.1); 394 + border-left: 3px solid #22c55e; 395 + color: #166534; 396 + } 397 + 398 + .diff-entry.removed { 399 + background: rgba(239, 68, 68, 0.1); 400 + border-left: 3px solid #ef4444; 401 + color: #7f1d1d; 402 + } 403 + 404 + .diff-entry.modified { 405 + background: rgba(245, 158, 11, 0.1); 406 + border-left: 3px solid #f59e0b; 407 + color: #92400e; 408 + } 409 + 410 + .diff-prefix { 411 + font-weight: 600; 412 + flex-shrink: 0; 413 + width: 1rem; 414 + } 415 + 416 + .diff-value { 417 + font-family: monospace; 418 + font-size: 0.75rem; 419 + word-break: break-all; 420 + margin: 0; 421 + } 422 + 423 + .service-text { 424 + word-break: break-word; 425 + } 426 + 427 + .error-box { 428 + background: rgba(239, 68, 68, 0.1); 429 + border: 1px solid #ef4444; 430 + border-radius: 8px; 431 + padding: 0.75rem 1rem; 432 + } 433 + 434 + .error-text { 435 + font-size: 0.85rem; 436 + color: #7f1d1d; 437 + margin: 0; 438 + line-height: 1.4; 439 + } 440 + 441 + .button-group { 442 + display: flex; 443 + flex-direction: column; 444 + gap: 0.75rem; 445 + margin-top: auto; 446 + } 447 + 448 + .cta { 449 + padding: 1rem; 450 + border: none; 451 + border-radius: 12px; 452 + font-size: 1rem; 453 + font-weight: 600; 454 + cursor: pointer; 455 + transition: opacity 0.2s; 456 + width: 100%; 457 + } 458 + 459 + .cta--primary { 460 + background: #007aff; 461 + color: #fff; 462 + } 463 + 464 + .cta--primary:disabled { 465 + opacity: 0.5; 466 + cursor: not-allowed; 467 + } 468 + 469 + .cta--secondary { 470 + background: #e5e7eb; 471 + color: #374151; 472 + } 473 + 474 + .cta--secondary:disabled { 475 + opacity: 0.5; 476 + cursor: not-allowed; 477 + } 478 + </style>
+19
apps/identity-wallet/src/routes/+page.svelte
··· 22 22 import DIDDocumentScreen from '$lib/components/home/DIDDocumentScreen.svelte'; 23 23 import RecoveryInfoScreen from '$lib/components/home/RecoveryInfoScreen.svelte'; 24 24 import AlertDetailScreen from '$lib/components/home/AlertDetailScreen.svelte'; 25 + import RecoveryOverrideScreen from '$lib/components/home/RecoveryOverrideScreen.svelte'; 25 26 import { createAccount, listIdentities, checkIdentityStatus, type CreateAccountError, type OAuthError, type HomeData, type IdentityInfo, type VerifiedClaimOp, type ClaimResult, type UnauthorizedChange } from '$lib/ipc'; 26 27 import { normalizePlcDocToW3c } from '$lib/did-doc-utils'; 27 28 import IdentityListHome from '$lib/components/home/IdentityListHome.svelte'; ··· 56 57 | 'did_document' 57 58 | 'recovery_info' 58 59 | 'alert_detail' 60 + | 'recovery_override' 59 61 | 'auth_failed' 60 62 | 'identity_input' 61 63 | 'pds_auth' ··· 89 91 90 92 let selectedAlertDid = $state<string | null>(null); 91 93 let selectedAlertChanges = $state<UnauthorizedChange[]>([]); 94 + 95 + let selectedRecoveryCid = $state<string | null>(null); 96 + let selectedRecoveryCreatedAt = $state<string | null>(null); 92 97 93 98 // ── Navigation helpers ─────────────────────────────────────────────────── 94 99 ··· 363 368 did={selectedAlertDid ?? ''} 364 369 changes={selectedAlertChanges} 365 370 onback={() => goTo('home')} 371 + onoverride={(cid, createdAt) => { 372 + selectedRecoveryCid = cid; 373 + selectedRecoveryCreatedAt = createdAt; 374 + goTo('recovery_override'); 375 + }} 376 + /> 377 + 378 + {:else if step === 'recovery_override'} 379 + <RecoveryOverrideScreen 380 + did={selectedAlertDid ?? ''} 381 + operationCid={selectedRecoveryCid ?? ''} 382 + createdAt={selectedRecoveryCreatedAt ?? ''} 383 + onback={() => goTo('alert_detail')} 384 + onsuccess={() => goTo('home')} 366 385 /> 367 386 368 387 {:else if step === 'auth_failed'}