handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs
22
fork

Configure Feed

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

refactor: split out plc-applicator tool into components

mary.my.id e9b7d411 53d78998

verified
+1863 -1193
+10
src/api/utils/auth.ts
··· 1 + export const TOTP_RE = /^([a-zA-Z0-9]{5})[\- ]?([a-zA-Z0-9]{5})$/; 2 + 3 + export const formatTotpCode = (code: string) => { 4 + const match = TOTP_RE.exec(code); 5 + if (match !== null) { 6 + return `${match[1]}-${match[2]}`.toUpperCase(); 7 + } 8 + 9 + return undefined; 10 + };
+2
src/api/utils/types.ts
··· 1 + export type UnwrapArray<T> = T extends (infer V)[] ? V : never; 2 + export type AccessorMaybe<T> = T | (() => T);
+30
src/components/inputs/button.tsx
··· 1 + import { JSX } from 'solid-js'; 2 + 3 + interface ButtonProps { 4 + children?: JSX.Element; 5 + variant?: 'primary' | 'secondary'; 6 + type?: 'button' | 'submit'; 7 + onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 8 + } 9 + 10 + const buttonStyles = ({ variant = 'primary' }: ButtonProps): string => { 11 + let cn = `flex h-9 select-none items-center rounded px-4 text-sm font-semibold`; 12 + 13 + if (variant === 'primary') { 14 + cn += ` bg-purple-800 text-white hover:bg-purple-700 active:bg-purple-700`; 15 + } else if (variant === 'secondary') { 16 + cn += ` bg-gray-200 text-black hover:bg-gray-300 active:bg-gray-300`; 17 + } 18 + 19 + return cn; 20 + }; 21 + 22 + const Button = (props: ButtonProps) => { 23 + return ( 24 + <button type={props.type ?? 'button'} class={buttonStyles(props)} onClick={props.onClick}> 25 + {props.children} 26 + </button> 27 + ); 28 + }; 29 + 30 + export default Button;
+52
src/components/inputs/multiline-input.tsx
··· 1 + import { createEffect, JSX } from 'solid-js'; 2 + 3 + import { createId } from '~/lib/hooks/id'; 4 + 5 + interface MultilineInputProps { 6 + label: JSX.Element; 7 + name?: string; 8 + required?: boolean; 9 + autocomplete?: 'off' | 'on'; 10 + autocorrect?: 'off' | 'on'; 11 + value?: string; 12 + autofocus?: boolean; 13 + onChange?: (next: string) => void; 14 + } 15 + 16 + const MultilineInput = (props: MultilineInputProps) => { 17 + const fieldId = createId(); 18 + 19 + const onChange = props.onChange; 20 + 21 + return ( 22 + <div class="flex flex-col gap-2"> 23 + <label for={fieldId} class="font-semibold text-gray-600"> 24 + {props.label} 25 + </label> 26 + 27 + <textarea 28 + ref={(node) => { 29 + if ('autofocus' in props) { 30 + createEffect(() => { 31 + if (props.autofocus) { 32 + node.focus(); 33 + } 34 + }); 35 + } 36 + }} 37 + name={props.name} 38 + required={props.required} 39 + autocomplete={props.autocomplete} 40 + // @ts-expect-error 41 + autocorrect={props.autocorrect} 42 + rows={22} 43 + value={props.value} 44 + class="resize-y break-all rounded border border-gray-400 px-3 py-2 font-mono text-xs tracking-wider placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 45 + style="field-sizing: content" 46 + onChange={(ev) => onChange?.(ev.target.value)} 47 + /> 48 + </div> 49 + ); 50 + }; 51 + 52 + export default MultilineInput;
+52
src/components/inputs/radio-input.tsx
··· 1 + import { JSX } from 'solid-js'; 2 + 3 + import { createId } from '~/lib/hooks/id'; 4 + 5 + interface RadioInputProps<T extends string> { 6 + label: JSX.Element; 7 + name?: string; 8 + required?: boolean; 9 + value?: T | undefined; 10 + options: { value: T; label: string }[]; 11 + onChange?: (next: T) => void; 12 + } 13 + 14 + const RadioInput = <T extends string>(props: RadioInputProps<T>) => { 15 + const fieldId = createId(); 16 + 17 + const onChange = props.onChange; 18 + const hasValue = 'value' in props; 19 + 20 + return ( 21 + <fieldset class="flex flex-col gap-2"> 22 + <legend class="contents"> 23 + <span class="font-semibold text-gray-600">{props.label}</span> 24 + </legend> 25 + 26 + {props.options.map(({ value, label }, idx) => { 27 + const optionId = fieldId + idx; 28 + 29 + return ( 30 + <span class="flex items-center gap-3"> 31 + <input 32 + type="radio" 33 + id={optionId} 34 + name={props.name ?? fieldId} 35 + required={props.required} 36 + value={value} 37 + checked={hasValue ? props.value === value : false} 38 + class="border-gray-400 text-purple-800 focus:ring-purple-800" 39 + onInput={() => onChange?.(value)} 40 + /> 41 + 42 + <label for={optionId} class="text-sm"> 43 + {label} 44 + </label> 45 + </span> 46 + ); 47 + })} 48 + </fieldset> 49 + ); 50 + }; 51 + 52 + export default RadioInput;
+58
src/components/inputs/select-input.tsx
··· 1 + import { createEffect, JSX } from 'solid-js'; 2 + 3 + import { createId } from '~/lib/hooks/id'; 4 + 5 + interface SelectInputProps<T extends string> { 6 + label: JSX.Element; 7 + blurb?: string; 8 + name?: string; 9 + required?: boolean; 10 + value?: T; 11 + autofocus?: boolean; 12 + options: { value: T; label: string; disabled?: boolean }[]; 13 + onChange?: (next: T) => void; 14 + } 15 + 16 + const SelectInput = <T extends string>(props: SelectInputProps<T>) => { 17 + const fieldId = createId(); 18 + 19 + const onChange = props.onChange; 20 + 21 + return ( 22 + <div class="flex flex-col gap-2"> 23 + <label for={fieldId} class="font-semibold text-gray-600"> 24 + {props.label} 25 + </label> 26 + 27 + <select 28 + ref={(node) => { 29 + if ('autofocus' in props) { 30 + createEffect(() => { 31 + if (props.autofocus) { 32 + node.focus(); 33 + } 34 + }); 35 + } 36 + }} 37 + id={fieldId} 38 + name={props.name} 39 + required={props.required} 40 + value={props.value ?? ''} 41 + class="rounded border border-gray-400 py-2 pl-3 pr-8 text-sm focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 42 + onChange={(ev) => onChange?.(ev.target.value as T)} 43 + > 44 + {props.options.map((props) => { 45 + return ( 46 + <option value={/* @once */ props.value} disabled={props.disabled}> 47 + {props.label} 48 + </option> 49 + ); 50 + })} 51 + </select> 52 + 53 + <p class="text-pretty text-[0.8125rem] leading-5 text-gray-500 empty:hidden">{props.blurb}</p> 54 + </div> 55 + ); 56 + }; 57 + 58 + export default SelectInput;
+68
src/components/inputs/text-input.tsx
··· 1 + import { createEffect, JSX } from 'solid-js'; 2 + import { createId } from '~/lib/hooks/id'; 3 + 4 + interface TextInputProps { 5 + label: JSX.Element; 6 + blurb?: JSX.Element; 7 + monospace?: boolean; 8 + type?: 'text' | 'password' | 'url' | 'email'; 9 + name?: string; 10 + required?: boolean; 11 + autocomplete?: 'off' | 'on' | 'one-time-code'; 12 + autocorrect?: 'off' | 'on'; 13 + pattern?: string; 14 + placeholder?: string; 15 + value?: string; 16 + autofocus?: boolean; 17 + onChange?: (next: string) => void; 18 + } 19 + 20 + const textInputStyles = ({ monospace = false }: TextInputProps) => { 21 + let cn = `rounded border border-gray-400 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0`; 22 + 23 + if (monospace) { 24 + cn += ` font-mono tracking-wide`; 25 + } 26 + 27 + return cn; 28 + }; 29 + 30 + const TextInput = (props: TextInputProps) => { 31 + const fieldId = createId(); 32 + 33 + const onChange = props.onChange; 34 + 35 + return ( 36 + <div class="flex flex-col gap-2"> 37 + <label for={fieldId} class="font-semibold text-gray-600"> 38 + {props.label} 39 + </label> 40 + 41 + <input 42 + ref={(node) => { 43 + if ('autofocus' in props) { 44 + createEffect(() => { 45 + if (props.autofocus) { 46 + node.focus(); 47 + } 48 + }); 49 + } 50 + }} 51 + type={props.type ?? 'text'} 52 + id={fieldId} 53 + name={props.type} 54 + required={props.required} 55 + autocomplete={props.autocomplete} 56 + pattern={props.pattern} 57 + placeholder={props.placeholder} 58 + value={props.value ?? ''} 59 + class={textInputStyles(props)} 60 + onChange={(ev) => onChange?.(ev.target.value)} 61 + /> 62 + 63 + <p class="text-pretty text-[0.8125rem] leading-5 text-gray-500 empty:hidden">{props.blurb}</p> 64 + </div> 65 + ); 66 + }; 67 + 68 + export default TextInput;
+148
src/components/wizard.tsx
··· 1 + import { Component, createMemo, createSignal, For, JSX } from 'solid-js'; 2 + 3 + type EmptyObjectKeys<T> = { 4 + [K in keyof T]: T[K] extends Record<string, never> ? K : never; 5 + }[keyof T]; 6 + 7 + export type WizardConstraints = Record<string, Record<string, any>>; 8 + 9 + export interface WizardStepProps<TConstraints extends WizardConstraints, TStep extends keyof TConstraints> { 10 + data: TConstraints[TStep]; 11 + isActive: () => boolean; 12 + onNext: <TNext extends keyof TConstraints>(step: TNext, data: TConstraints[TNext]) => void; 13 + onPrevious: () => void; 14 + } 15 + 16 + export interface WizardProps<TConstraints extends WizardConstraints> { 17 + initialStep: EmptyObjectKeys<TConstraints>; 18 + components: { 19 + [TStep in keyof TConstraints]: Component<WizardStepProps<TConstraints, TStep>>; 20 + }; 21 + onStepChange?: (step: number) => void; 22 + } 23 + 24 + interface HistoryEntry<TConstraints extends WizardConstraints> { 25 + step: keyof TConstraints; 26 + data: TConstraints[keyof TConstraints]; 27 + } 28 + 29 + export const Wizard = <TConstraints extends WizardConstraints>(props: WizardProps<TConstraints>) => { 30 + const components = props.components; 31 + const onStepChange = props.onStepChange; 32 + 33 + const [history, setHistory] = createSignal<HistoryEntry<TConstraints>[]>([ 34 + // @ts-expect-error 35 + { step: props.initialStep, data: {} }, 36 + ]); 37 + 38 + const current = createMemo(() => { 39 + return history().length - 1; 40 + }); 41 + 42 + const handleNext = <TNext extends keyof TConstraints>(step: TNext, data: TConstraints[TNext]) => { 43 + const entries = history(); 44 + 45 + setHistory([...entries, { step, data }]); 46 + onStepChange?.(entries.length + 1); 47 + }; 48 + 49 + const handleBack = () => { 50 + const entries = history(); 51 + 52 + if (entries.length > 1) { 53 + setHistory(entries.slice(0, -1)); 54 + onStepChange?.(entries.length - 1); 55 + } 56 + }; 57 + 58 + return ( 59 + <div class="pb-8"> 60 + <For each={history()}> 61 + {({ step, data }, index) => { 62 + const Component = components[step]; 63 + 64 + const isActive = createMemo(() => current() === index()); 65 + 66 + return ( 67 + <fieldset 68 + disabled={!isActive()} 69 + class={`flex min-w-0 gap-4 px-4` + (!isActive() ? ` opacity-50` : ``)} 70 + > 71 + <div class="flex flex-col items-center gap-1 pt-4"> 72 + <div class="grid h-6 w-6 place-items-center rounded-full bg-gray-200 py-1 text-center text-sm font-medium leading-none text-black"> 73 + {'' + (index() + 1)} 74 + </div> 75 + <div hidden={isActive()} class="-mb-3 grow border-l border-gray-400"></div> 76 + </div> 77 + 78 + <Component data={data} isActive={isActive} onNext={handleNext} onPrevious={handleBack} /> 79 + </fieldset> 80 + ); 81 + }} 82 + </For> 83 + </div> 84 + ); 85 + }; 86 + 87 + export interface StageProps { 88 + title: string; 89 + disabled?: boolean; 90 + onSubmit?: JSX.EventHandler<HTMLFormElement, SubmitEvent>; 91 + children: JSX.Element; 92 + } 93 + 94 + export const Stage = (props: StageProps) => { 95 + const onSubmit = props.onSubmit; 96 + 97 + return ( 98 + <form 99 + onSubmit={(ev) => { 100 + ev.preventDefault(); 101 + onSubmit?.(ev); 102 + }} 103 + class="flex min-w-0 grow flex-col py-4" 104 + > 105 + <h3 class="mb-[1.125rem] mt-0.5 text-sm font-semibold">{props.title}</h3> 106 + <fieldset 107 + disabled={props.disabled} 108 + class={`flex min-w-0 flex-col gap-6` + (props.disabled ? ` opacity-50` : ``)} 109 + > 110 + {props.children} 111 + </fieldset> 112 + </form> 113 + ); 114 + }; 115 + 116 + export interface StageActionsProps { 117 + hidden?: boolean; 118 + children: JSX.Element; 119 + } 120 + 121 + export interface StageActionsDividerProps {} 122 + 123 + export const StageActions = (props: StageActionsProps) => { 124 + return ( 125 + <div hidden={props.hidden} class="flex flex-wrap gap-4"> 126 + {props.children} 127 + </div> 128 + ); 129 + }; 130 + 131 + StageActions.Divider = (_props: StageActionsDividerProps) => { 132 + return <div class="grow"></div>; 133 + }; 134 + 135 + export interface StageErrorViewProps { 136 + error: string | undefined; 137 + } 138 + 139 + export const StageErrorView = (props: StageErrorViewProps) => { 140 + return ( 141 + <div 142 + hidden={!props.error} 143 + class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800" 144 + > 145 + {'' + props.error} 146 + </div> 147 + ); 148 + };
+5
src/lib/hooks/id.ts
··· 1 + let uid = 0; 2 + 3 + export const createId = () => { 4 + return `_${uid++}_`; 5 + };
+8
src/lib/utils/confirmation-code.ts
··· 1 + import { customAlphabet } from 'nanoid'; 2 + 3 + const generateCode = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', 10); 4 + 5 + export const generateConfirmationCode = () => { 6 + const code = generateCode(); 7 + return `${code.slice(0, 5)}-${code.slice(5, 10)}`; 8 + };
+145
src/lib/utils/mutation.ts
··· 1 + import { createSignal } from 'solid-js'; 2 + import { makeAbortable } from './abortable'; 3 + 4 + export interface SuccessMutationState<D, V> { 5 + data: D; 6 + error: undefined; 7 + variables: V; 8 + isSuccess: true; 9 + isError: false; 10 + isPending: false; 11 + isIdle: false; 12 + } 13 + 14 + export interface ErrorMutationState<V> { 15 + data: undefined; 16 + error: unknown; 17 + variables: V; 18 + isSuccess: false; 19 + isError: true; 20 + isPending: false; 21 + isIdle: false; 22 + } 23 + 24 + export interface PendingMutationState<V> { 25 + data: undefined; 26 + error: undefined; 27 + variables: V; 28 + isSuccess: false; 29 + isError: false; 30 + isPending: true; 31 + isIdle: false; 32 + } 33 + 34 + export interface IdleMutationState { 35 + data: undefined; 36 + error: undefined; 37 + variables: undefined; 38 + isSuccess: false; 39 + isError: false; 40 + isPending: false; 41 + isIdle: true; 42 + } 43 + 44 + export type MutationReturn<D, V> = ( 45 + | IdleMutationState 46 + | PendingMutationState<V> 47 + | SuccessMutationState<D, V> 48 + | ErrorMutationState<V> 49 + ) & { 50 + mutate(variables: V): void; 51 + mutateAsync(variables: V): Promise<D>; 52 + }; 53 + 54 + type MutationFunction<D = unknown, V = unknown> = (variables: V, signal: AbortSignal) => Promise<D>; 55 + 56 + export interface MutationOptions<D = unknown, V = unknown> { 57 + mutationFn: MutationFunction<D, V>; 58 + onMutate?: (variables: V) => void; 59 + onSuccess?: (data: NoInfer<D>, variables: NoInfer<V>) => void; 60 + onError?: (error: unknown, variables: NoInfer<V>) => void; 61 + onSettled?: (data: NoInfer<D> | undefined, error: unknown, variables: NoInfer<V>) => void; 62 + } 63 + 64 + const enum MutationState { 65 + IDLE, 66 + PENDING, 67 + SUCCESS, 68 + ERROR, 69 + } 70 + 71 + export const createMutation = <D, V = void>(options: MutationOptions<D, V>): MutationReturn<D, V> => { 72 + const [getSignal, cleanup] = makeAbortable(); 73 + const [state, setState] = createSignal<{ s: MutationState; v?: any; d?: any; e?: any }>( 74 + { s: MutationState.IDLE }, 75 + { equals: (prev, next) => prev.s === next.s }, 76 + ); 77 + 78 + const mutate = async (variables: V): Promise<D> => { 79 + const signal = getSignal(); 80 + 81 + setState({ s: MutationState.PENDING, v: variables }); 82 + 83 + try { 84 + options.onMutate?.(variables); 85 + 86 + const data = await options.mutationFn(variables, signal); 87 + 88 + if (!signal.aborted) { 89 + options.onSuccess?.(data, variables); 90 + options.onSettled?.(data, undefined, variables); 91 + 92 + setState({ s: MutationState.SUCCESS, v: variables, d: data }); 93 + } 94 + 95 + return data; 96 + } catch (err) { 97 + if (!signal.aborted) { 98 + options.onError?.(err, variables); 99 + options.onSettled?.(undefined, err, variables); 100 + 101 + setState({ s: MutationState.ERROR, v: variables, e: err }); 102 + } 103 + 104 + throw err; 105 + } finally { 106 + if (!signal.aborted) { 107 + cleanup(); 108 + } 109 + } 110 + }; 111 + 112 + return { 113 + get data() { 114 + const $state = state(); 115 + if ($state.s === MutationState.SUCCESS) { 116 + return $state.d; 117 + } 118 + }, 119 + get error() { 120 + const $state = state(); 121 + if ($state.s === MutationState.ERROR) { 122 + return $state.e; 123 + } 124 + }, 125 + get variables() { 126 + return state().v; 127 + }, 128 + get isSuccess() { 129 + return state().s === MutationState.SUCCESS; 130 + }, 131 + get isError() { 132 + return state().s === MutationState.ERROR; 133 + }, 134 + get isPending() { 135 + return state().s === MutationState.PENDING; 136 + }, 137 + get isIdle() { 138 + return state().s === MutationState.IDLE; 139 + }, 140 + mutateAsync: mutate, 141 + mutate: (variables: V) => mutate(variables).then(noop, noop), 142 + } as any; 143 + }; 144 + 145 + const noop = () => {};
+1 -1
src/routes.ts
··· 23 23 }, 24 24 { 25 25 path: '/plc-applicator', 26 - component: lazy(() => import('./views/identity/plc-applicator')), 26 + component: lazy(() => import('./views/identity/plc-applicator/page')), 27 27 }, 28 28 29 29 {
-1192
src/views/identity/plc-applicator.tsx
··· 1 - import { createEffect, createSignal, JSX, Match, onCleanup, Show, Switch } from 'solid-js'; 2 - import { createMutable, unwrap } from 'solid-js/store'; 3 - 4 - import * as CBOR from '@atcute/cbor'; 5 - import { AtpSessionData, CredentialManager, XRPC, XRPCError } from '@atcute/client'; 6 - import { At, ComAtprotoIdentityGetRecommendedDidCredentials } from '@atcute/client/lexicons'; 7 - 8 - import { P256Keypair, Secp256k1Keypair, verifySignature } from '@atproto/crypto'; 9 - import * as uint8arrays from 'uint8arrays'; 10 - 11 - import { getDidDocument } from '~/api/queries/did-doc'; 12 - import { resolveHandleViaAppView } from '~/api/queries/handle'; 13 - import { getPlcAuditLogs } from '~/api/queries/plc'; 14 - import { DidDocument, getPdsEndpoint } from '~/api/types/did-doc'; 15 - import { PlcLogEntry, PlcUpdateOp, PlcUpdatePayload, updatePayload } from '~/api/types/plc'; 16 - import { DID_OR_HANDLE_RE, isDid } from '~/api/utils/strings'; 17 - 18 - import { history } from '~/globals/navigation'; 19 - 20 - import { useTitle } from '~/lib/navigation/router'; 21 - import { assert } from '~/lib/utils/invariant'; 22 - 23 - const EMAIL_OTP_RE = /^([a-zA-Z0-9]{5})[\- ]?([a-zA-Z0-9]{5})$/; 24 - 25 - const PlcUpdatePage = () => { 26 - const [step, setStep] = createSignal(1); 27 - const [pending, setPending] = createSignal(false); 28 - 29 - const [error, setError] = createSignal<{ step: number; message: string }>(); 30 - 31 - const states = createMutable<{ 32 - didDoc?: DidDocument; 33 - logs?: Awaited<ReturnType<typeof getPlcKeying>>; 34 - 35 - rotationKeyType?: 'owned' | 'pds'; 36 - ownedRotationKey?: { 37 - keypair: P256Keypair | Secp256k1Keypair; 38 - didPublicKey: string; 39 - }; 40 - pdsData?: { 41 - service: string; 42 - session: AtpSessionData; 43 - rpc: XRPC; 44 - recommendedDidDoc: ComAtprotoIdentityGetRecommendedDidCredentials.Output; 45 - }; 46 - accountHasOtp?: boolean; 47 - 48 - prev?: PlcLogEntry; 49 - payload?: PlcUpdatePayload; 50 - }>({}); 51 - 52 - useTitle(() => `Apply PLC operations — boat`); 53 - 54 - createEffect(() => { 55 - const $step = step(); 56 - if (($step > 1 && $step < 6) || pending()) { 57 - const cleanup = history.block((tx) => { 58 - if (window.confirm(`Abort this action?`)) { 59 - cleanup(); 60 - tx.retry(); 61 - } 62 - }); 63 - 64 - onCleanup(cleanup); 65 - } 66 - }); 67 - 68 - return ( 69 - <fieldset disabled={pending()} class="contents"> 70 - <div class="p-4"> 71 - <h1 class="text-lg font-bold text-purple-800">Apply PLC operations</h1> 72 - <p class="text-gray-600">Submit operations to your did:plc identity</p> 73 - </div> 74 - <hr class="mx-4 border-gray-300" /> 75 - 76 - <StepPage 77 - step={1} 78 - title="Enter the did:plc identity you want to edit" 79 - current={step()} 80 - onSubmit={async (form) => { 81 - try { 82 - setPending(true); 83 - setError(); 84 - 85 - const identifier = form.get('ident') as string; 86 - const rotation = form.get('rotation') as 'owned' | 'pds'; 87 - 88 - let did: At.DID; 89 - if (isDid(identifier)) { 90 - did = identifier; 91 - } else { 92 - did = await resolveHandleViaAppView({ handle: identifier }); 93 - } 94 - 95 - if (!did.startsWith('did:plc:')) { 96 - setError({ step: 1, message: `"${did}" is not did:plc` }); 97 - return; 98 - } 99 - 100 - const [didDoc, logs] = await Promise.all([getDidDocument({ did }), getPlcAuditLogs({ did })]); 101 - 102 - states.didDoc = didDoc; 103 - states.logs = await getPlcKeying(logs); 104 - 105 - states.rotationKeyType = rotation; 106 - 107 - if (rotation === 'owned') { 108 - states.pdsData = undefined; 109 - } else if (rotation === 'pds') { 110 - states.ownedRotationKey = undefined; 111 - } 112 - 113 - if (states.pdsData) { 114 - if (states.pdsData.session.did !== did) { 115 - states.pdsData = undefined; 116 - states.accountHasOtp = false; 117 - } 118 - } 119 - 120 - setStep(2); 121 - } catch (err) { 122 - console.error(err); 123 - setError({ step: 1, message: `Something went wrong: ${err}` }); 124 - } finally { 125 - setPending(false); 126 - } 127 - }} 128 - > 129 - <label class="flex flex-col gap-2"> 130 - <span class="font-semibold text-gray-600">Handle or DID identifier</span> 131 - <input 132 - ref={(node) => { 133 - createEffect(() => { 134 - if (step() === 1) { 135 - setTimeout(() => node.focus(), 1); 136 - } 137 - }); 138 - }} 139 - type="text" 140 - name="ident" 141 - required 142 - pattern={/* @once */ DID_OR_HANDLE_RE.source} 143 - placeholder="paul.bsky.social" 144 - class="rounded border border-gray-400 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 145 - /> 146 - </label> 147 - 148 - <fieldset class="mt-6 flex flex-col gap-2"> 149 - <span class="font-semibold text-gray-600">I will be using...</span> 150 - 151 - <label class="flex items-center gap-3"> 152 - <input 153 - type="radio" 154 - name="rotation" 155 - required 156 - value="pds" 157 - class="border-gray-400 text-purple-800 focus:ring-purple-800" 158 - /> 159 - <span class="text-sm">my PDS' rotation key (requires sign in)</span> 160 - </label> 161 - 162 - <label class="flex items-center gap-3"> 163 - <input 164 - type="radio" 165 - name="rotation" 166 - required 167 - value="owned" 168 - class="border-gray-400 text-purple-800 focus:ring-purple-800" 169 - /> 170 - <span class="text-sm">my own rotation key</span> 171 - </label> 172 - </fieldset> 173 - 174 - <ErrorMessageView step={1} error={error()} /> 175 - 176 - <div hidden={step() !== 1} class="mt-6 flex flex-wrap gap-4"> 177 - <div class="grow"></div> 178 - 179 - <button 180 - type="submit" 181 - class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 182 - > 183 - Next 184 - </button> 185 - </div> 186 - </StepPage> 187 - 188 - <Switch> 189 - <Match when={states.rotationKeyType === 'pds'}> 190 - <StepPage 191 - step={2} 192 - title="Sign in to your PDS" 193 - current={step()} 194 - onSubmit={async (form) => { 195 - if (states.pdsData) { 196 - setStep(3); 197 - return; 198 - } 199 - 200 - assert(states.didDoc); 201 - 202 - try { 203 - setPending(true); 204 - setError(); 205 - 206 - const service = form.get('service') as string; 207 - const pass = form.get('pass') as string; 208 - const otp = form.get('otp') as string | null; 209 - 210 - const manager = new CredentialManager({ service }); 211 - const session = await manager.login({ 212 - identifier: states.didDoc.id, 213 - password: pass, 214 - code: otp ? formatEmailOtpCode(otp) : undefined, 215 - }); 216 - 217 - const rpc = new XRPC({ handler: manager }); 218 - const { data: recommendedDidDoc } = await rpc.get( 219 - 'com.atproto.identity.getRecommendedDidCredentials', 220 - {}, 221 - ); 222 - 223 - const data = { 224 - service, 225 - session, 226 - recommendedDidDoc, 227 - rpc, 228 - }; 229 - 230 - states.pdsData = data; 231 - states.accountHasOtp = false; 232 - 233 - setStep(3); 234 - } catch (err) { 235 - let msg: string | undefined; 236 - 237 - if (err instanceof XRPCError) { 238 - if (err.kind === 'AuthFactorTokenRequired') { 239 - states.accountHasOtp = true; 240 - return; 241 - } 242 - 243 - if (err.kind === 'AuthenticationRequired') { 244 - msg = `Invalid identifier or password`; 245 - } else if (err.kind === 'AccountTakedown') { 246 - msg = `Account has been taken down`; 247 - } else if (err.message.includes('Token is invalid')) { 248 - msg = `Invalid one-time confirmation code`; 249 - states.accountHasOtp = true; 250 - } 251 - } 252 - 253 - if (msg !== undefined) { 254 - setError({ step: 2, message: msg }); 255 - } else { 256 - console.error(err); 257 - setError({ step: 2, message: `Something went wrong: ${err}` }); 258 - } 259 - } finally { 260 - setPending(false); 261 - } 262 - }} 263 - > 264 - <Show when={states.pdsData}> 265 - {(session) => ( 266 - <p class="break-words"> 267 - Signed in via <b>{session().service}</b>.{' '} 268 - <button 269 - type="button" 270 - onClick={() => (states.pdsData = undefined)} 271 - hidden={step() !== 2} 272 - class="text-purple-800 hover:underline disabled:pointer-events-none" 273 - > 274 - Sign out? 275 - </button> 276 - </p> 277 - )} 278 - </Show> 279 - 280 - <Show when={!states.pdsData}> 281 - <label class="flex flex-col gap-2"> 282 - <span class="font-semibold text-gray-600">PDS service</span> 283 - <input 284 - type="url" 285 - name="service" 286 - required 287 - value={(states.didDoc && getPdsEndpoint(states.didDoc)) || ''} 288 - placeholder="https://bsky.social" 289 - class="rounded border border-gray-400 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 290 - /> 291 - </label> 292 - 293 - <label class="mt-6 flex flex-col gap-2"> 294 - <span class="font-semibold text-gray-600">Main password</span> 295 - <input 296 - ref={(node) => { 297 - createEffect(() => { 298 - if (step() === 2) { 299 - setTimeout(() => node.focus(), 1); 300 - } 301 - }); 302 - }} 303 - type="password" 304 - name="pass" 305 - required 306 - class="rounded border border-gray-400 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 307 - /> 308 - </label> 309 - 310 - <Show when={states.accountHasOtp}> 311 - <label class="mt-6 flex flex-col gap-2"> 312 - <span class="font-semibold text-gray-600">One-time confirmation code</span> 313 - <input 314 - type="text" 315 - name="otp" 316 - required 317 - autocomplete="one-time-code" 318 - pattern={/* @once */ EMAIL_OTP_RE.source} 319 - placeholder="AAAAA-BBBBB" 320 - class="rounded border border-gray-400 px-3 py-2 font-mono text-sm tracking-wide placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 321 - /> 322 - </label> 323 - </Show> 324 - 325 - <p class="mt-2 text-[0.8125rem] leading-5 text-gray-500"> 326 - This app runs locally on your browser, your credentials stays entirely within your device. 327 - </p> 328 - </Show> 329 - 330 - <ErrorMessageView step={2} error={error()} /> 331 - 332 - <div hidden={step() !== 2} class="mt-6 flex flex-wrap gap-4"> 333 - <div class="grow"></div> 334 - 335 - <button 336 - type="button" 337 - onClick={() => setStep(1)} 338 - class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 339 - > 340 - Previous 341 - </button> 342 - 343 - <button 344 - type="submit" 345 - class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 346 - > 347 - Next 348 - </button> 349 - </div> 350 - </StepPage> 351 - </Match> 352 - 353 - <Match when={states.rotationKeyType === 'owned'}> 354 - <StepPage 355 - step={2} 356 - title="Enter your private key" 357 - current={step()} 358 - onSubmit={async (form) => { 359 - try { 360 - setPending(true); 361 - setError(); 362 - 363 - const key = form.get('key') as string; 364 - const type = form.get('type') as 'secp256k1' | 'nistp256'; 365 - 366 - let keypair: P256Keypair | Secp256k1Keypair; 367 - 368 - if (type === 'nistp256') { 369 - keypair = await P256Keypair.import(key); 370 - } else if (type === 'secp256k1') { 371 - keypair = await Secp256k1Keypair.import(key); 372 - } else { 373 - throw new Error(`unsupported '${type}' type`); 374 - } 375 - 376 - states.ownedRotationKey = { didPublicKey: keypair.did(), keypair: keypair }; 377 - 378 - setStep(3); 379 - } catch (err) { 380 - let msg: string | undefined; 381 - 382 - if (msg !== undefined) { 383 - setError({ step: 2, message: msg }); 384 - } else { 385 - console.error(err); 386 - setError({ step: 2, message: `Something went wrong: ${err}` }); 387 - } 388 - } finally { 389 - setPending(false); 390 - } 391 - }} 392 - > 393 - <label class="flex flex-col gap-2"> 394 - <span class="font-semibold text-gray-600">Hex-encoded private key</span> 395 - <input 396 - ref={(node) => { 397 - createEffect(() => { 398 - if (step() === 2) { 399 - setTimeout(() => node.focus(), 1); 400 - } 401 - }); 402 - }} 403 - type={step() === 2 ? 'text' : 'password'} 404 - name="key" 405 - required 406 - autocomplete="off" 407 - autocorrect="off" 408 - placeholder="a5973930f9d348..." 409 - pattern="[0-9a-f]+" 410 - class="rounded border border-gray-400 px-3 py-2 font-mono text-sm placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 411 - /> 412 - </label> 413 - <p hidden={step() !== 2} class="mt-2 text-[0.8125rem] leading-5 text-gray-500"> 414 - This app runs locally on your browser, your private key stays entirely within your device. 415 - </p> 416 - 417 - <fieldset class="mt-6 flex flex-col gap-2"> 418 - <span class="font-semibold text-gray-600">This is a...</span> 419 - 420 - <label class="flex items-start gap-3"> 421 - <input 422 - type="radio" 423 - name="type" 424 - required 425 - value="secp256k1" 426 - class="border-gray-400 text-purple-800 focus:ring-purple-800" 427 - /> 428 - <span class="text-sm">ES256K (secp256k1) private key</span> 429 - </label> 430 - 431 - <label class="flex items-start gap-3"> 432 - <input 433 - type="radio" 434 - name="type" 435 - required 436 - value="nistp256" 437 - class="border-gray-400 text-purple-800 focus:ring-purple-800" 438 - /> 439 - <span class="text-sm">ES256 (nistp256) private key</span> 440 - </label> 441 - </fieldset> 442 - 443 - <ErrorMessageView step={2} error={error()} /> 444 - 445 - <div hidden={step() !== 2} class="mt-6 flex flex-wrap gap-4"> 446 - <div class="grow"></div> 447 - 448 - <button 449 - type="button" 450 - onClick={() => setStep(1)} 451 - class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 452 - > 453 - Previous 454 - </button> 455 - 456 - <button 457 - type="submit" 458 - class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 459 - > 460 - Next 461 - </button> 462 - </div> 463 - </StepPage> 464 - </Match> 465 - </Switch> 466 - 467 - <StepPage 468 - step={3} 469 - title="Select which operation to use as foundation" 470 - current={step()} 471 - onSubmit={(form) => { 472 - setError(); 473 - 474 - const cid = form.get('cid') as string; 475 - const entry = states.logs?.find((entry) => entry.cid === cid); 476 - 477 - if (!entry) { 478 - setError({ step: 3, message: `Can't find CID ${cid}` }); 479 - return; 480 - } 481 - 482 - const op = entry.operation; 483 - if (op.type !== 'plc_operation' && op.type !== 'create') { 484 - setError({ step: 3, message: `Expected op to be 'plc_operation' or 'create'` }); 485 - return; 486 - } 487 - 488 - states.prev = entry; 489 - states.payload = getPlcPayload(entry); 490 - 491 - setStep(4); 492 - }} 493 - > 494 - <label class="flex flex-col gap-2"> 495 - <span class="font-semibold text-gray-600">Base operation</span> 496 - 497 - <select 498 - ref={(node) => { 499 - createEffect(() => { 500 - if (step() === 3) { 501 - setTimeout(() => node.focus(), 1); 502 - } 503 - }); 504 - }} 505 - name="cid" 506 - value="" 507 - required 508 - class="rounded border border-gray-400 py-2 pl-3 pr-8 text-sm focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 509 - > 510 - <option value="">Select an operation...</option> 511 - {(() => { 512 - const logs = states.logs; 513 - if (!logs) { 514 - return null; 515 - } 516 - 517 - const rotationKeyType = states.rotationKeyType; 518 - 519 - let ownKey: string | undefined; 520 - if (rotationKeyType === 'pds') { 521 - ownKey = states.pdsData?.recommendedDidDoc.rotationKeys?.at(-1); 522 - } else if (rotationKeyType === 'owned') { 523 - ownKey = states.ownedRotationKey?.didPublicKey; 524 - } 525 - 526 - if (ownKey === undefined) { 527 - return []; 528 - } 529 - 530 - const length = logs.length; 531 - const nodes = logs.map((entry, idx) => { 532 - const signers = getCurrentSignersFromEntry(entry); 533 - const last = idx === length - 1; 534 - 535 - let enabled = signers.includes(ownKey!); 536 - 537 - // If we're showing older operations for forking/nullification, 538 - // check to see that our key has priority over the signer. 539 - if (enabled && !last) { 540 - if (rotationKeyType === 'pds') { 541 - // `signPlcOperation` will always grab the last op 542 - enabled = false; 543 - } else { 544 - const holderKey = logs[idx + 1].signedBy; 545 - 546 - const holderPriority = signers.indexOf(holderKey); 547 - const ownPriority = signers.indexOf(ownKey); 548 - 549 - enabled = ownPriority < holderPriority; 550 - } 551 - } 552 - 553 - return ( 554 - <option disabled={!enabled} value={/* @once */ entry.cid}> 555 - {/* @once */ `${entry.createdAt} (by ${entry.signedBy})`} 556 - </option> 557 - ); 558 - }); 559 - 560 - return nodes.reverse(); 561 - })()} 562 - </select> 563 - </label> 564 - 565 - <p class="mt-2 text-[0.8125rem] leading-5 text-gray-500"> 566 - Some operations can't be used as a base if the rotation key does not have the privilege for 567 - nullification, or if it is not listed. 568 - </p> 569 - 570 - <ErrorMessageView step={3} error={error()} /> 571 - 572 - <div hidden={step() !== 3} class="mt-6 flex flex-wrap gap-4"> 573 - <div class="grow"></div> 574 - 575 - <button 576 - type="button" 577 - onClick={() => setStep(2)} 578 - class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 579 - > 580 - Previous 581 - </button> 582 - 583 - <button 584 - type="submit" 585 - class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 586 - > 587 - Next 588 - </button> 589 - </div> 590 - </StepPage> 591 - 592 - <StepPage 593 - step={4} 594 - title="Enter your payload" 595 - current={step()} 596 - onSubmit={(form) => { 597 - setError(); 598 - 599 - const payload = form.get('payload') as string; 600 - 601 - let json: unknown; 602 - try { 603 - json = JSON.parse(payload); 604 - } catch { 605 - setError({ step: 4, message: `Unable to parse JSON` }); 606 - return; 607 - } 608 - 609 - const result = updatePayload.try(json); 610 - if (!result.ok) { 611 - setError({ step: 4, message: result.message }); 612 - return; 613 - } 614 - 615 - states.payload = result.value; 616 - 617 - setStep(5); 618 - }} 619 - > 620 - <label class="flex flex-col gap-2"> 621 - <span class="font-semibold text-gray-600">Payload input</span> 622 - 623 - <textarea 624 - ref={(node) => { 625 - createEffect(() => { 626 - if (step() === 4) { 627 - setTimeout(() => node.focus(), 1); 628 - } 629 - }); 630 - }} 631 - name="payload" 632 - required 633 - rows={22} 634 - value={JSON.stringify(states.payload, null, 2)} 635 - class="resize-y break-all rounded border border-gray-400 px-3 py-2 font-mono text-xs tracking-wider placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 636 - style="field-sizing: content" 637 - /> 638 - </label> 639 - 640 - <div hidden={step() !== 4} class="mt-2 flex flex-wrap gap-4"> 641 - {states.pdsData && ( 642 - <button 643 - type="button" 644 - onClick={() => { 645 - const entry = unwrap(states.prev); 646 - assert(entry !== undefined); 647 - 648 - const recommended = unwrap(states.pdsData!.recommendedDidDoc); 649 - const payload = getPlcPayload(entry); 650 - 651 - if (recommended.alsoKnownAs) { 652 - payload.alsoKnownAs = recommended.alsoKnownAs; 653 - } 654 - if (recommended.rotationKeys) { 655 - payload.rotationKeys = recommended.rotationKeys; 656 - } 657 - if (recommended.services) { 658 - // @ts-expect-error 659 - payload.services = recommended.services; 660 - } 661 - if (recommended.verificationMethods) { 662 - // @ts-expect-error 663 - payload.verificationMethods = recommended.verificationMethods; 664 - } 665 - 666 - states.payload = payload; 667 - }} 668 - class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 669 - > 670 - Use PDS recommendation 671 - </button> 672 - )} 673 - 674 - <button 675 - type="button" 676 - onClick={() => { 677 - const entry = unwrap(states.prev); 678 - assert(entry !== undefined); 679 - 680 - const payload = getPlcPayload(entry); 681 - 682 - states.payload = payload; 683 - }} 684 - class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 685 - > 686 - Reset to default 687 - </button> 688 - </div> 689 - 690 - <ErrorMessageView step={4} error={error()} /> 691 - 692 - <div hidden={step() !== 4} class="mt-6 flex flex-wrap gap-4"> 693 - <div class="grow"></div> 694 - 695 - <button 696 - type="button" 697 - onClick={() => setStep(3)} 698 - class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 699 - > 700 - Previous 701 - </button> 702 - 703 - <button 704 - type="submit" 705 - class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 706 - > 707 - Next 708 - </button> 709 - </div> 710 - </StepPage> 711 - 712 - <Switch> 713 - <Match when={states.rotationKeyType === 'pds'}> 714 - <StepPage 715 - step={5} 716 - title="One more step" 717 - current={step()} 718 - onSubmit={async (form) => { 719 - try { 720 - setPending(true); 721 - setError(); 722 - 723 - const code = form.get('code') as string; 724 - 725 - const rpc = states.pdsData!.rpc; 726 - const payload = states.payload!; 727 - 728 - const { data: signage } = await rpc.call('com.atproto.identity.signPlcOperation', { 729 - data: { 730 - token: code, 731 - alsoKnownAs: payload.alsoKnownAs, 732 - rotationKeys: payload.rotationKeys, 733 - services: payload.services, 734 - verificationMethods: payload.verificationMethods, 735 - }, 736 - }); 737 - 738 - await rpc.call('com.atproto.identity.submitPlcOperation', { 739 - data: { 740 - operation: signage.operation, 741 - }, 742 - }); 743 - 744 - setStep(6); 745 - } catch (err) { 746 - let msg: string | undefined; 747 - 748 - if (err instanceof XRPCError) { 749 - if (err.kind === 'InvalidToken' || err.kind === 'ExpiredToken') { 750 - msg = `Confirmation code has expired`; 751 - } 752 - } 753 - 754 - if (msg !== undefined) { 755 - setError({ step: 5, message: msg }); 756 - } else { 757 - console.error(err); 758 - setError({ step: 5, message: `Something went wrong: ${err}` }); 759 - } 760 - } finally { 761 - setPending(false); 762 - } 763 - }} 764 - > 765 - <p> 766 - To continue with this submission, you will need to request a confirmation code from your PDS. 767 - This code will be sent to your account's email address. 768 - </p> 769 - 770 - <label class="mt-6 flex flex-col gap-2"> 771 - <span class="font-semibold text-gray-600">One-time confirmation code</span> 772 - <input 773 - ref={(node) => { 774 - createEffect(() => { 775 - if (step() === 5) { 776 - setTimeout(() => node.focus(), 1); 777 - } 778 - }); 779 - }} 780 - type="text" 781 - name="code" 782 - required 783 - autocomplete="one-time-code" 784 - pattern={/* @once */ EMAIL_OTP_RE.source} 785 - placeholder="AAAAA-BBBBB" 786 - class="rounded border border-gray-400 px-3 py-2 font-mono text-sm tracking-wide placeholder:text-gray-400 focus:border-purple-800 focus:ring-1 focus:ring-purple-800 focus:ring-offset-0" 787 - /> 788 - </label> 789 - 790 - <div hidden={step() !== 5} class="mt-2 flex flex-wrap gap-4"> 791 - <button 792 - type="button" 793 - onClick={async () => { 794 - try { 795 - const rpc = states.pdsData!.rpc; 796 - 797 - await rpc.call('com.atproto.identity.requestPlcOperationSignature', {}); 798 - alert(`Confirmation code has been sent, check your email inbox.`); 799 - } catch (err) { 800 - let msg: string | undefined; 801 - 802 - if (err instanceof XRPCError) { 803 - if (err.message.includes(`does not have an email address`)) { 804 - msg = `Account does not have an email address`; 805 - } else if (err.message.includes(`not found`)) { 806 - msg = `Account is not registered on the PDS`; 807 - } 808 - } 809 - 810 - if (msg !== undefined) { 811 - setError({ step: 5, message: msg }); 812 - } else { 813 - console.error(err); 814 - setError({ step: 5, message: `Something went wrong: ${err}` }); 815 - } 816 - } 817 - }} 818 - class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 819 - > 820 - Request confirmation code 821 - </button> 822 - </div> 823 - 824 - <p class="mt-6"> 825 - Now, relax. Take a breather. Verify that you have provided the intended payload, and hit{' '} 826 - <i>Submit</i> when you're ready. 827 - </p> 828 - 829 - <p class="mt-3 text-[0.8125rem] font-medium leading-5 text-red-800"> 830 - Caution: This action carries significant risk which can possibly render your did:plc identity 831 - unusable. Proceed at your own risk, we assume no liability for any consequences. 832 - </p> 833 - 834 - <label class="mt-6 flex items-start gap-3"> 835 - <input 836 - type="checkbox" 837 - name="confirm" 838 - required 839 - class="rounded border-gray-400 text-purple-800 focus:ring-purple-800" 840 - /> 841 - <span class="text-sm">I have verified and am ready to proceed</span> 842 - </label> 843 - 844 - <ErrorMessageView step={5} error={error()} /> 845 - 846 - <div hidden={step() !== 5} class="mt-6 flex flex-wrap gap-4"> 847 - <div class="grow"></div> 848 - 849 - <button 850 - type="button" 851 - onClick={() => setStep(4)} 852 - class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 853 - > 854 - Previous 855 - </button> 856 - 857 - <button 858 - type="submit" 859 - class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 860 - > 861 - Submit 862 - </button> 863 - </div> 864 - </StepPage> 865 - </Match> 866 - 867 - <Match when={states.rotationKeyType === 'owned'}> 868 - <StepPage 869 - step={5} 870 - title="One more step" 871 - current={step()} 872 - onSubmit={async () => { 873 - try { 874 - setPending(true); 875 - setError(); 876 - 877 - const keypair = states.ownedRotationKey!.keypair; 878 - const payload = states.payload!; 879 - const prev = states.prev!; 880 - 881 - const operation: Omit<PlcUpdateOp, 'sig'> = { 882 - type: 'plc_operation', 883 - prev: prev!.cid, 884 - 885 - alsoKnownAs: payload.alsoKnownAs, 886 - rotationKeys: payload.rotationKeys, 887 - services: payload.services, 888 - verificationMethods: payload.verificationMethods, 889 - }; 890 - 891 - const opBytes = CBOR.encode(operation); 892 - const sigBytes = await keypair.sign(opBytes); 893 - 894 - const signature = uint8arrays.toString(sigBytes, 'base64url'); 895 - 896 - const signedOperation: PlcUpdateOp = { 897 - ...operation, 898 - sig: signature, 899 - }; 900 - 901 - await pushPlcOperation(states.didDoc!.id, signedOperation); 902 - 903 - setStep(6); 904 - } catch (err) { 905 - let msg: string | undefined; 906 - 907 - if (msg !== undefined) { 908 - setError({ step: 5, message: msg }); 909 - } else { 910 - console.error(err); 911 - setError({ step: 5, message: `Something went wrong: ${err}` }); 912 - } 913 - } finally { 914 - setPending(false); 915 - } 916 - }} 917 - > 918 - <p> 919 - Now, relax. Take a breather. Verify that you have provided the intended payload, and hit{' '} 920 - <i>Submit</i> when you're ready. 921 - </p> 922 - 923 - <p class="mt-3 text-[0.8125rem] font-medium leading-5 text-red-800"> 924 - Caution: This action carries significant risk which can possibly render your did:plc identity 925 - unusable. Proceed at your own risk, we assume no liability for any consequences. 926 - </p> 927 - 928 - <label class="mt-6 flex items-start gap-3"> 929 - <input 930 - ref={(node) => { 931 - createEffect(() => { 932 - if (step() === 5) { 933 - setTimeout(() => node.focus(), 1); 934 - } 935 - }); 936 - }} 937 - type="checkbox" 938 - name="confirm" 939 - required 940 - class="rounded border-gray-400 text-purple-800 focus:ring-purple-800" 941 - /> 942 - <span class="text-sm">I have verified and am ready to proceed</span> 943 - </label> 944 - 945 - <ErrorMessageView step={5} error={error()} /> 946 - 947 - <div hidden={step() !== 5} class="mt-6 flex flex-wrap gap-4"> 948 - <div class="grow"></div> 949 - 950 - <button 951 - type="button" 952 - onClick={() => setStep(4)} 953 - class="flex h-9 select-none items-center rounded bg-gray-200 px-4 text-sm font-semibold text-black hover:bg-gray-300 active:bg-gray-300" 954 - > 955 - Previous 956 - </button> 957 - 958 - <button 959 - type="submit" 960 - class="flex h-9 select-none items-center rounded bg-purple-800 px-4 text-sm font-semibold text-white hover:bg-purple-700 active:bg-purple-700" 961 - > 962 - Submit 963 - </button> 964 - </div> 965 - </StepPage> 966 - </Match> 967 - </Switch> 968 - 969 - <StepPage step={6} title="All done!" current={step()} onSubmit={() => {}}> 970 - <p>Your did:plc identity has been updated.</p> 971 - 972 - <p class="mt-3"> 973 - You can close this page, or reload the page if you intend on doing another submission. 974 - </p> 975 - </StepPage> 976 - 977 - <div class="pb-24"></div> 978 - </fieldset> 979 - ); 980 - }; 981 - 982 - export default PlcUpdatePage; 983 - 984 - const pushPlcOperation = async (did: string, operation: PlcUpdateOp) => { 985 - const origin = import.meta.env.VITE_PLC_DIRECTORY_URL; 986 - const response = await fetch(`${origin}/${did}`, { 987 - method: 'post', 988 - headers: { 989 - 'content-type': 'application/json', 990 - }, 991 - body: JSON.stringify(operation), 992 - }); 993 - 994 - const headers = response.headers; 995 - if (!response.ok) { 996 - const type = headers.get('content-type'); 997 - 998 - if (type?.includes('application/json')) { 999 - const json = await response.json(); 1000 - if (typeof json === 'object' && json !== null && typeof json.message === 'string') { 1001 - throw new Error(json.message); 1002 - } 1003 - } 1004 - 1005 - throw new Error(`got http ${response.status} from plc`); 1006 - } 1007 - }; 1008 - 1009 - const formatEmailOtpCode = (code: string) => { 1010 - code = code.toUpperCase(); 1011 - 1012 - const match = EMAIL_OTP_RE.exec(code); 1013 - if (match !== null) { 1014 - return `${match[1]}-${match[2]}`; 1015 - } 1016 - 1017 - return ''; 1018 - }; 1019 - 1020 - const getPlcPayload = (entry: PlcLogEntry): PlcUpdatePayload => { 1021 - const op = entry.operation; 1022 - assert(op.type === 'plc_operation' || op.type === 'create'); 1023 - 1024 - if (op.type === 'create') { 1025 - return { 1026 - alsoKnownAs: [`at://${op.handle}`], 1027 - rotationKeys: [op.recoveryKey, op.signingKey], 1028 - verificationMethods: { 1029 - atproto: op.signingKey, 1030 - }, 1031 - services: { 1032 - atproto_pds: { 1033 - type: 'AtprotoPersonalDataServer', 1034 - endpoint: op.service, 1035 - }, 1036 - }, 1037 - }; 1038 - } else if (op.type === 'plc_operation') { 1039 - return { 1040 - alsoKnownAs: op.alsoKnownAs, 1041 - rotationKeys: op.rotationKeys, 1042 - services: op.services, 1043 - verificationMethods: op.verificationMethods, 1044 - }; 1045 - } 1046 - 1047 - assert(false); 1048 - }; 1049 - 1050 - const getPlcKeying = async (logs: PlcLogEntry[]) => { 1051 - logs = logs.filter((entry) => !entry.nullified); 1052 - 1053 - const length = logs.length; 1054 - const promises = logs.map(async (entry, idx) => { 1055 - const operation = entry.operation; 1056 - if (operation.type === 'plc_tombstone') { 1057 - return; 1058 - } 1059 - 1060 - // If it's not the last entry, check if the next entry ahead of this one 1061 - // was made within the last 72 hours. 1062 - if (idx !== length - 1) { 1063 - const next = logs[idx + 1]!; 1064 - const date = new Date(next.createdAt); 1065 - const diff = Date.now() - date.getTime(); 1066 - 1067 - if (diff / (1_000 * 60 * 60) > 72) { 1068 - return; 1069 - } 1070 - } 1071 - 1072 - /** keys that potentially signed this operation */ 1073 - let signers: string[] | undefined; 1074 - if (operation.prev === null) { 1075 - if (operation.type === 'create') { 1076 - signers = [operation.recoveryKey, operation.signingKey]; 1077 - } else if (operation.type === 'plc_operation') { 1078 - signers = operation.rotationKeys; 1079 - } 1080 - } else { 1081 - const prev = logs[idx - 1]; 1082 - assert(prev !== undefined, `missing previous entry from ${entry.createdAt}`); 1083 - assert(prev.cid === operation.prev, `prev cid mismatch on ${entry.createdAt}`); 1084 - 1085 - const prevOp = prev.operation; 1086 - 1087 - if (prevOp.type === 'create') { 1088 - signers = [prevOp.recoveryKey, prevOp.signingKey]; 1089 - } else if (prevOp.type === 'plc_operation') { 1090 - signers = prevOp.rotationKeys; 1091 - } 1092 - } 1093 - 1094 - assert(signers !== undefined, `no signers found for ${entry.createdAt}`); 1095 - 1096 - const opBytes = CBOR.encode({ ...operation, sig: undefined }); 1097 - const sigBytes = uint8arrays.fromString(operation.sig, 'base64url'); 1098 - 1099 - /** key that signed this operation */ 1100 - let signedBy: string | undefined; 1101 - for (const key of signers) { 1102 - const valid = await verifySignature(key, opBytes, sigBytes); 1103 - if (valid) { 1104 - signedBy = key; 1105 - break; 1106 - } 1107 - } 1108 - 1109 - assert(signedBy !== undefined, `no valid signer for ${entry.createdAt}`); 1110 - 1111 - return { 1112 - ...entry, 1113 - signers, 1114 - signedBy, 1115 - }; 1116 - }); 1117 - 1118 - const fulfilled = await Promise.all(promises); 1119 - return fulfilled.filter((entry) => entry !== undefined); 1120 - }; 1121 - 1122 - const getCurrentSignersFromEntry = (entry: PlcLogEntry): string[] => { 1123 - const operation = entry.operation; 1124 - 1125 - /** keys that can sign the next operation */ 1126 - let nextSigners: string[] | undefined; 1127 - if (operation.type === 'create') { 1128 - nextSigners = [operation.recoveryKey, operation.signingKey]; 1129 - } else if (operation.type === 'plc_operation') { 1130 - nextSigners = operation.rotationKeys; 1131 - } 1132 - 1133 - assert(nextSigners !== undefined, `no signers found for ${entry.createdAt}`); 1134 - return nextSigners; 1135 - }; 1136 - 1137 - const ErrorMessageView = (props: { step: number; error: { step: number; message: string } | undefined }) => { 1138 - return ( 1139 - <Show 1140 - when={(() => { 1141 - const error = props.error; 1142 - if (error && props.step === error.step) { 1143 - return error; 1144 - } 1145 - })()} 1146 - > 1147 - {(error) => ( 1148 - <p class="mt-4 whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800"> 1149 - {error().message} 1150 - </p> 1151 - )} 1152 - </Show> 1153 - ); 1154 - }; 1155 - 1156 - const StepPage = (props: { 1157 - step: number; 1158 - title: string; 1159 - current: number; 1160 - onSubmit: (formData: FormData) => void; 1161 - children: JSX.Element; 1162 - }) => { 1163 - const onSubmit = props.onSubmit; 1164 - 1165 - const handleSubmit: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (ev) => { 1166 - ev.preventDefault(); 1167 - 1168 - const formData = new FormData(ev.currentTarget); 1169 - onSubmit(formData); 1170 - }; 1171 - 1172 - return ( 1173 - <Show when={props.step <= props.current}> 1174 - <form onSubmit={handleSubmit} class="contents"> 1175 - <fieldset disabled={props.step !== props.current} class="flex min-w-0 gap-4 px-4 disabled:opacity-50"> 1176 - <div class="flex flex-col items-center gap-1 pt-4"> 1177 - <div class="grid h-6 w-6 place-items-center rounded-full bg-gray-200 py-1 text-center text-sm font-medium leading-none text-black"> 1178 - {'' + props.step} 1179 - </div> 1180 - 1181 - <div hidden={!(props.current > props.step)} class="-mb-3 grow border-l border-gray-400"></div> 1182 - </div> 1183 - 1184 - <div class="min-w-0 grow py-4"> 1185 - <h3 class="mb-[1.125rem] mt-0.5 text-sm font-semibold">{props.title}</h3> 1186 - {props.children} 1187 - </div> 1188 - </fieldset> 1189 - </form> 1190 - </Show> 1191 - ); 1192 - };
+123
src/views/identity/plc-applicator/page.tsx
··· 1 + import { createEffect, createSignal, onCleanup } from 'solid-js'; 2 + 3 + import type { CredentialManager } from '@atcute/client'; 4 + import type { ComAtprotoIdentityGetRecommendedDidCredentials } from '@atcute/client/lexicons'; 5 + import type { P256Keypair, Secp256k1Keypair } from '@atproto/crypto'; 6 + 7 + import type { DidDocument } from '~/api/types/did-doc'; 8 + import type { PlcUpdatePayload } from '~/api/types/plc'; 9 + 10 + import { history } from '~/globals/navigation'; 11 + 12 + import { Wizard } from '~/components/wizard'; 13 + 14 + import type { DetailedPlcEntry } from './plc-utils'; 15 + 16 + import Step1_HandleInput from './steps/step1_handle-input'; 17 + import Step2_PdsAuthentication from './steps/step2_pds-authentication'; 18 + import Step2_PrivateKeyInput from './steps/step2_private-key-input'; 19 + import Step3_OperationSelect from './steps/step3_operation-select'; 20 + import Step4_PayloadInput from './steps/step4_payload-input'; 21 + import Step5_PdsConfirmation from './steps/step5_pds-confirmation'; 22 + import Step5_PrivateKeyConfirmation from './steps/step5_private-key-confirmation'; 23 + import Step6_Finished from './steps/step6_finished'; 24 + 25 + export interface PlcInformation { 26 + didDoc: DidDocument; 27 + logs: DetailedPlcEntry[]; 28 + } 29 + 30 + export interface PdsSigningMethod { 31 + type: 'pds'; 32 + manager: CredentialManager; 33 + recommendedDidDoc: ComAtprotoIdentityGetRecommendedDidCredentials.Output; 34 + } 35 + 36 + export interface PrivateKeySigningMethod { 37 + type: 'private_key'; 38 + keypair: P256Keypair | Secp256k1Keypair; 39 + didPublicKey: string; 40 + } 41 + 42 + export type SigningMethod = PdsSigningMethod | PrivateKeySigningMethod; 43 + 44 + export type PlcApplicatorConstraints = { 45 + Step1_HandleInput: {}; 46 + 47 + Step2_PdsAuthentication: { 48 + info: PlcInformation; 49 + }; 50 + Step2_PrivateKeyInput: { 51 + info: PlcInformation; 52 + }; 53 + 54 + Step3_OperationSelect: { 55 + info: PlcInformation; 56 + method: SigningMethod; 57 + }; 58 + 59 + Step4_PayloadInput: { 60 + info: PlcInformation; 61 + method: SigningMethod; 62 + base: DetailedPlcEntry; 63 + }; 64 + 65 + Step5_PdsConfirmation: { 66 + info: PlcInformation; 67 + method: PdsSigningMethod; 68 + base: DetailedPlcEntry; 69 + payload: PlcUpdatePayload; 70 + }; 71 + Step5_PrivateKeyConfirmation: { 72 + info: PlcInformation; 73 + method: PrivateKeySigningMethod; 74 + base: DetailedPlcEntry; 75 + payload: PlcUpdatePayload; 76 + }; 77 + 78 + Step6_Finished: {}; 79 + }; 80 + 81 + const PlcApplicatorPage = () => { 82 + const [isActive, setIsActive] = createSignal(false); 83 + 84 + createEffect(() => { 85 + if (isActive()) { 86 + const cleanup = history.block((tx) => { 87 + if (window.confirm(`Abort this action?`)) { 88 + cleanup(); 89 + tx.retry(); 90 + } 91 + }); 92 + 93 + onCleanup(cleanup); 94 + } 95 + }); 96 + 97 + return ( 98 + <> 99 + <div class="p-4"> 100 + <h1 class="text-lg font-bold text-purple-800">Apply PLC operations</h1> 101 + <p class="text-gray-600">Submit operations to your did:plc identity</p> 102 + </div> 103 + <hr class="mx-4 border-gray-300" /> 104 + 105 + <Wizard<PlcApplicatorConstraints> 106 + initialStep="Step1_HandleInput" 107 + components={{ 108 + Step1_HandleInput, 109 + Step2_PdsAuthentication, 110 + Step2_PrivateKeyInput, 111 + Step3_OperationSelect, 112 + Step4_PayloadInput, 113 + Step5_PdsConfirmation, 114 + Step5_PrivateKeyConfirmation, 115 + Step6_Finished, 116 + }} 117 + onStepChange={(step) => setIsActive(step > 1 && step < 6)} 118 + /> 119 + </> 120 + ); 121 + }; 122 + 123 + export default PlcApplicatorPage;
+128
src/views/identity/plc-applicator/plc-utils.ts
··· 1 + import * as CBOR from '@atcute/cbor'; 2 + import { verifySignature } from '@atproto/crypto'; 3 + import * as uint8arrays from 'uint8arrays'; 4 + 5 + import { PlcLogEntry, PlcUpdatePayload } from '~/api/types/plc'; 6 + import { UnwrapArray } from '~/api/utils/types'; 7 + 8 + import { assert } from '~/lib/utils/invariant'; 9 + 10 + export const getPlcPayload = (entry: PlcLogEntry): PlcUpdatePayload => { 11 + const op = entry.operation; 12 + assert(op.type === 'plc_operation' || op.type === 'create'); 13 + 14 + if (op.type === 'create') { 15 + return { 16 + alsoKnownAs: [`at://${op.handle}`], 17 + rotationKeys: [op.recoveryKey, op.signingKey], 18 + verificationMethods: { 19 + atproto: op.signingKey, 20 + }, 21 + services: { 22 + atproto_pds: { 23 + type: 'AtprotoPersonalDataServer', 24 + endpoint: op.service, 25 + }, 26 + }, 27 + }; 28 + } else if (op.type === 'plc_operation') { 29 + return { 30 + alsoKnownAs: op.alsoKnownAs, 31 + rotationKeys: op.rotationKeys, 32 + services: op.services, 33 + verificationMethods: op.verificationMethods, 34 + }; 35 + } 36 + 37 + assert(false); 38 + }; 39 + 40 + export const getPlcKeying = async (logs: PlcLogEntry[]) => { 41 + logs = logs.filter((entry) => !entry.nullified); 42 + 43 + const length = logs.length; 44 + const promises = logs.map(async (entry, idx) => { 45 + const operation = entry.operation; 46 + if (operation.type === 'plc_tombstone') { 47 + return; 48 + } 49 + 50 + // If it's not the last entry, check if the next entry ahead of this one 51 + // was made within the last 72 hours. 52 + if (idx !== length - 1) { 53 + const next = logs[idx + 1]!; 54 + const date = new Date(next.createdAt); 55 + const diff = Date.now() - date.getTime(); 56 + 57 + if (diff / (1_000 * 60 * 60) > 72) { 58 + return; 59 + } 60 + } 61 + 62 + /** keys that potentially signed this operation */ 63 + let signers: string[] | undefined; 64 + if (operation.prev === null) { 65 + if (operation.type === 'create') { 66 + signers = [operation.recoveryKey, operation.signingKey]; 67 + } else if (operation.type === 'plc_operation') { 68 + signers = operation.rotationKeys; 69 + } 70 + } else { 71 + const prev = logs[idx - 1]; 72 + assert(prev !== undefined, `missing previous entry from ${entry.createdAt}`); 73 + assert(prev.cid === operation.prev, `prev cid mismatch on ${entry.createdAt}`); 74 + 75 + const prevOp = prev.operation; 76 + 77 + if (prevOp.type === 'create') { 78 + signers = [prevOp.recoveryKey, prevOp.signingKey]; 79 + } else if (prevOp.type === 'plc_operation') { 80 + signers = prevOp.rotationKeys; 81 + } 82 + } 83 + 84 + assert(signers !== undefined, `no signers found for ${entry.createdAt}`); 85 + 86 + const opBytes = CBOR.encode({ ...operation, sig: undefined }); 87 + const sigBytes = uint8arrays.fromString(operation.sig, 'base64url'); 88 + 89 + /** key that signed this operation */ 90 + let signedBy: string | undefined; 91 + for (const key of signers) { 92 + const valid = await verifySignature(key, opBytes, sigBytes); 93 + if (valid) { 94 + signedBy = key; 95 + break; 96 + } 97 + } 98 + 99 + assert(signedBy !== undefined, `no valid signer for ${entry.createdAt}`); 100 + 101 + return { 102 + ...entry, 103 + signers, 104 + signedBy, 105 + }; 106 + }); 107 + 108 + const fulfilled = await Promise.all(promises); 109 + return fulfilled.filter((entry) => entry !== undefined); 110 + }; 111 + 112 + type DetailedEntries = Awaited<ReturnType<typeof getPlcKeying>>; 113 + export type DetailedPlcEntry = UnwrapArray<DetailedEntries>; 114 + 115 + export const getCurrentSignersFromEntry = (entry: PlcLogEntry): string[] => { 116 + const operation = entry.operation; 117 + 118 + /** keys that can sign the next operation */ 119 + let nextSigners: string[] | undefined; 120 + if (operation.type === 'create') { 121 + nextSigners = [operation.recoveryKey, operation.signingKey]; 122 + } else if (operation.type === 'plc_operation') { 123 + nextSigners = operation.rotationKeys; 124 + } 125 + 126 + assert(nextSigners !== undefined, `no signers found for ${entry.createdAt}`); 127 + return nextSigners; 128 + };
+125
src/views/identity/plc-applicator/steps/step1_handle-input.tsx
··· 1 + import { createSignal } from 'solid-js'; 2 + 3 + import { At } from '@atcute/client/lexicons'; 4 + 5 + import { getDidDocument } from '~/api/queries/did-doc'; 6 + import { resolveHandleViaAppView } from '~/api/queries/handle'; 7 + import { getPlcAuditLogs } from '~/api/queries/plc'; 8 + import { DID_OR_HANDLE_RE, DID_PLC_RE, isDid } from '~/api/utils/strings'; 9 + 10 + import { createMutation } from '~/lib/utils/mutation'; 11 + 12 + import Button from '~/components/inputs/button'; 13 + import RadioInput from '~/components/inputs/radio-input'; 14 + import TextInput from '~/components/inputs/text-input'; 15 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 16 + 17 + import { type PlcInformation, PlcApplicatorConstraints } from '../page'; 18 + import { getPlcKeying } from '../plc-utils'; 19 + 20 + type Method = 'pds' | 'key'; 21 + 22 + interface MutationVariables { 23 + identifier: string; 24 + method: Method; 25 + } 26 + 27 + class DidIsNotPlcError extends Error {} 28 + 29 + const Step1_HandleInput = ({ 30 + isActive, 31 + onNext, 32 + }: WizardStepProps<PlcApplicatorConstraints, 'Step1_HandleInput'>) => { 33 + const [error, setError] = createSignal<string>(); 34 + 35 + const [identifier, setIdentifier] = createSignal(''); 36 + const [method, setMethod] = createSignal<Method>('pds'); 37 + 38 + const mutation = createMutation({ 39 + async mutationFn({ identifier }: MutationVariables): Promise<PlcInformation> { 40 + let did: At.DID; 41 + if (isDid(identifier)) { 42 + did = identifier; 43 + } else { 44 + did = await resolveHandleViaAppView({ handle: identifier }); 45 + } 46 + 47 + if (!DID_PLC_RE.test(did)) { 48 + throw new DidIsNotPlcError(`"${did}" is not did:plc`); 49 + } 50 + 51 + const [didDoc, logs] = await Promise.all([getDidDocument({ did }), getPlcAuditLogs({ did })]); 52 + 53 + return { 54 + didDoc, 55 + logs: await getPlcKeying(logs), 56 + }; 57 + }, 58 + onMutate() { 59 + setError(); 60 + }, 61 + onSuccess(info, { method }) { 62 + if (method === 'pds') { 63 + onNext('Step2_PdsAuthentication', { info }); 64 + } else { 65 + onNext('Step2_PrivateKeyInput', { info }); 66 + } 67 + }, 68 + onError(error) { 69 + let message: string | undefined; 70 + 71 + if (error instanceof DidIsNotPlcError) { 72 + message = error.message; 73 + } 74 + 75 + if (message !== undefined) { 76 + setError(message); 77 + } else { 78 + setError(`Something went wrong: ${error}`); 79 + } 80 + }, 81 + }); 82 + 83 + return ( 84 + <Stage 85 + title="Enter the did:plc you want to edit" 86 + disabled={mutation.isPending} 87 + onSubmit={() => { 88 + mutation.mutate({ 89 + identifier: identifier(), 90 + method: method(), 91 + }); 92 + }} 93 + > 94 + <TextInput 95 + label="Handle or DID identifier" 96 + placeholder="paul.bsky.social" 97 + value={identifier()} 98 + required 99 + pattern={/* @once */ DID_OR_HANDLE_RE.source} 100 + autofocus={isActive()} 101 + onChange={setIdentifier} 102 + /> 103 + 104 + <RadioInput 105 + label="I will be using..." 106 + value={method()} 107 + required 108 + options={[ 109 + { value: 'pds', label: `my PDS' rotation keys (requires sign in)` }, 110 + { value: 'key', label: `my own rotation keys` }, 111 + ]} 112 + onChange={setMethod} 113 + /> 114 + 115 + <StageErrorView error={error()} /> 116 + 117 + <StageActions hidden={!isActive()}> 118 + <StageActions.Divider /> 119 + <Button type="submit">Next</Button> 120 + </StageActions> 121 + </Stage> 122 + ); 123 + }; 124 + 125 + export default Step1_HandleInput;
+217
src/views/identity/plc-applicator/steps/step2_pds-authentication.tsx
··· 1 + import { createSignal, Match, Show, Switch } from 'solid-js'; 2 + 3 + import { AtpAccessJwt, CredentialManager, XRPC, XRPCError } from '@atcute/client'; 4 + import { decodeJwt } from '@atcute/client/utils/jwt'; 5 + 6 + import { getPdsEndpoint } from '~/api/types/did-doc'; 7 + import { TOTP_RE, formatTotpCode } from '~/api/utils/auth'; 8 + 9 + import { createMutation } from '~/lib/utils/mutation'; 10 + 11 + import Button from '~/components/inputs/button'; 12 + import TextInput from '~/components/inputs/text-input'; 13 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 14 + 15 + import { PlcApplicatorConstraints } from '../page'; 16 + 17 + class InsufficientLoginError extends Error {} 18 + 19 + const Step2_PdsAuthentication = ({ 20 + data, 21 + isActive, 22 + onPrevious, 23 + onNext, 24 + }: WizardStepProps<PlcApplicatorConstraints, 'Step2_PdsAuthentication'>) => { 25 + const [error, setError] = createSignal<string>(); 26 + const [isTotpRequired, setIsTotpRequired] = createSignal(false); 27 + 28 + const [manager, setManager] = createSignal<CredentialManager>(); 29 + 30 + const [serviceUrl, setServiceUrl] = createSignal(getPdsEndpoint(data.info.didDoc) ?? ''); 31 + const [password, setPassword] = createSignal(''); 32 + const [otp, setOtp] = createSignal(''); 33 + 34 + const loginMutation = createMutation({ 35 + async mutationFn({ service, password, otp }: { service: string; password: string; otp: string }) { 36 + const manager = new CredentialManager({ service }); 37 + const _session = await manager.login({ 38 + identifier: data.info.didDoc.id, 39 + password: password, 40 + code: formatTotpCode(otp), 41 + }); 42 + 43 + const decoded = decodeJwt(_session.accessJwt) as AtpAccessJwt; 44 + if (decoded.scope !== 'com.atproto.access') { 45 + throw new InsufficientLoginError(`You need to be signed in with a main password`); 46 + } 47 + 48 + return manager; 49 + }, 50 + onMutate() { 51 + setError(); 52 + }, 53 + onSuccess(manager) { 54 + dispatchMutation.mutate({ manager }); 55 + 56 + setManager(manager); 57 + 58 + setOtp(''); 59 + setPassword(''); 60 + setIsTotpRequired(false); 61 + }, 62 + onError(error) { 63 + let message: string | undefined; 64 + 65 + if (error instanceof XRPCError) { 66 + if (error.kind === 'AuthFactorTokenRequired') { 67 + setOtp(''); 68 + setIsTotpRequired(true); 69 + return; 70 + } 71 + 72 + if (error.kind === 'AuthenticationRequired') { 73 + message = `Invalid identifier or password`; 74 + } else if (error.kind === 'AccountTakedown') { 75 + message = `Account has been taken down`; 76 + } else if (error.message.includes('Token is invalid')) { 77 + message = `Invalid one-time confirmation code`; 78 + setIsTotpRequired(true); 79 + } 80 + } else if (error instanceof InsufficientLoginError) { 81 + message = error.message; 82 + } 83 + 84 + if (message !== undefined) { 85 + setError(message); 86 + } else { 87 + console.error(error); 88 + setError(`Something went wrong: ${error}`); 89 + } 90 + }, 91 + }); 92 + 93 + const dispatchMutation = createMutation({ 94 + async mutationFn({ manager }: { manager: CredentialManager }) { 95 + const rpc = new XRPC({ handler: manager }); 96 + const { data: recommendedDidDoc } = await rpc.get( 97 + 'com.atproto.identity.getRecommendedDidCredentials', 98 + {}, 99 + ); 100 + 101 + return { recommendedDidDoc }; 102 + }, 103 + onMutate() { 104 + setError(); 105 + }, 106 + onSuccess({ recommendedDidDoc }, { manager }) { 107 + onNext('Step3_OperationSelect', { 108 + info: data.info, 109 + method: { 110 + type: 'pds', 111 + manager, 112 + recommendedDidDoc, 113 + }, 114 + }); 115 + }, 116 + onError(error) { 117 + let message: string | undefined; 118 + 119 + if (message !== undefined) { 120 + setError(message); 121 + } else { 122 + console.error(error); 123 + setError(`Something went wrong: ${error}`); 124 + } 125 + }, 126 + }); 127 + 128 + return ( 129 + <Stage 130 + title="Sign in to your PDS" 131 + disabled={loginMutation.isPending || dispatchMutation.isPending} 132 + onSubmit={() => { 133 + const $manager = manager(); 134 + 135 + if ($manager) { 136 + dispatchMutation.mutate({ 137 + manager: $manager, 138 + }); 139 + } else { 140 + loginMutation.mutate({ 141 + service: serviceUrl(), 142 + password: password(), 143 + otp: otp(), 144 + }); 145 + } 146 + }} 147 + > 148 + <Switch> 149 + <Match when={manager()} keyed> 150 + {(manager) => ( 151 + <p class="break-words"> 152 + Signed in via <b>{/* @once */ manager.dispatchUrl}</b>.{' '} 153 + <button 154 + type="button" 155 + onClick={() => setManager(undefined)} 156 + hidden={!isActive()} 157 + class="text-purple-800 hover:underline disabled:pointer-events-none" 158 + > 159 + Sign out? 160 + </button> 161 + </p> 162 + )} 163 + </Match> 164 + 165 + <Match when> 166 + <TextInput 167 + label="PDS service URL" 168 + type="url" 169 + placeholder="https://pds.example.com" 170 + value={serviceUrl()} 171 + required 172 + onChange={setServiceUrl} 173 + /> 174 + 175 + <TextInput 176 + label="Main password" 177 + blurb="This app runs locally on your browser, your credentials stays entirely within your device." 178 + type="password" 179 + value={password()} 180 + required 181 + autofocus={isActive()} 182 + onChange={setPassword} 183 + /> 184 + 185 + <Show when={isTotpRequired()}> 186 + <TextInput 187 + label="One-time confirmation code" 188 + blurb="A code has been sent to your email address, check your inbox." 189 + type="text" 190 + autocomplete="one-time-code" 191 + autocorrect="off" 192 + pattern={/* @once */ TOTP_RE.source} 193 + placeholder="AAAAA-BBBBB" 194 + value={otp()} 195 + required 196 + onChange={setOtp} 197 + monospace 198 + /> 199 + </Show> 200 + </Match> 201 + </Switch> 202 + 203 + <StageErrorView error={error()} /> 204 + 205 + <StageActions hidden={!isActive()}> 206 + <StageActions.Divider /> 207 + 208 + <Button variant="secondary" onClick={onPrevious}> 209 + Previous 210 + </Button> 211 + <Button type="submit">Next</Button> 212 + </StageActions> 213 + </Stage> 214 + ); 215 + }; 216 + 217 + export default Step2_PdsAuthentication;
+112
src/views/identity/plc-applicator/steps/step2_private-key-input.tsx
··· 1 + import { createSignal } from 'solid-js'; 2 + 3 + import { createMutation } from '~/lib/utils/mutation'; 4 + 5 + import Button from '~/components/inputs/button'; 6 + import RadioInput from '~/components/inputs/radio-input'; 7 + import TextInput from '~/components/inputs/text-input'; 8 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 9 + 10 + import { PlcApplicatorConstraints } from '../page'; 11 + import { P256Keypair, Secp256k1Keypair } from '@atproto/crypto'; 12 + 13 + type KeyType = 'nistp256' | 'secp256k1'; 14 + 15 + const Step2_PrivateKeyInput = ({ 16 + data, 17 + isActive, 18 + onPrevious, 19 + onNext, 20 + }: WizardStepProps<PlcApplicatorConstraints, 'Step2_PrivateKeyInput'>) => { 21 + const [error, setError] = createSignal<string>(); 22 + 23 + const [type, setType] = createSignal<KeyType>(); 24 + const [hex, setHex] = createSignal(''); 25 + 26 + const hexMutation = createMutation({ 27 + async mutationFn({ type, hex }: { type: KeyType; hex: string }) { 28 + let keypair: P256Keypair | Secp256k1Keypair; 29 + 30 + if (type === 'nistp256') { 31 + keypair = await P256Keypair.import(hex); 32 + } else if (type === 'secp256k1') { 33 + keypair = await Secp256k1Keypair.import(hex); 34 + } else { 35 + throw new Error(`unsupported "${type}" type`); 36 + } 37 + 38 + return keypair; 39 + }, 40 + onMutate() { 41 + setError(); 42 + }, 43 + onSuccess(keypair) { 44 + onNext('Step3_OperationSelect', { 45 + info: data.info, 46 + method: { 47 + type: 'private_key', 48 + didPublicKey: keypair.did(), 49 + keypair: keypair, 50 + }, 51 + }); 52 + }, 53 + onError(error) { 54 + let message: string | undefined; 55 + 56 + if (message !== undefined) { 57 + setError(message); 58 + } else { 59 + console.error(error); 60 + setError(`Something went wrong: ${error}`); 61 + } 62 + }, 63 + }); 64 + 65 + return ( 66 + <Stage 67 + title="Enter your private key" 68 + disabled={hexMutation.isPending} 69 + onSubmit={() => { 70 + hexMutation.mutate({ 71 + type: type()!, 72 + hex: hex(), 73 + }); 74 + }} 75 + > 76 + <TextInput 77 + label="Hex-encoded private key" 78 + blurb="This app runs locally on your browser, your private key stays entirely within your device." 79 + placeholder="a5973930f9d348..." 80 + value={hex()} 81 + required 82 + pattern="[0-9a-f]+" 83 + autofocus={isActive()} 84 + onChange={setHex} 85 + /> 86 + 87 + <RadioInput 88 + label="This is a..." 89 + value={type()} 90 + required 91 + options={[ 92 + { value: 'secp256k1', label: `ES256K (secp256k1) private key` }, 93 + { value: 'nistp256', label: `ES256 (nistp256) private key` }, 94 + ]} 95 + onChange={setType} 96 + /> 97 + 98 + <StageErrorView error={error()} /> 99 + 100 + <StageActions hidden={!isActive()}> 101 + <StageActions.Divider /> 102 + 103 + <Button variant="secondary" onClick={onPrevious}> 104 + Previous 105 + </Button> 106 + <Button type="submit">Next</Button> 107 + </StageActions> 108 + </Stage> 109 + ); 110 + }; 111 + 112 + export default Step2_PrivateKeyInput;
+117
src/views/identity/plc-applicator/steps/step3_operation-select.tsx
··· 1 + import { createMemo, createSignal } from 'solid-js'; 2 + 3 + import Button from '~/components/inputs/button'; 4 + import SelectInput from '~/components/inputs/select-input'; 5 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 6 + 7 + import { PlcApplicatorConstraints } from '../page'; 8 + import { getCurrentSignersFromEntry } from '../plc-utils'; 9 + 10 + const Step3_OperationSelect = ({ 11 + data, 12 + isActive, 13 + onPrevious, 14 + onNext, 15 + }: WizardStepProps<PlcApplicatorConstraints, 'Step3_OperationSelect'>) => { 16 + const [error, setError] = createSignal<string>(); 17 + const [selectedCid, setSelectedCid] = createSignal<string>(); 18 + 19 + const options = createMemo(() => { 20 + const signingMethod = data.method; 21 + const logs = data.info.logs; 22 + 23 + let ownKey: string | undefined; 24 + if (signingMethod.type === 'pds') { 25 + ownKey = signingMethod.recommendedDidDoc.rotationKeys?.at(-1); 26 + } else if (signingMethod.type === 'private_key') { 27 + ownKey = signingMethod.didPublicKey; 28 + } 29 + 30 + if (ownKey === undefined) { 31 + return []; 32 + } 33 + 34 + const length = logs.length; 35 + const items = logs.map((entry, idx) => { 36 + const signers = getCurrentSignersFromEntry(entry); 37 + const last = idx === length - 1; 38 + 39 + let enabled = signers.includes(ownKey!); 40 + 41 + // If we're showing older operations for forking/nullification, 42 + // check to see that our key has priority over the signer. 43 + if (enabled && !last) { 44 + if (signingMethod.type === 'pds') { 45 + // `signPlcOperation` will always grab the last op 46 + enabled = false; 47 + } else { 48 + const holderKey = logs[idx + 1].signedBy; 49 + 50 + const holderPriority = signers.indexOf(holderKey); 51 + const ownPriority = signers.indexOf(ownKey); 52 + 53 + enabled = ownPriority < holderPriority; 54 + } 55 + } 56 + 57 + return { 58 + value: entry.cid, 59 + label: `${entry.createdAt} (by ${entry.signedBy})`, 60 + disabled: !enabled, 61 + }; 62 + }); 63 + 64 + return items.reverse(); 65 + }); 66 + 67 + return ( 68 + <Stage 69 + title="Select which operation to use as foundation" 70 + onSubmit={() => { 71 + setError(); 72 + 73 + const cid = selectedCid(); 74 + const entry = data.info.logs.find((entry) => entry.cid === cid); 75 + 76 + if (!entry) { 77 + setError(`Can't find CID ${cid}`); 78 + return; 79 + } 80 + 81 + const operation = entry.operation; 82 + if (operation.type !== 'plc_operation' && operation.type === 'create') { 83 + setError(`Expected operation to be of type "plc_operation" or "create"`); 84 + return; 85 + } 86 + 87 + onNext('Step4_PayloadInput', { 88 + info: data.info, 89 + method: data.method, 90 + base: entry, 91 + }); 92 + }} 93 + > 94 + <SelectInput 95 + label="Base operation" 96 + blurb="Some operations can't be used as a base if the rotation key does not have the privilege for nullification, or if it is not listed." 97 + required 98 + value={selectedCid()} 99 + autofocus={isActive()} 100 + options={[{ value: '', label: `Select an operation...` }, ...options()]} 101 + onChange={setSelectedCid} 102 + /> 103 + 104 + <StageErrorView error={error()} /> 105 + 106 + <StageActions hidden={!isActive()}> 107 + <StageActions.Divider /> 108 + <Button variant="secondary" onClick={onPrevious}> 109 + Previous 110 + </Button> 111 + <Button type="submit">Next</Button> 112 + </StageActions> 113 + </Stage> 114 + ); 115 + }; 116 + 117 + export default Step3_OperationSelect;
+130
src/views/identity/plc-applicator/steps/step4_payload-input.tsx
··· 1 + import { createSignal } from 'solid-js'; 2 + 3 + import { updatePayload } from '~/api/types/plc'; 4 + 5 + import Button from '~/components/inputs/button'; 6 + import MultilineInput from '~/components/inputs/multiline-input'; 7 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 8 + 9 + import { PlcApplicatorConstraints } from '../page'; 10 + import { getPlcPayload } from '../plc-utils'; 11 + 12 + export const Step4_PayloadInput = ({ 13 + data, 14 + isActive, 15 + onPrevious, 16 + onNext, 17 + }: WizardStepProps<PlcApplicatorConstraints, 'Step4_PayloadInput'>) => { 18 + const [error, setError] = createSignal<string>(); 19 + 20 + const originalSource = JSON.stringify(getPlcPayload(data.base), null, 2); 21 + const [source, setSource] = createSignal(originalSource); 22 + 23 + const method = data.method; 24 + const isPdsMethod = method.type === 'pds'; 25 + 26 + return ( 27 + <Stage 28 + title="Enter your payload" 29 + onSubmit={() => { 30 + setError(); 31 + 32 + const $source = source(); 33 + 34 + let json: unknown; 35 + try { 36 + json = JSON.parse($source); 37 + } catch { 38 + setError(`Unable to parse JSON`); 39 + return; 40 + } 41 + 42 + const result = updatePayload.try(json); 43 + if (!result.ok) { 44 + setError(result.message); 45 + return; 46 + } 47 + 48 + if (method.type === 'pds') { 49 + onNext('Step5_PdsConfirmation', { 50 + info: data.info, 51 + method: method, 52 + base: data.base, 53 + payload: result.value, 54 + }); 55 + } else if (method.type === 'private_key') { 56 + onNext('Step5_PrivateKeyConfirmation', { 57 + info: data.info, 58 + method: method, 59 + base: data.base, 60 + payload: result.value, 61 + }); 62 + } 63 + }} 64 + > 65 + <MultilineInput 66 + label="Payload input" 67 + required 68 + autocomplete="off" 69 + autocorrect="off" 70 + value={source()} 71 + autofocus={isActive()} 72 + onChange={setSource} 73 + /> 74 + 75 + <div class="-mt-4 flex flex-wrap gap-4"> 76 + {isPdsMethod && ( 77 + <button 78 + type="button" 79 + class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 80 + onClick={() => { 81 + const recommended = method.recommendedDidDoc; 82 + const payload = getPlcPayload(data.base); 83 + 84 + if (recommended.alsoKnownAs) { 85 + payload.alsoKnownAs = recommended.alsoKnownAs; 86 + } 87 + if (recommended.rotationKeys) { 88 + payload.rotationKeys = recommended.rotationKeys; 89 + } 90 + if (recommended.services) { 91 + // @ts-expect-error 92 + payload.services = recommended.services; 93 + } 94 + if (recommended.verificationMethods) { 95 + // @ts-expect-error 96 + payload.verificationMethods = recommended.verificationMethods; 97 + } 98 + 99 + setSource(JSON.stringify(payload, null, 2)); 100 + }} 101 + > 102 + Use PDS recommendation 103 + </button> 104 + )} 105 + 106 + <button 107 + type="button" 108 + class="text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none" 109 + onClick={() => { 110 + setSource(originalSource); 111 + }} 112 + > 113 + Reset to default 114 + </button> 115 + </div> 116 + 117 + <StageErrorView error={error()} /> 118 + 119 + <StageActions hidden={!isActive()}> 120 + <StageActions.Divider /> 121 + <Button variant="secondary" onClick={onPrevious}> 122 + Previous 123 + </Button> 124 + <Button type="submit">Next</Button> 125 + </StageActions> 126 + </Stage> 127 + ); 128 + }; 129 + 130 + export default Step4_PayloadInput;
+165
src/views/identity/plc-applicator/steps/step5_pds-confirmation.tsx
··· 1 + import { createSignal } from 'solid-js'; 2 + 3 + import { XRPC, XRPCError } from '@atcute/client'; 4 + 5 + import { formatTotpCode, TOTP_RE } from '~/api/utils/auth'; 6 + 7 + import { createMutation } from '~/lib/utils/mutation'; 8 + 9 + import CheckIcon from '~/components/ic-icons/baseline-check'; 10 + import Button from '~/components/inputs/button'; 11 + import TextInput from '~/components/inputs/text-input'; 12 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 13 + 14 + import { PlcApplicatorConstraints } from '../page'; 15 + 16 + export const Step5_PdsConfirmation = ({ 17 + data, 18 + isActive, 19 + onPrevious, 20 + onNext, 21 + }: WizardStepProps<PlcApplicatorConstraints, 'Step5_PdsConfirmation'>) => { 22 + const [requestError, setRequestError] = createSignal<string>(); 23 + const [applyError, setApplyError] = createSignal<string>(); 24 + 25 + const [otp, setOtp] = createSignal(''); 26 + 27 + const requestMutation = createMutation({ 28 + async mutationFn() { 29 + const manager = data.method.manager; 30 + const rpc = new XRPC({ handler: manager }); 31 + 32 + await rpc.call('com.atproto.identity.requestPlcOperationSignature', {}); 33 + }, 34 + onMutate() { 35 + setRequestError(); 36 + }, 37 + onError(error) { 38 + let message: string | undefined; 39 + 40 + if (message !== undefined) { 41 + setRequestError(message); 42 + } else { 43 + console.error(error); 44 + setRequestError(`Something went wrong: ${error}`); 45 + } 46 + }, 47 + }); 48 + 49 + const applyMutation = createMutation({ 50 + async mutationFn({ code }: { code: string }) { 51 + const manager = data.method.manager; 52 + const rpc = new XRPC({ handler: manager }); 53 + 54 + const payload = data.payload; 55 + 56 + const { data: signage } = await rpc.call('com.atproto.identity.signPlcOperation', { 57 + data: { 58 + token: formatTotpCode(code), 59 + alsoKnownAs: payload.alsoKnownAs, 60 + rotationKeys: payload.rotationKeys, 61 + services: payload.services, 62 + verificationMethods: payload.verificationMethods, 63 + }, 64 + }); 65 + 66 + await rpc.call('com.atproto.identity.submitPlcOperation', { 67 + data: { 68 + operation: signage.operation, 69 + }, 70 + }); 71 + }, 72 + onMutate() { 73 + setApplyError(); 74 + }, 75 + onSuccess() { 76 + onNext('Step6_Finished', {}); 77 + }, 78 + onError(error) { 79 + let message: string | undefined; 80 + 81 + if (error instanceof XRPCError) { 82 + if (error.kind === 'InvalidToken' || error.kind === 'ExpiredToken') { 83 + message = `Confirmation code has expired`; 84 + } 85 + } 86 + 87 + if (message !== undefined) { 88 + setApplyError(message); 89 + } else { 90 + console.error(error); 91 + setApplyError(`Something went wrong: ${error}`); 92 + } 93 + }, 94 + }); 95 + 96 + return ( 97 + <Stage 98 + title="One more step" 99 + onSubmit={() => { 100 + applyMutation.mutate({ 101 + code: otp(), 102 + }); 103 + }} 104 + > 105 + <p class="text-pretty"> 106 + To continue with this submission, you will need to request a confirmation code from your PDS. This 107 + code will be sent to your account's email address. 108 + </p> 109 + 110 + <TextInput 111 + label="One-time confirmation code" 112 + type="text" 113 + autocomplete="one-time-code" 114 + autocorrect="off" 115 + pattern={/* @once */ TOTP_RE.source} 116 + placeholder="AAAAA-BBBBB" 117 + value={otp()} 118 + required 119 + autofocus={isActive()} 120 + onChange={setOtp} 121 + monospace 122 + /> 123 + 124 + <div hidden={!isActive()} class="-mt-4 flex flex-wrap gap-4"> 125 + <button 126 + disabled={requestMutation.isPending} 127 + type="button" 128 + class={ 129 + `flex items-center gap-1 text-[0.8125rem] leading-5 text-purple-800 hover:underline disabled:pointer-events-none` + 130 + (requestMutation.isPending ? `disabled:opacity-50` : ``) 131 + } 132 + onClick={() => requestMutation.mutate()} 133 + > 134 + <span>Request confirmation code</span> 135 + {requestMutation.isSuccess && <CheckIcon class="text-lg text-green-600" />} 136 + </button> 137 + </div> 138 + 139 + <StageErrorView error={requestError()} /> 140 + 141 + <div> 142 + <p class="text-pretty"> 143 + Review your payload carefully, and click <i>Submit</i> when ready. 144 + </p> 145 + 146 + <p class="mt-3 text-pretty font-medium text-red-800"> 147 + Caution: This action carries significant risk which can possibly render your did:plc identity 148 + unusable. Proceed at your own risk, we assume no liability for any consequences. 149 + </p> 150 + </div> 151 + 152 + <StageErrorView error={applyError()} /> 153 + 154 + <StageActions hidden={!isActive()}> 155 + <StageActions.Divider /> 156 + <Button variant="secondary" onClick={onPrevious}> 157 + Previous 158 + </Button> 159 + <Button type="submit">Next</Button> 160 + </StageActions> 161 + </Stage> 162 + ); 163 + }; 164 + 165 + export default Step5_PdsConfirmation;
+148
src/views/identity/plc-applicator/steps/step5_private-key-confirmation.tsx
··· 1 + import { createSignal } from 'solid-js'; 2 + 3 + import * as CBOR from '@atcute/cbor'; 4 + import * as uint8arrays from 'uint8arrays'; 5 + 6 + import { PlcUpdateOp } from '~/api/types/plc'; 7 + 8 + import { generateConfirmationCode } from '~/lib/utils/confirmation-code'; 9 + import { createMutation } from '~/lib/utils/mutation'; 10 + 11 + import Button from '~/components/inputs/button'; 12 + import TextInput from '~/components/inputs/text-input'; 13 + import { Stage, StageActions, StageErrorView, WizardStepProps } from '~/components/wizard'; 14 + 15 + import { PlcApplicatorConstraints } from '../page'; 16 + 17 + const Step5_PrivateKeyConfirmation = ({ 18 + data, 19 + isActive, 20 + onPrevious, 21 + onNext, 22 + }: WizardStepProps<PlcApplicatorConstraints, 'Step5_PrivateKeyConfirmation'>) => { 23 + const [error, setError] = createSignal<string>(); 24 + 25 + const code = generateConfirmationCode(); 26 + 27 + const mutation = createMutation({ 28 + async mutationFn() { 29 + const keypair = data.method.keypair; 30 + const payload = data.payload; 31 + const prev = data.base; 32 + 33 + const operation: Omit<PlcUpdateOp, 'sig'> = { 34 + type: 'plc_operation', 35 + prev: prev!.cid, 36 + 37 + alsoKnownAs: payload.alsoKnownAs, 38 + rotationKeys: payload.rotationKeys, 39 + services: payload.services, 40 + verificationMethods: payload.verificationMethods, 41 + }; 42 + 43 + const opBytes = CBOR.encode(operation); 44 + const sigBytes = await keypair.sign(opBytes); 45 + 46 + const signature = uint8arrays.toString(sigBytes, 'base64url'); 47 + 48 + const signedOperation: PlcUpdateOp = { 49 + ...operation, 50 + sig: signature, 51 + }; 52 + 53 + await pushPlcOperation(data.info.didDoc.id, signedOperation); 54 + }, 55 + onMutate() { 56 + setError(); 57 + }, 58 + onSuccess() { 59 + onNext('Step6_Finished', {}); 60 + }, 61 + onError(error) { 62 + let message: string | undefined; 63 + 64 + if (message !== undefined) { 65 + setError(message); 66 + } else { 67 + console.error(error); 68 + setError(`Something went wrong: ${error}`); 69 + } 70 + }, 71 + }); 72 + 73 + return ( 74 + <Stage 75 + title="One more step" 76 + disabled={mutation.isPending} 77 + onSubmit={() => { 78 + mutation.mutate(); 79 + }} 80 + > 81 + <p class="text-pretty"> 82 + To continue with this submission, type in the following code{' '} 83 + <code class="whitespace-nowrap font-bold">{code}</code> to the confirmation box below. 84 + </p> 85 + 86 + <TextInput 87 + label="Confirmation code" 88 + type="text" 89 + autocomplete="one-time-code" 90 + autocorrect="off" 91 + required 92 + pattern={code} 93 + placeholder="AAAAA-BBBBB" 94 + autofocus={isActive()} 95 + monospace 96 + /> 97 + 98 + <div> 99 + <p class="text-pretty"> 100 + Review your payload carefully, and click <i>Submit</i> when ready. 101 + </p> 102 + 103 + <p class="mt-3 text-pretty font-medium text-red-800"> 104 + Caution: This action carries significant risk which can possibly render your did:plc identity 105 + unusable. Proceed at your own risk, we assume no liability for any consequences. 106 + </p> 107 + </div> 108 + 109 + <StageErrorView error={error()} /> 110 + 111 + <StageActions hidden={!isActive()}> 112 + <StageActions.Divider /> 113 + 114 + <Button variant="secondary" onClick={onPrevious}> 115 + Previous 116 + </Button> 117 + <Button type="submit">Next</Button> 118 + </StageActions> 119 + </Stage> 120 + ); 121 + }; 122 + 123 + export default Step5_PrivateKeyConfirmation; 124 + 125 + const pushPlcOperation = async (did: string, operation: PlcUpdateOp) => { 126 + const origin = import.meta.env.VITE_PLC_DIRECTORY_URL; 127 + const response = await fetch(`${origin}/${did}`, { 128 + method: 'post', 129 + headers: { 130 + 'content-type': 'application/json', 131 + }, 132 + body: JSON.stringify(operation), 133 + }); 134 + 135 + const headers = response.headers; 136 + if (!response.ok) { 137 + const type = headers.get('content-type'); 138 + 139 + if (type?.includes('application/json')) { 140 + const json = await response.json(); 141 + if (typeof json === 'object' && json !== null && typeof json.message === 'string') { 142 + throw new Error(json.message); 143 + } 144 + } 145 + 146 + throw new Error(`got http ${response.status} from plc`); 147 + } 148 + };
+19
src/views/identity/plc-applicator/steps/step6_finished.tsx
··· 1 + import { Stage, WizardStepProps } from '~/components/wizard'; 2 + 3 + import { PlcApplicatorConstraints } from '../page'; 4 + 5 + export const Step6_Finished = ({}: WizardStepProps<PlcApplicatorConstraints, 'Step6_Finished'>) => { 6 + return ( 7 + <Stage title="All done!"> 8 + <div> 9 + <p class="text-pretty">Your did:plc identity has been updated.</p> 10 + 11 + <p class="mt-3 text-pretty"> 12 + You can close this page, or reload the page if you intend on doing another submission. 13 + </p> 14 + </div> 15 + </Stage> 16 + ); 17 + }; 18 + 19 + export default Step6_Finished;