anastasis-webui: implement more challenge types

This commit is contained in:
Florian Dold 2021-10-14 15:35:34 +02:00
parent c532648694
commit 40b137b549
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
4 changed files with 310 additions and 318 deletions

View File

@ -32,6 +32,11 @@ export interface ReducerStateBackup {
payto: string; payto: string;
provider: string; provider: string;
}[]; }[];
core_secret?: {
mime: string;
value: string;
};
} }
export interface AuthMethod { export interface AuthMethod {

View File

@ -45,43 +45,39 @@ function withProcessLabel(reducer: AnastasisReducerApi, text: string): string {
function ContinentSelection(props: CommonReducerProps) { function ContinentSelection(props: CommonReducerProps) {
const { reducer, reducerState } = props; const { reducer, reducerState } = props;
const sel = (x: string) =>
reducer.transition("select_continent", { continent: x });
return ( return (
<AnastasisClientFrame <AnastasisClientFrame
hideNext hideNext
title={withProcessLabel(reducer, "Select Continent")} title={withProcessLabel(reducer, "Select Continent")}
> >
{reducerState.continents.map((x: any) => { {reducerState.continents.map((x: any) => (
const sel = (x: string) => <button onClick={() => sel(x.name)} key={x.name}>
reducer.transition("select_continent", { continent: x }); {x.name}
return ( </button>
<button onClick={() => sel(x.name)} key={x.name}> ))}
{x.name}
</button>
);
})}
</AnastasisClientFrame> </AnastasisClientFrame>
); );
} }
function CountrySelection(props: CommonReducerProps) { function CountrySelection(props: CommonReducerProps) {
const { reducer, reducerState } = props; const { reducer, reducerState } = props;
const sel = (x: any) =>
reducer.transition("select_country", {
country_code: x.code,
currencies: [x.currency],
});
return ( return (
<AnastasisClientFrame <AnastasisClientFrame
hideNext hideNext
title={withProcessLabel(reducer, "Select Country")} title={withProcessLabel(reducer, "Select Country")}
> >
{reducerState.countries.map((x: any) => { {reducerState.countries.map((x: any) => (
const sel = (x: any) => <button onClick={() => sel(x)} key={x.name}>
reducer.transition("select_country", { {x.name} ({x.currency})
country_code: x.code, </button>
currencies: [x.currency], ))}
});
return (
<button onClick={() => sel(x)} key={x.name}>
{x.name} ({x.currency})
</button>
);
})}
</AnastasisClientFrame> </AnastasisClientFrame>
); );
} }
@ -106,21 +102,85 @@ function SolveQuestionEntry(props: SolveEntryProps) {
> >
<p>Feedback: {JSON.stringify(feedback)}</p> <p>Feedback: {JSON.stringify(feedback)}</p>
<p>Question: {challenge.instructions}</p> <p>Question: {challenge.instructions}</p>
<label> <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} />
<input </AnastasisClientFrame>
value={answer} );
onChange={(e) => setAnswer((e.target as HTMLInputElement).value)} }
type="test"
/> function SolveSmsEntry(props: SolveEntryProps) {
</label> 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> </AnastasisClientFrame>
); );
} }
function SecretEditor(props: BackupReducerProps) { function SecretEditor(props: BackupReducerProps) {
const { reducer } = props; const { reducer } = props;
const [secretName, setSecretName] = useState(""); const [secretName, setSecretName] = useState(
const [secretValue, setSecretValue] = useState(""); props.backupState.secret_name ?? "",
);
const [secretValue, setSecretValue] = useState(
props.backupState.core_secret?.value ?? "" ?? "",
);
const secretNext = () => { const secretNext = () => {
reducer.runTransaction(async (tx) => { reducer.runTransaction(async (tx) => {
await tx.transition("enter_secret_name", { await tx.transition("enter_secret_name", {
@ -144,34 +204,17 @@ function SecretEditor(props: BackupReducerProps) {
onNext={() => secretNext()} onNext={() => secretNext()}
> >
<div> <div>
<label> <LabeledInput
Secret name:{" "} label="Secret Name:"
<input grabFocus
value={secretName} bind={[secretName, setSecretName]}
onChange={(e) => />
setSecretName((e.target as HTMLInputElement).value)
}
type="text"
/>
</label>
</div> </div>
<div> <div>
<label> <LabeledInput
Secret value:{" "} label="Secret Value:"
<input bind={[secretValue, setSecretValue]}
value={secretValue} />
onChange={(e) =>
setSecretValue((e.target as HTMLInputElement).value)
}
type="text"
/>
</label>
</div>
or:
<div>
<label>
File Upload: <input type="file" />
</label>
</div> </div>
</AnastasisClientFrame> </AnastasisClientFrame>
); );
@ -234,6 +277,7 @@ function SecretSelection(props: RecoveryReducerProps) {
const [otherVersion, setOtherVersion] = useState<number>( const [otherVersion, setOtherVersion] = useState<number>(
recoveryState.recovery_document?.version ?? 0, recoveryState.recovery_document?.version ?? 0,
); );
const recoveryDocument = recoveryState.recovery_document!;
const [otherProvider, setOtherProvider] = useState<string>(""); const [otherProvider, setOtherProvider] = useState<string>("");
function selectVersion(p: string, n: number) { function selectVersion(p: string, n: number) {
reducer.runTransaction(async (tx) => { reducer.runTransaction(async (tx) => {
@ -250,9 +294,11 @@ function SecretSelection(props: RecoveryReducerProps) {
<p>Select a different version of the secret</p> <p>Select a different version of the secret</p>
<select onChange={(e) => setOtherProvider((e.target as any).value)}> <select onChange={(e) => setOtherProvider((e.target as any).value)}>
{Object.keys(recoveryState.authentication_providers ?? {}).map( {Object.keys(recoveryState.authentication_providers ?? {}).map(
(x) => { (x) => (
return <option value={x}>{x}</option>; <option selected={x === recoveryDocument.provider_url} value={x}>
}, {x}
</option>
),
)} )}
</select> </select>
<div> <div>
@ -264,7 +310,7 @@ function SecretSelection(props: RecoveryReducerProps) {
type="number" type="number"
/> />
<button onClick={() => selectVersion(otherProvider, otherVersion)}> <button onClick={() => selectVersion(otherProvider, otherVersion)}>
Select Use this version
</button> </button>
</div> </div>
<div> <div>
@ -280,9 +326,9 @@ function SecretSelection(props: RecoveryReducerProps) {
} }
return ( return (
<AnastasisClientFrame title="Recovery: Select secret"> <AnastasisClientFrame title="Recovery: Select secret">
<p>Provider: {recoveryState.recovery_document!.provider_url}</p> <p>Provider: {recoveryDocument.provider_url}</p>
<p>Secret version: {recoveryState.recovery_document!.version}</p> <p>Secret version: {recoveryDocument.version}</p>
<p>Secret name: {recoveryState.recovery_document!.version}</p> <p>Secret name: {recoveryDocument.version}</p>
<button onClick={() => setSelectingVersion(true)}> <button onClick={() => setSelectingVersion(true)}>
Select different secret Select different secret
</button> </button>
@ -305,37 +351,99 @@ interface AnastasisClientFrameProps {
} }
function AnastasisClientFrame(props: 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 ( return (
<WithReducer.Consumer> <div class={style.home} onKeyPress={(e) => handleKeyPress(e)}>
{(reducer) => { <button onClick={() => reducer.reset()}>Reset session</button>
if (!reducer) { <h1>{props.title}</h1>
return <p>Fatal: Reducer must be in context.</p>; <ErrorBanner reducer={reducer} />
} {props.children}
const next = () => { {!props.hideNav ? (
if (props.onNext) { <div>
props.onNext(); <button onClick={() => reducer.back()}>Back</button>
} else { {!props.hideNext ? (
reducer.transition("next", {}); <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 ( return (
<div class={style.home}> <div>
<button onClick={() => reducer.reset()}>Reset session</button> <h3>Policy #{i + 1}</h3>
<h1>{props.title}</h1> {x.map((x) => {
<ErrorBanner reducer={reducer} /> const ch = challenges[x.uuid];
{props.children} const feedback = recoveryState.challenge_feedback?.[x.uuid];
{!props.hideNav ? ( return (
<div> <div
<button onClick={() => reducer.back()}>Back</button> style={{
{!props.hideNext ? ( borderLeft: "2px solid gray",
<button onClick={() => next()}>Next</button> paddingLeft: "0.5em",
) : null} borderRadius: "0.5em",
</div> marginTop: "0.5em",
) : null} 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> </div>
); );
}} })}
</WithReducer.Consumer> </AnastasisClientFrame>
); );
} }
@ -472,51 +580,7 @@ const AnastasisClientImpl: FunctionalComponent = () => {
} }
if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) { if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) {
const policies = reducerState.recovery_information!.policies; return <ChallengeOverview reducer={reducer} recoveryState={reducerState} />;
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>
);
} }
if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) { if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) {
@ -530,22 +594,21 @@ const AnastasisClientImpl: FunctionalComponent = () => {
challenges[ch.uuid] = ch; challenges[ch.uuid] = ch;
} }
const selectedChallenge = challenges[selectedUuid]; const selectedChallenge = challenges[selectedUuid];
if (selectedChallenge.type === "question") { const dialogMap: Record<string, (p: SolveEntryProps) => h.JSX.Element> = {
return ( question: SolveQuestionEntry,
<SolveQuestionEntry sms: SolveSmsEntry,
challenge={selectedChallenge} email: SolveEmailEntry,
reducer={reducer} post: SolvePostEntry,
feedback={challengeFeedback[selectedUuid]} };
/> const SolveDialog =
); dialogMap[selectedChallenge.type] ?? SolveUnsupportedEntry;
} else { return (
return ( <SolveDialog
<AnastasisClientFrame hideNext title="Recovery: Solve challenge"> challenge={selectedChallenge}
<p>{JSON.stringify(selectedChallenge)}</p> reducer={reducer}
<p>Challenge not supported.</p> feedback={challengeFeedback[selectedUuid]}
</AnastasisClientFrame> />
); );
}
} }
if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) {
@ -620,6 +683,14 @@ function AuthMethodSmsSetup(props: AuthMethodSetupProps) {
function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { function AuthMethodQuestionSetup(props: AuthMethodSetupProps) {
const [questionText, setQuestionText] = useState(""); const [questionText, setQuestionText] = useState("");
const [answerText, setAnswerText] = useState(""); const [answerText, setAnswerText] = useState("");
const addQuestionAuth = () =>
props.addAuthMethod({
authentication_method: {
type: "question",
instructions: questionText,
challenge: encodeCrock(stringToBytes(answerText)),
},
});
return ( return (
<AnastasisClientFrame hideNav title="Add Security Question"> <AnastasisClientFrame hideNav title="Add Security Question">
<div> <div>
@ -630,44 +701,18 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) {
here. here.
</p> </p>
<div> <div>
<label> <LabeledInput
Security question:{" "} label="Security question"
<input grabFocus
value={questionText} bind={[questionText, setQuestionText]}
style={{ display: "block" }} />
autoFocus
onChange={(e) => setQuestionText((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<label> <LabeledInput label="Answer" bind={[answerText, setAnswerText]} />
Answer:{" "}
<input
value={answerText}
style={{ display: "block" }}
autoFocus
onChange={(e) => setAnswerText((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<button onClick={() => props.cancel()}>Cancel</button> <button onClick={() => props.cancel()}>Cancel</button>
<button <button onClick={() => addQuestionAuth()}>Add</button>
onClick={() =>
props.addAuthMethod({
authentication_method: {
type: "question",
instructions: questionText,
challenge: encodeCrock(stringToBytes(answerText)),
},
})
}
>
Add
</button>
</div> </div>
</div> </div>
</AnastasisClientFrame> </AnastasisClientFrame>
@ -684,16 +729,11 @@ function AuthMethodEmailSetup(props: AuthMethodSetupProps) {
email. email.
</p> </p>
<div> <div>
<label> <LabeledInput
Email address:{" "} label="Email address"
<input grabFocus
style={{ display: "block" }} bind={[email, setEmail]}
value={email} />
autoFocus
onChange={(e) => setEmail((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<button onClick={() => props.cancel()}>Cancel</button> <button onClick={() => props.cancel()}>Cancel</button>
@ -723,24 +763,20 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) {
const [country, setCountry] = useState(""); const [country, setCountry] = useState("");
const addPostAuth = () => { const addPostAuth = () => {
() => const challengeJson = {
props.addAuthMethod({ full_name: fullName,
authentication_method: { street,
type: "email", city,
instructions: `Letter to address in postal code ${postcode}`, postcode,
challenge: encodeCrock( country,
stringToBytes( };
canonicalJson({ props.addAuthMethod({
full_name: fullName, authentication_method: {
street, type: "email",
city, instructions: `Letter to address in postal code ${postcode}`,
postcode, challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))),
country, },
}), });
),
),
},
});
}; };
return ( return (
@ -753,59 +789,23 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) {
code that you will receive in a letter to that address. code that you will receive in a letter to that address.
</p> </p>
<div> <div>
<label> <LabeledInput
Full Name grabFocus
<input label="Full Name"
value={fullName} bind={[fullName, setFullName]}
autoFocus />
onChange={(e) => setFullName((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<label> <LabeledInput label="Street" bind={[street, setStreet]} />
Street
<input
value={street}
autoFocus
onChange={(e) => setStreet((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<label> <LabeledInput label="City" bind={[city, setCity]} />
City
<input
value={city}
autoFocus
onChange={(e) => setCity((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<label> <LabeledInput label="Postal Code" bind={[postcode, setPostcode]} />
Postal Code
<input
value={postcode}
autoFocus
onChange={(e) => setPostcode((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<label> <LabeledInput label="Country" bind={[country, setCountry]} />
Country
<input
value={country}
autoFocus
onChange={(e) => setCountry((e.target as any).value)}
type="text"
/>
</label>
</div> </div>
<div> <div>
<button onClick={() => props.cancel()}>Cancel</button> <button onClick={() => props.cancel()}>Cancel</button>
@ -851,48 +851,23 @@ function AuthenticationEditor(props: AuthenticationEditorProps) {
reducer.transition("add_authentication", args); reducer.transition("add_authentication", args);
setSelectedMethod(undefined); setSelectedMethod(undefined);
}; };
switch (selectedMethod) { const methodMap: Record<
case "sms": string,
return ( (props: AuthMethodSetupProps) => h.JSX.Element
<AuthMethodSmsSetup > = {
cancel={cancel} sms: AuthMethodSmsSetup,
addAuthMethod={addMethod} question: AuthMethodQuestionSetup,
method="sms" email: AuthMethodEmailSetup,
/> post: AuthMethodPostSetup,
); };
case "question": const AuthSetup = methodMap[selectedMethod] ?? AuthMethodNotImplemented;
return ( return (
<AuthMethodQuestionSetup <AuthSetup
cancel={cancel} cancel={cancel}
addAuthMethod={addMethod} addAuthMethod={addMethod}
method="question" method={selectedMethod}
/> />
); );
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}
/>
);
}
} }
function MethodButton(props: { method: string; label: String }) { function MethodButton(props: { method: string; label: String }) {
return ( 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 { export interface AttributeEntryFieldProps {
isFirst: boolean; isFirst: boolean;
value: string; value: string;
@ -988,13 +989,10 @@ export interface AttributeEntryFieldProps {
function AttributeEntryField(props: AttributeEntryFieldProps) { function AttributeEntryField(props: AttributeEntryFieldProps) {
return ( return (
<div> <div>
<label>{props.spec.label}:</label> <LabeledInput
<input grabFocus={props.isFirst}
style={{ display: "block" }} label={props.spec.label}
autoFocus={props.isFirst} bind={[props.value, props.setValue]}
type="text"
value={props.value}
onChange={(e) => props.setValue((e as any).target.value)}
/> />
</div> </div>
); );

View File

@ -2,6 +2,7 @@
padding: 1em 1em; padding: 1em 1em;
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
max-width: 40em;
} }
.home div { .home div {

View File

@ -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);
});
});