import { bytesToString, canonicalJson, decodeCrock, encodeCrock, stringToBytes, } from "@gnu-taler/taler-util"; import { FunctionalComponent, ComponentChildren, h, createContext, } from "preact"; import { useState, useContext, useRef, useLayoutEffect } from "preact/hooks"; import { AnastasisReducerApi, AuthMethod, BackupStates, ChallengeFeedback, ChallengeInfo, RecoveryStates, ReducerStateBackup, ReducerStateRecovery, useAnastasisReducer, } from "../../hooks/use-anastasis-reducer"; import style from "./style.css"; const WithReducer = createContext(undefined); function isBackup(reducer: AnastasisReducerApi) { return !!reducer.currentReducerState?.backup_state; } interface CommonReducerProps { reducer: AnastasisReducerApi; reducerState: ReducerStateBackup | ReducerStateRecovery; } function withProcessLabel(reducer: AnastasisReducerApi, text: string): string { if (isBackup(reducer)) { return "Backup: " + text; } return "Recovery: " + text; } function ContinentSelection(props: CommonReducerProps) { const { reducer, reducerState } = props; const sel = (x: string) => reducer.transition("select_continent", { continent: x }); return ( {reducerState.continents.map((x: any) => ( ))} ); } function CountrySelection(props: CommonReducerProps) { const { reducer, reducerState } = props; const sel = (x: any) => reducer.transition("select_country", { country_code: x.code, currencies: [x.currency], }); return ( {reducerState.countries.map((x: any) => ( ))} ); } interface SolveEntryProps { reducer: AnastasisReducerApi; challenge: ChallengeInfo; feedback?: ChallengeFeedback; } function SolveQuestionEntry(props: SolveEntryProps) { const [answer, setAnswer] = useState(""); const { reducer, challenge, feedback } = props; const next = () => reducer.transition("solve_challenge", { answer, }); return ( next()} >

Feedback: {JSON.stringify(feedback)}

Question: {challenge.instructions}

); } function SolveSmsEntry(props: SolveEntryProps) { const [answer, setAnswer] = useState(""); const { reducer, challenge, feedback } = props; const next = () => reducer.transition("solve_challenge", { answer, }); return ( next()} >

Feedback: {JSON.stringify(feedback)}

{challenge.instructions}

); } function SolvePostEntry(props: SolveEntryProps) { const [answer, setAnswer] = useState(""); const { reducer, challenge, feedback } = props; const next = () => reducer.transition("solve_challenge", { answer, }); return ( next()} >

Feedback: {JSON.stringify(feedback)}

{challenge.instructions}

); } function SolveEmailEntry(props: SolveEntryProps) { const [answer, setAnswer] = useState(""); const { reducer, challenge, feedback } = props; const next = () => reducer.transition("solve_challenge", { answer, }); return ( next()} >

Feedback: {JSON.stringify(feedback)}

{challenge.instructions}

); } function SolveUnsupportedEntry(props: SolveEntryProps) { return (

{JSON.stringify(props.challenge)}

Challenge not supported.

); } function SecretEditor(props: BackupReducerProps) { const { reducer } = props; const [secretName, setSecretName] = useState( props.backupState.secret_name ?? "", ); const [secretValue, setSecretValue] = useState( props.backupState.core_secret?.value ?? "" ?? "", ); const secretNext = () => { reducer.runTransaction(async (tx) => { await tx.transition("enter_secret_name", { name: secretName, }); await tx.transition("enter_secret", { secret: { value: encodeCrock(stringToBytes(secretValue)), mime: "text/plain", }, expiration: { t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5, }, }); await tx.transition("next", {}); }); }; return ( secretNext()} >
); } export interface BackupReducerProps { reducer: AnastasisReducerApi; backupState: ReducerStateBackup; } function ReviewPolicies(props: BackupReducerProps) { const { reducer, backupState } = props; const authMethods = backupState.authentication_methods!; return ( {backupState.policies?.map((p, i) => { const policyName = p.methods .map((x) => authMethods[x.authentication_method].type) .join(" + "); return (

Policy #{i + 1}: {policyName}

Required Authentications:
    {p.methods.map((x) => { const m = authMethods[x.authentication_method]; return (
  • {m.type} ({m.instructions}) at provider {x.provider}
  • ); })}
); })}
); } export interface RecoveryReducerProps { reducer: AnastasisReducerApi; recoveryState: ReducerStateRecovery; } function SecretSelection(props: RecoveryReducerProps) { const { reducer, recoveryState } = props; const [selectingVersion, setSelectingVersion] = useState(false); const [otherVersion, setOtherVersion] = useState( recoveryState.recovery_document?.version ?? 0, ); const recoveryDocument = recoveryState.recovery_document!; const [otherProvider, setOtherProvider] = useState(""); function selectVersion(p: string, n: number) { reducer.runTransaction(async (tx) => { await tx.transition("change_version", { version: n, provider_url: p, }); setSelectingVersion(false); }); } if (selectingVersion) { return (

Select a different version of the secret

setOtherVersion(Number((e.target as HTMLInputElement).value)) } type="number" />
); } return (

Provider: {recoveryDocument.provider_url}

Secret version: {recoveryDocument.version}

Secret name: {recoveryDocument.version}

); } interface AnastasisClientFrameProps { onNext?(): void; title: string; children: ComponentChildren; /** * Should back/next buttons be provided? */ hideNav?: boolean; /** * Hide only the "next" button. */ hideNext?: boolean; } function AnastasisClientFrame(props: AnastasisClientFrameProps) { const reducer = useContext(WithReducer); if (!reducer) { return

Fatal: Reducer must be in context.

; } const next = () => { if (props.onNext) { props.onNext(); } else { reducer.transition("next", {}); } }; const handleKeyPress = (e: h.JSX.TargetedKeyboardEvent) => { console.log("Got key press", e.key); // FIXME: By default, "next" action should be executed here }; return (
handleKeyPress(e)}>

{props.title}

{props.children} {!props.hideNav ? (
{!props.hideNext ? ( ) : null}
) : null}
); } function ChallengeOverview(props: RecoveryReducerProps) { const { recoveryState, reducer } = props; const policies = recoveryState.recovery_information!.policies; const chArr = recoveryState.recovery_information!.challenges; const challenges: { [uuid: string]: { type: string; instructions: string; cost: string; }; } = {}; for (const ch of chArr) { challenges[ch.uuid] = { type: ch.type, cost: ch.cost, instructions: ch.instructions, }; } return (

Policies

{policies.map((x, i) => { return (

Policy #{i + 1}

{x.map((x) => { const ch = challenges[x.uuid]; const feedback = recoveryState.challenge_feedback?.[x.uuid]; return (

{ch.type} ({ch.instructions})

Status: {feedback?.state ?? "unknown"}

{feedback?.state !== "solved" ? ( ) : null}
); })}
); })}
); } const AnastasisClient: FunctionalComponent = () => { const reducer = useAnastasisReducer(); return ( ); }; const AnastasisClientImpl: FunctionalComponent = () => { const reducer = useContext(WithReducer)!; const reducerState = reducer.currentReducerState; if (!reducerState) { return ( ); } console.log("state", reducer.currentReducerState); if ( reducerState.backup_state === BackupStates.ContinentSelecting || reducerState.recovery_state === RecoveryStates.ContinentSelecting ) { return ; } if ( reducerState.backup_state === BackupStates.CountrySelecting || reducerState.recovery_state === RecoveryStates.CountrySelecting ) { return ; } if ( reducerState.backup_state === BackupStates.UserAttributesCollecting || reducerState.recovery_state === RecoveryStates.UserAttributesCollecting ) { return ; } if (reducerState.backup_state === BackupStates.AuthenticationsEditing) { return ( ); } if (reducerState.backup_state === BackupStates.PoliciesReviewing) { return ; } if (reducerState.backup_state === BackupStates.SecretEditing) { return ; } if (reducerState.backup_state === BackupStates.BackupFinished) { const backupState: ReducerStateBackup = reducerState; return (

Your backup of secret "{backupState.secret_name ?? "??"}" was successful.

The backup is stored by the following providers:

    {Object.keys(backupState.success_details).map((x, i) => { const sd = backupState.success_details[x]; return (
  • {x} (Policy version {sd.policy_version})
  • ); })}
); } if (reducerState.backup_state === BackupStates.TruthsPaying) { const backupState: ReducerStateBackup = reducerState; const payments = backupState.payments ?? []; return (

Some of the providers require a payment to store the encrypted authentication information.

    {payments.map((x) => { return
  • {x}
  • ; })}
); } if (reducerState.backup_state === BackupStates.PoliciesPaying) { const backupState: ReducerStateBackup = reducerState; const payments = backupState.policy_payment_requests ?? []; return (

Some of the providers require a payment to store the encrypted recovery document.

    {payments.map((x) => { return (
  • {x.provider}: {x.payto}
  • ); })}
); } if (reducerState.recovery_state === RecoveryStates.SecretSelecting) { return ; } if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) { return ; } if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) { const chArr = reducerState.recovery_information!.challenges; const challengeFeedback = reducerState.challenge_feedback ?? {}; const selectedUuid = reducerState.selected_challenge_uuid!; const challenges: { [uuid: string]: ChallengeInfo; } = {}; for (const ch of chArr) { challenges[ch.uuid] = ch; } const selectedChallenge = challenges[selectedUuid]; const dialogMap: Record h.JSX.Element> = { question: SolveQuestionEntry, sms: SolveSmsEntry, email: SolveEmailEntry, post: SolvePostEntry, }; const SolveDialog = dialogMap[selectedChallenge.type] ?? SolveUnsupportedEntry; return ( ); } if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { return (

Recovery Finished

Secret: {bytesToString(decodeCrock(reducerState.core_secret?.value!))}

); } console.log("unknown state", reducer.currentReducerState); return (

Bug: Unknown state.

); }; interface AuthMethodSetupProps { method: string; addAuthMethod: (x: any) => void; cancel: () => void; } function AuthMethodSmsSetup(props: AuthMethodSetupProps) { const [mobileNumber, setMobileNumber] = useState(""); const addSmsAuth = () => { props.addAuthMethod({ authentication_method: { type: "sms", instructions: `SMS to ${mobileNumber}`, challenge: encodeCrock(stringToBytes(mobileNumber)), }, }); }; const inputRef = useRef(null); useLayoutEffect(() => { inputRef.current?.focus(); }, []); return (

For SMS authentication, you need to provide a mobile number. When recovering your secret, you will be asked to enter the code you receive via SMS.

); } function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { const [questionText, setQuestionText] = useState(""); const [answerText, setAnswerText] = useState(""); const addQuestionAuth = () => props.addAuthMethod({ authentication_method: { type: "question", instructions: questionText, challenge: encodeCrock(stringToBytes(answerText)), }, }); return (

For security question authentication, you need to provide a question and its answer. When recovering your secret, you will be shown the question and you will need to type the answer exactly as you typed it here.

); } function AuthMethodEmailSetup(props: AuthMethodSetupProps) { const [email, setEmail] = useState(""); return (

For email authentication, you need to provide an email address. When recovering your secret, you will need to enter the code you receive by email.

); } function AuthMethodPostSetup(props: AuthMethodSetupProps) { const [fullName, setFullName] = useState(""); const [street, setStreet] = useState(""); const [city, setCity] = useState(""); const [postcode, setPostcode] = useState(""); const [country, setCountry] = useState(""); const addPostAuth = () => { const challengeJson = { full_name: fullName, street, city, postcode, country, }; props.addAuthMethod({ authentication_method: { type: "email", instructions: `Letter to address in postal code ${postcode}`, challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))), }, }); }; return (

Add {props.method} authentication

For postal letter authentication, you need to provide a postal address. When recovering your secret, you will be asked to enter a code that you will receive in a letter to that address.

); } function AuthMethodNotImplemented(props: AuthMethodSetupProps) { return (

This auth method is not implemented yet, please choose another one.

); } export interface AuthenticationEditorProps { reducer: AnastasisReducerApi; backupState: ReducerStateBackup; } function AuthenticationEditor(props: AuthenticationEditorProps) { const [selectedMethod, setSelectedMethod] = useState( undefined, ); const { reducer, backupState } = props; const providers = backupState.authentication_providers; const authAvailableSet = new Set(); for (const provKey of Object.keys(providers)) { const p = providers[provKey]; if (p.methods) { for (const meth of p.methods) { authAvailableSet.add(meth.type); } } } if (selectedMethod) { const cancel = () => setSelectedMethod(undefined); const addMethod = (args: any) => { reducer.transition("add_authentication", args); setSelectedMethod(undefined); }; const methodMap: Record< string, (props: AuthMethodSetupProps) => h.JSX.Element > = { sms: AuthMethodSmsSetup, question: AuthMethodQuestionSetup, email: AuthMethodEmailSetup, post: AuthMethodPostSetup, }; const AuthSetup = methodMap[selectedMethod] ?? AuthMethodNotImplemented; return ( ); } function MethodButton(props: { method: string; label: String }) { return ( ); } const configuredAuthMethods: AuthMethod[] = backupState.authentication_methods ?? []; const haveMethodsConfigured = configuredAuthMethods.length; return (

Configured authentication methods

{haveMethodsConfigured ? ( configuredAuthMethods.map((x, i) => { return (

{x.type} ({x.instructions}){" "}

); }) ) : (

No authentication methods configured yet.

)}
); } export interface AttributeEntryProps { reducer: AnastasisReducerApi; reducerState: ReducerStateRecovery | ReducerStateBackup; } function AttributeEntry(props: AttributeEntryProps) { const { reducer, reducerState: backupState } = props; const [attrs, setAttrs] = useState>( props.reducerState.identity_attributes ?? {}, ); return ( reducer.transition("enter_user_attributes", { identity_attributes: attrs, }) } > {backupState.required_attributes.map((x: any, i: number) => { return ( setAttrs({ ...attrs, [x.name]: v })} spec={x} value={attrs[x.name]} /> ); })} ); } interface LabeledInputProps { label: string; grabFocus?: boolean; bind: [string, (x: string) => void]; } function LabeledInput(props: LabeledInputProps) { const inputRef = useRef(null); useLayoutEffect(() => { if (props.grabFocus) { inputRef.current?.focus(); } }, []); return ( ); } export interface AttributeEntryFieldProps { isFirst: boolean; value: string; setValue: (newValue: string) => void; spec: any; } function AttributeEntryField(props: AttributeEntryFieldProps) { return (
); } interface ErrorBannerProps { reducer: AnastasisReducerApi; } /** * Show a dismissable error banner if there is a current error. */ function ErrorBanner(props: ErrorBannerProps) { const currentError = props.reducer.currentError; if (currentError) { return (

Error: {JSON.stringify(currentError)}

); } return null; } export default AnastasisClient;