diff options
author | Florian Dold <florian@dold.me> | 2021-10-14 15:35:34 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2021-10-14 15:35:34 +0200 |
commit | 40b137b549d9e62ff05eb1c7973901bcd6ab54b3 (patch) | |
tree | 8d4848261908165d979b6cf336702ee0f3fb6c49 | |
parent | c53264869451ccbfbaf1976e01df8c7636163068 (diff) |
anastasis-webui: implement more challenge types
4 files changed, 310 insertions, 318 deletions
diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts index efa0592dd..3acaaa361 100644 --- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts +++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts @@ -32,6 +32,11 @@ export interface ReducerStateBackup { payto: string; provider: string; }[]; + + core_secret?: { + mime: string; + value: string; + }; } export interface AuthMethod { diff --git a/packages/anastasis-webui/src/routes/home/index.tsx b/packages/anastasis-webui/src/routes/home/index.tsx index c6bf15be6..b1d017f30 100644 --- a/packages/anastasis-webui/src/routes/home/index.tsx +++ b/packages/anastasis-webui/src/routes/home/index.tsx @@ -45,43 +45,39 @@ function withProcessLabel(reducer: AnastasisReducerApi, text: string): string { function ContinentSelection(props: CommonReducerProps) { const { reducer, reducerState } = props; + const sel = (x: string) => + reducer.transition("select_continent", { continent: x }); return ( <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Continent")} > - {reducerState.continents.map((x: any) => { - const sel = (x: string) => - reducer.transition("select_continent", { continent: x }); - return ( - <button onClick={() => sel(x.name)} key={x.name}> - {x.name} - </button> - ); - })} + {reducerState.continents.map((x: any) => ( + <button onClick={() => sel(x.name)} key={x.name}> + {x.name} + </button> + ))} </AnastasisClientFrame> ); } function CountrySelection(props: CommonReducerProps) { const { reducer, reducerState } = props; + const sel = (x: any) => + reducer.transition("select_country", { + country_code: x.code, + currencies: [x.currency], + }); return ( <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Country")} > - {reducerState.countries.map((x: any) => { - const sel = (x: any) => - reducer.transition("select_country", { - country_code: x.code, - currencies: [x.currency], - }); - return ( - <button onClick={() => sel(x)} key={x.name}> - {x.name} ({x.currency}) - </button> - ); - })} + {reducerState.countries.map((x: any) => ( + <button onClick={() => sel(x)} key={x.name}> + {x.name} ({x.currency}) + </button> + ))} </AnastasisClientFrame> ); } @@ -106,21 +102,85 @@ function SolveQuestionEntry(props: SolveEntryProps) { > <p>Feedback: {JSON.stringify(feedback)}</p> <p>Question: {challenge.instructions}</p> - <label> - <input - value={answer} - onChange={(e) => setAnswer((e.target as HTMLInputElement).value)} - type="test" - /> - </label> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} + +function SolveSmsEntry(props: SolveEntryProps) { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = () => + reducer.transition("solve_challenge", { + answer, + }); + return ( + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <p>Feedback: {JSON.stringify(feedback)}</p> + <p>{challenge.instructions}</p> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} + +function SolvePostEntry(props: SolveEntryProps) { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = () => + reducer.transition("solve_challenge", { + answer, + }); + return ( + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <p>Feedback: {JSON.stringify(feedback)}</p> + <p>{challenge.instructions}</p> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} + +function SolveEmailEntry(props: SolveEntryProps) { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = () => + reducer.transition("solve_challenge", { + answer, + }); + return ( + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <p>Feedback: {JSON.stringify(feedback)}</p> + <p>{challenge.instructions}</p> + <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </AnastasisClientFrame> + ); +} + +function SolveUnsupportedEntry(props: SolveEntryProps) { + return ( + <AnastasisClientFrame hideNext title="Recovery: Solve challenge"> + <p>{JSON.stringify(props.challenge)}</p> + <p>Challenge not supported.</p> </AnastasisClientFrame> ); } function SecretEditor(props: BackupReducerProps) { const { reducer } = props; - const [secretName, setSecretName] = useState(""); - const [secretValue, setSecretValue] = useState(""); + 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", { @@ -144,34 +204,17 @@ function SecretEditor(props: BackupReducerProps) { onNext={() => secretNext()} > <div> - <label> - Secret name:{" "} - <input - value={secretName} - onChange={(e) => - setSecretName((e.target as HTMLInputElement).value) - } - type="text" - /> - </label> - </div> - <div> - <label> - Secret value:{" "} - <input - value={secretValue} - onChange={(e) => - setSecretValue((e.target as HTMLInputElement).value) - } - type="text" - /> - </label> + <LabeledInput + label="Secret Name:" + grabFocus + bind={[secretName, setSecretName]} + /> </div> - or: <div> - <label> - File Upload: <input type="file" /> - </label> + <LabeledInput + label="Secret Value:" + bind={[secretValue, setSecretValue]} + /> </div> </AnastasisClientFrame> ); @@ -234,6 +277,7 @@ function SecretSelection(props: RecoveryReducerProps) { const [otherVersion, setOtherVersion] = useState<number>( recoveryState.recovery_document?.version ?? 0, ); + const recoveryDocument = recoveryState.recovery_document!; const [otherProvider, setOtherProvider] = useState<string>(""); function selectVersion(p: string, n: number) { reducer.runTransaction(async (tx) => { @@ -250,9 +294,11 @@ function SecretSelection(props: RecoveryReducerProps) { <p>Select a different version of the secret</p> <select onChange={(e) => setOtherProvider((e.target as any).value)}> {Object.keys(recoveryState.authentication_providers ?? {}).map( - (x) => { - return <option value={x}>{x}</option>; - }, + (x) => ( + <option selected={x === recoveryDocument.provider_url} value={x}> + {x} + </option> + ), )} </select> <div> @@ -264,7 +310,7 @@ function SecretSelection(props: RecoveryReducerProps) { type="number" /> <button onClick={() => selectVersion(otherProvider, otherVersion)}> - Select + Use this version </button> </div> <div> @@ -280,9 +326,9 @@ function SecretSelection(props: RecoveryReducerProps) { } return ( <AnastasisClientFrame title="Recovery: Select secret"> - <p>Provider: {recoveryState.recovery_document!.provider_url}</p> - <p>Secret version: {recoveryState.recovery_document!.version}</p> - <p>Secret name: {recoveryState.recovery_document!.version}</p> + <p>Provider: {recoveryDocument.provider_url}</p> + <p>Secret version: {recoveryDocument.version}</p> + <p>Secret name: {recoveryDocument.version}</p> <button onClick={() => setSelectingVersion(true)}> Select different secret </button> @@ -305,37 +351,99 @@ interface AnastasisClientFrameProps { } function AnastasisClientFrame(props: AnastasisClientFrameProps) { + const reducer = useContext(WithReducer); + if (!reducer) { + return <p>Fatal: Reducer must be in context.</p>; + } + const next = () => { + if (props.onNext) { + props.onNext(); + } else { + reducer.transition("next", {}); + } + }; + const handleKeyPress = (e: h.JSX.TargetedKeyboardEvent<HTMLDivElement>) => { + console.log("Got key press", e.key); + // FIXME: By default, "next" action should be executed here + }; return ( - <WithReducer.Consumer> - {(reducer) => { - if (!reducer) { - return <p>Fatal: Reducer must be in context.</p>; - } - const next = () => { - if (props.onNext) { - props.onNext(); - } else { - reducer.transition("next", {}); - } - }; + <div class={style.home} onKeyPress={(e) => handleKeyPress(e)}> + <button onClick={() => reducer.reset()}>Reset session</button> + <h1>{props.title}</h1> + <ErrorBanner reducer={reducer} /> + {props.children} + {!props.hideNav ? ( + <div> + <button onClick={() => reducer.back()}>Back</button> + {!props.hideNext ? ( + <button onClick={() => next()}>Next</button> + ) : null} + </div> + ) : null} + </div> + ); +} + +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 ( + <AnastasisClientFrame title="Recovery: Solve challenges"> + <h2>Policies</h2> + {policies.map((x, i) => { return ( - <div class={style.home}> - <button onClick={() => reducer.reset()}>Reset session</button> - <h1>{props.title}</h1> - <ErrorBanner reducer={reducer} /> - {props.children} - {!props.hideNav ? ( - <div> - <button onClick={() => reducer.back()}>Back</button> - {!props.hideNext ? ( - <button onClick={() => next()}>Next</button> - ) : null} - </div> - ) : null} + <div> + <h3>Policy #{i + 1}</h3> + {x.map((x) => { + const ch = challenges[x.uuid]; + const feedback = recoveryState.challenge_feedback?.[x.uuid]; + return ( + <div + style={{ + borderLeft: "2px solid gray", + paddingLeft: "0.5em", + borderRadius: "0.5em", + marginTop: "0.5em", + marginBottom: "0.5em", + }} + > + <h4> + {ch.type} ({ch.instructions}) + </h4> + <p>Status: {feedback?.state ?? "unknown"}</p> + {feedback?.state !== "solved" ? ( + <button + onClick={() => + reducer.transition("select_challenge", { + uuid: x.uuid, + }) + } + > + Solve + </button> + ) : null} + </div> + ); + })} </div> ); - }} - </WithReducer.Consumer> + })} + </AnastasisClientFrame> ); } @@ -472,51 +580,7 @@ const AnastasisClientImpl: FunctionalComponent = () => { } if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) { - const policies = reducerState.recovery_information!.policies; - const chArr = reducerState.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 ( - <AnastasisClientFrame title="Recovery: Solve challenges"> - <h2>Policies</h2> - {policies.map((x, i) => { - return ( - <div> - <h3>Policy #{i + 1}</h3> - {x.map((x) => { - const ch = challenges[x.uuid]; - return ( - <div> - {ch.type} ({ch.instructions}) - <button - onClick={() => - reducer.transition("select_challenge", { - uuid: x.uuid, - }) - } - > - Solve - </button> - </div> - ); - })} - </div> - ); - })} - </AnastasisClientFrame> - ); + return <ChallengeOverview reducer={reducer} recoveryState={reducerState} />; } if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) { @@ -530,22 +594,21 @@ const AnastasisClientImpl: FunctionalComponent = () => { challenges[ch.uuid] = ch; } const selectedChallenge = challenges[selectedUuid]; - if (selectedChallenge.type === "question") { - return ( - <SolveQuestionEntry - challenge={selectedChallenge} - reducer={reducer} - feedback={challengeFeedback[selectedUuid]} - /> - ); - } else { - return ( - <AnastasisClientFrame hideNext title="Recovery: Solve challenge"> - <p>{JSON.stringify(selectedChallenge)}</p> - <p>Challenge not supported.</p> - </AnastasisClientFrame> - ); - } + const dialogMap: Record<string, (p: SolveEntryProps) => h.JSX.Element> = { + question: SolveQuestionEntry, + sms: SolveSmsEntry, + email: SolveEmailEntry, + post: SolvePostEntry, + }; + const SolveDialog = + dialogMap[selectedChallenge.type] ?? SolveUnsupportedEntry; + return ( + <SolveDialog + challenge={selectedChallenge} + reducer={reducer} + feedback={challengeFeedback[selectedUuid]} + /> + ); } if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { @@ -620,6 +683,14 @@ function AuthMethodSmsSetup(props: AuthMethodSetupProps) { 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 ( <AnastasisClientFrame hideNav title="Add Security Question"> <div> @@ -630,44 +701,18 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { here. </p> <div> - <label> - Security question:{" "} - <input - value={questionText} - style={{ display: "block" }} - autoFocus - onChange={(e) => setQuestionText((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput + label="Security question" + grabFocus + bind={[questionText, setQuestionText]} + /> </div> <div> - <label> - Answer:{" "} - <input - value={answerText} - style={{ display: "block" }} - autoFocus - onChange={(e) => setAnswerText((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput label="Answer" bind={[answerText, setAnswerText]} /> </div> <div> <button onClick={() => props.cancel()}>Cancel</button> - <button - onClick={() => - props.addAuthMethod({ - authentication_method: { - type: "question", - instructions: questionText, - challenge: encodeCrock(stringToBytes(answerText)), - }, - }) - } - > - Add - </button> + <button onClick={() => addQuestionAuth()}>Add</button> </div> </div> </AnastasisClientFrame> @@ -684,16 +729,11 @@ function AuthMethodEmailSetup(props: AuthMethodSetupProps) { email. </p> <div> - <label> - Email address:{" "} - <input - style={{ display: "block" }} - value={email} - autoFocus - onChange={(e) => setEmail((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput + label="Email address" + grabFocus + bind={[email, setEmail]} + /> </div> <div> <button onClick={() => props.cancel()}>Cancel</button> @@ -723,24 +763,20 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) { const [country, setCountry] = useState(""); const addPostAuth = () => { - () => - props.addAuthMethod({ - authentication_method: { - type: "email", - instructions: `Letter to address in postal code ${postcode}`, - challenge: encodeCrock( - stringToBytes( - canonicalJson({ - full_name: fullName, - street, - city, - postcode, - country, - }), - ), - ), - }, - }); + 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 ( @@ -753,59 +789,23 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) { code that you will receive in a letter to that address. </p> <div> - <label> - Full Name - <input - value={fullName} - autoFocus - onChange={(e) => setFullName((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput + grabFocus + label="Full Name" + bind={[fullName, setFullName]} + /> </div> <div> - <label> - Street - <input - value={street} - autoFocus - onChange={(e) => setStreet((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput label="Street" bind={[street, setStreet]} /> </div> <div> - <label> - City - <input - value={city} - autoFocus - onChange={(e) => setCity((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput label="City" bind={[city, setCity]} /> </div> <div> - <label> - Postal Code - <input - value={postcode} - autoFocus - onChange={(e) => setPostcode((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput label="Postal Code" bind={[postcode, setPostcode]} /> </div> <div> - <label> - Country - <input - value={country} - autoFocus - onChange={(e) => setCountry((e.target as any).value)} - type="text" - /> - </label> + <LabeledInput label="Country" bind={[country, setCountry]} /> </div> <div> <button onClick={() => props.cancel()}>Cancel</button> @@ -851,48 +851,23 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { reducer.transition("add_authentication", args); setSelectedMethod(undefined); }; - switch (selectedMethod) { - case "sms": - return ( - <AuthMethodSmsSetup - cancel={cancel} - addAuthMethod={addMethod} - method="sms" - /> - ); - case "question": - return ( - <AuthMethodQuestionSetup - cancel={cancel} - addAuthMethod={addMethod} - method="question" - /> - ); - case "email": - return ( - <AuthMethodEmailSetup - cancel={cancel} - addAuthMethod={addMethod} - method="email" - /> - ); - case "post": - return ( - <AuthMethodPostSetup - cancel={cancel} - addAuthMethod={addMethod} - method="post" - /> - ); - default: - return ( - <AuthMethodNotImplemented - cancel={cancel} - addAuthMethod={addMethod} - method={selectedMethod} - /> - ); - } + const methodMap: Record< + string, + (props: AuthMethodSetupProps) => h.JSX.Element + > = { + sms: AuthMethodSmsSetup, + question: AuthMethodQuestionSetup, + email: AuthMethodEmailSetup, + post: AuthMethodPostSetup, + }; + const AuthSetup = methodMap[selectedMethod] ?? AuthMethodNotImplemented; + return ( + <AuthSetup + cancel={cancel} + addAuthMethod={addMethod} + method={selectedMethod} + /> + ); } function MethodButton(props: { method: string; label: String }) { return ( @@ -978,6 +953,32 @@ function AttributeEntry(props: AttributeEntryProps) { ); } +interface LabeledInputProps { + label: string; + grabFocus?: boolean; + bind: [string, (x: string) => void]; +} + +function LabeledInput(props: LabeledInputProps) { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, []); + return ( + <label> + {props.label} + <input + value={props.bind[0]} + onChange={(e) => props.bind[1]((e.target as HTMLInputElement).value)} + ref={inputRef} + style={{ display: "block" }} + /> + </label> + ); +} + export interface AttributeEntryFieldProps { isFirst: boolean; value: string; @@ -988,13 +989,10 @@ export interface AttributeEntryFieldProps { function AttributeEntryField(props: AttributeEntryFieldProps) { return ( <div> - <label>{props.spec.label}:</label> - <input - style={{ display: "block" }} - autoFocus={props.isFirst} - type="text" - value={props.value} - onChange={(e) => props.setValue((e as any).target.value)} + <LabeledInput + grabFocus={props.isFirst} + label={props.spec.label} + bind={[props.value, props.setValue]} /> </div> ); diff --git a/packages/anastasis-webui/src/routes/home/style.css b/packages/anastasis-webui/src/routes/home/style.css index b94981f10..e70f11a59 100644 --- a/packages/anastasis-webui/src/routes/home/style.css +++ b/packages/anastasis-webui/src/routes/home/style.css @@ -2,6 +2,7 @@ padding: 1em 1em; min-height: 100%; width: 100%; + max-width: 40em; } .home div { diff --git a/packages/anastasis-webui/tests/header.test.tsx b/packages/anastasis-webui/tests/header.test.tsx deleted file mode 100644 index b2cfc2f4d..000000000 --- a/packages/anastasis-webui/tests/header.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { h } from 'preact'; -import Header from '../src/components/header'; -// See: https://github.com/preactjs/enzyme-adapter-preact-pure -import { shallow } from 'enzyme'; - -describe('Initial Test of the Header', () => { - test('Header renders 3 nav items', () => { - const context = shallow(<Header />); - expect(context.find('h1').text()).toBe('Preact App'); - expect(context.find('Link').length).toBe(3); - }); -}); |