Something went wrong. Try again.
Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
Something went wrong. Try again.
9.4 kB · 205 lines
Svelte
at oauth
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206<script lang="ts"> import { MAX_ROTATION_KEYS, addPastedKey, droppedPdsRow, formatAddedDate, moveRow, removeRow, selectedKeys, selectionProblem, } from '$lib/moover'; import type {RotationKeyRow, RotationKeySource} from '$lib/moover'; import type {Snippet} from 'svelte'; import {tick} from 'svelte'; import AddRotationKeySheet from '$lib/components/AddRotationKeySheet.svelte'; import ErrorMessage from '$lib/components/ui/ErrorMessage.svelte';
interface Props { /** The keys on show, highest priority first. The parent owns this list. */ rows: RotationKeyRow[]; /** Badge text per source, for naming the PDS hosts. Falls back to generic wording. */ labels?: Partial<Record<RotationKeySource, string>>; disabled?: boolean; max?: number; /** Show the selected keys as a static list, with no controls. For review steps. */ readonly?: boolean; /** Heading above the list; null when the parent already has one. */ title?: string | null; /** Offered in the add sheet when given. Should put the new key in `rows`. */ onGenerate?: () => Promise<void> | void; /** Rendered on the right of the footer, next to the slot count. */ actions?: Snippet; }
let { rows = $bindable(), labels = {}, disabled = false, max = MAX_ROTATION_KEYS, readonly = false, title = 'Rotation keys', onGenerate, actions, }: Props = $props();
const DEFAULT_LABELS: Record<RotationKeySource, string> = { generated: 'New key, generated in this browser', current: 'Already on your identity', 'current-pds': 'Your PDS', 'new-pds': 'New PDS', pasted: 'Pasted', };
const BADGE_CLASSES: Record<RotationKeySource, string> = { generated: 'bg-[rgba(46,204,113,0.3)]', current: 'bg-[rgba(52,120,220,0.5)]', 'current-pds': 'bg-accent-strong', 'new-pds': 'bg-accent-strong', pasted: 'bg-[rgba(155,89,182,0.35)]', };
let sheetOpen = $state(false); let addButton = $state<HTMLButtonElement | null>(null);
const selected = $derived(selectedKeys(rows)); const problem = $derived(selectionProblem(rows, max)); const dropped = $derived(droppedPdsRow(rows)); const hasGenerated = $derived(rows.some((row) => row.source === 'generated')); /** In read-only mode only the keys that will be written are worth showing. */ const shown = $derived(readonly ? rows.filter((row) => row.included) : rows);
function label(source: RotationKeySource): string { return labels[source] ?? DEFAULT_LABELS[source]; }
function addPasted(value: string) { rows = addPastedKey(rows, value); }
/** A pasted key was never on the identity, so it just goes away. Anything else stays visible, dropped. */ function remove(i: number) { if (rows[i].source === 'pasted') { rows = removeRow(rows, i); } else { rows[i].included = false; } }
async function closeSheet() { sheetOpen = false; await tick(); addButton?.focus(); }</script>
{#snippet card(row: RotationKeyRow, index: number)} <li class="rounded-xl border border-[rgba(127,127,127,0.35)] px-3.5 py-3 {row.included ? '' : 'opacity-60'}"> <div class="flex items-start gap-3"> <code class="min-w-0 grow text-[0.95em] leading-relaxed break-all {row.included ? '' : 'line-through'}">{row.key}</code> {#if !readonly} <span class="-mr-1.5 -mt-1 flex shrink-0 flex-col"> <button type="button" aria-label="Move up" title="Move up" class="cursor-pointer rounded border-0 bg-transparent p-1 text-muted hover:text-foreground disabled:cursor-default disabled:opacity-30" disabled={disabled || index === 0} onclick={() => rows = moveRow(rows, index, -1)}> <svg viewBox="0 0 16 16" class="size-4" aria-hidden="true"> <path d="M3.5 10l4.5-4.5 4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> <button type="button" aria-label="Move down" title="Move down" class="cursor-pointer rounded border-0 bg-transparent p-1 text-muted hover:text-foreground disabled:cursor-default disabled:opacity-30" disabled={disabled || index === rows.length - 1} onclick={() => rows = moveRow(rows, index, 1)}> <svg viewBox="0 0 16 16" class="size-4" aria-hidden="true"> <path d="M3.5 6l4.5 4.5L12.5 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> </span> {/if} </div> <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[0.85em]"> {#if row.included} <span class="rounded-full px-2.5 py-0.5 whitespace-nowrap {BADGE_CLASSES[row.source]}">{label(row.source)}</span> {#if row.addedAt} <span class="whitespace-nowrap text-muted">{formatAddedDate(row.addedAt)}</span> {/if} {#if !readonly} <button type="button" class="ml-auto cursor-pointer border-0 bg-transparent p-0 text-muted underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed" {disabled} onclick={() => remove(index)}>Remove</button> {/if} {:else} <span class="rounded-full bg-[rgba(127,127,127,0.25)] px-2.5 py-0.5 whitespace-nowrap">Removed</span> <span class="text-muted">{label(row.source)}</span> <button type="button" class="ml-auto cursor-pointer border-0 bg-transparent p-0 font-medium text-link underline-offset-2 hover:underline disabled:cursor-not-allowed" {disabled} onclick={() => rows[index].included = true}>Undo</button> {/if} </div> </li>{/snippet}
<div class="text-left"> {#if readonly} {#if shown.length === 0} <p>No rotation keys to show.</p> {:else} <ol class="m-0 flex list-none flex-col gap-2 p-0"> {#each shown as row, i (row.key)} {@render card(row, i)} {/each} </ol> {/if} {:else} {#if title} <h3 class="mt-0 mb-1">{title}</h3> {/if} <p class="mt-0 mb-3 text-muted">Highest priority first. A key higher in the list can undo changes made by a key below it.</p>
{#if rows.length === 0} <p>Your DID document lists no rotation keys yet.</p> {:else} <ol class="m-0 mb-2 flex list-none flex-col gap-2 p-0"> {#each rows as row, i (row.key)} {@render card(row, i)} {/each} </ol> {/if}
<button type="button" id="open-add-key" bind:this={addButton} class="flex w-full cursor-pointer items-center gap-3.5 rounded-xl border border-[rgba(127,127,127,0.35)] bg-transparent px-3.5 py-3.5 text-left text-[1.1em] text-inherit transition-colors hover:border-accent disabled:cursor-not-allowed disabled:opacity-60" {disabled} onclick={() => sheetOpen = true}> <svg viewBox="0 0 16 16" class="size-5 shrink-0 text-accent" aria-hidden="true"> <path d="M8 3v10M3 8h10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/> </svg> <span class="grow">Add a new key</span> <svg viewBox="0 0 16 16" class="size-4 shrink-0 text-muted" aria-hidden="true"> <path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button>
{#if problem} <ErrorMessage>{problem}</ErrorMessage> {/if}
{#if dropped} <div class="mt-3 rounded-lg border border-[#f39c12] px-2.5 py-2"> <strong>{label(dropped.source)}</strong> is removed. That PDS sends this change to the PLC directory and refuses to send a rotation key list without its own key, so submitting would fail and use up your emailed token. Keep its key for now; to stop relying on that PDS, add a key of your own above it. </div> {/if}
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[rgba(127,127,127,0.35)] pt-4"> <span class="text-muted">{selected.length} of {max} slots used</span> {@render actions?.()} </div>
{#if sheetOpen} <AddRotationKeySheet {onGenerate} {hasGenerated} onAddPasted={addPasted} onClose={closeSheet} {disabled}/> {/if} {/if}</div>