diff options
6 files changed, 598 insertions, 383 deletions
diff --git a/packages/anastasis-webui/src/components/app.tsx b/packages/anastasis-webui/src/components/app.tsx index 5abb12a3d..45c9035f0 100644 --- a/packages/anastasis-webui/src/components/app.tsx +++ b/packages/anastasis-webui/src/components/app.tsx @@ -1,23 +1,13 @@ -import { FunctionalComponent, h } from 'preact'; -import { Route, Router } from 'preact-router'; +import { FunctionalComponent, h } from "preact"; -import Home from '../routes/home'; -import Profile from '../routes/profile'; -import NotFoundPage from '../routes/notfound'; -import Header from './header'; +import AnastasisClient from "../routes/home"; const App: FunctionalComponent = () => { - return ( - <div id="preact_root"> - <Header /> - <Router> - <Route path="/" component={Home} /> - <Route path="/profile/" component={Profile} user="me" /> - <Route path="/profile/:user" component={Profile} /> - <NotFoundPage default /> - </Router> - </div> - ); + return ( + <div id="preact_root"> + <AnastasisClient /> + </div> + ); }; export default App; diff --git a/packages/anastasis-webui/src/components/header/index.tsx b/packages/anastasis-webui/src/components/header/index.tsx deleted file mode 100644 index f2b6fe8ad..000000000 --- a/packages/anastasis-webui/src/components/header/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FunctionalComponent, h } from 'preact'; -import { Link } from 'preact-router/match'; -import style from './style.css'; - -const Header: FunctionalComponent = () => { - return ( - <header class={style.header}> - <h1>Preact App</h1> - <nav> - <Link activeClassName={style.active} href="/"> - Home - </Link> - <Link activeClassName={style.active} href="/profile"> - Me - </Link> - <Link activeClassName={style.active} href="/profile/john"> - John - </Link> - </nav> - </header> - ); -}; - -export default Header; diff --git a/packages/anastasis-webui/src/components/header/style.css b/packages/anastasis-webui/src/components/header/style.css deleted file mode 100644 index f08fda702..000000000 --- a/packages/anastasis-webui/src/components/header/style.css +++ /dev/null @@ -1,48 +0,0 @@ -.header { - position: fixed; - left: 0; - top: 0; - width: 100%; - height: 56px; - padding: 0; - background: #673AB7; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); - z-index: 50; -} - -.header h1 { - float: left; - margin: 0; - padding: 0 15px; - font-size: 24px; - line-height: 56px; - font-weight: 400; - color: #FFF; -} - -.header nav { - float: right; - font-size: 100%; -} - -.header nav a { - display: inline-block; - height: 56px; - line-height: 56px; - padding: 0 15px; - min-width: 50px; - text-align: center; - background: rgba(255,255,255,0); - text-decoration: none; - color: #FFF; - will-change: background-color; -} - -.header nav a:hover, -.header nav a:active { - background: rgba(0,0,0,0.2); -} - -.header nav a.active { - background: rgba(0,0,0,0.4); -} diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts index 6ca0ccfae..efa0592dd 100644 --- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts +++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts @@ -11,6 +11,7 @@ export interface ReducerStateBackup { code: undefined; continents: any; countries: any; + identity_attributes?: { [n: string]: string }; authentication_providers: any; authentication_methods?: AuthMethod[]; required_attributes: any; @@ -39,14 +40,60 @@ export interface AuthMethod { challenge: string; } +export interface ChallengeInfo { + cost: string; + instructions: string; + type: string; + uuid: string; +} + export interface ReducerStateRecovery { backup_state: undefined; recovery_state: RecoveryStates; code: undefined; + identity_attributes?: { [n: string]: string }; + continents: any; countries: any; required_attributes: any; + + recovery_information?: { + challenges: ChallengeInfo[]; + policies: { + /** + * UUID of the associated challenge. + */ + uuid: string; + }[][]; + }; + + recovery_document?: { + secret_name: string; + provider_url: string; + version: number; + }; + + selected_challenge_uuid?: string; + + challenge_feedback?: { [uuid: string]: ChallengeFeedback }; + + core_secret?: { + mime: string; + value: string; + }; + + authentication_providers?: { + [url: string]: { + business_name: string; + }; + }; + + recovery_error: any; +} + +export interface ChallengeFeedback { + state: string; } export interface ReducerStateError { @@ -76,6 +123,11 @@ export enum RecoveryStates { ContinentSelecting = "CONTINENT_SELECTING", CountrySelecting = "COUNTRY_SELECTING", UserAttributesCollecting = "USER_ATTRIBUTES_COLLECTING", + SecretSelecting = "SECRET_SELECTING", + ChallengeSelecting = "CHALLENGE_SELECTING", + ChallengePaying = "CHALLENGE_PAYING", + ChallengeSolving = "CHALLENGE_SOLVING", + RecoveryFinished = "RECOVERY_FINISHED", } const reducerBaseUrl = "http://localhost:5000/"; diff --git a/packages/anastasis-webui/src/routes/home/index.tsx b/packages/anastasis-webui/src/routes/home/index.tsx index f0b630851..99f8febb4 100644 --- a/packages/anastasis-webui/src/routes/home/index.tsx +++ b/packages/anastasis-webui/src/routes/home/index.tsx @@ -1,14 +1,23 @@ import { + bytesToString, canonicalJson, + decodeCrock, encodeCrock, stringToBytes, } from "@gnu-taler/taler-util"; -import { FunctionalComponent, h } from "preact"; -import { useState } from "preact/hooks"; +import { + FunctionalComponent, + ComponentChildren, + h, + createContext, +} from "preact"; +import { useState, useContext, useRef, useLayoutEffect } from "preact/hooks"; import { AnastasisReducerApi, AuthMethod, BackupStates, + ChallengeFeedback, + ChallengeInfo, RecoveryStates, ReducerStateBackup, ReducerStateRecovery, @@ -16,85 +25,340 @@ import { } from "../../hooks/use-anastasis-reducer"; import style from "./style.css"; -interface ContinentSelectionProps { +const WithReducer = createContext<AnastasisReducerApi | undefined>(undefined); + +function isBackup(reducer: AnastasisReducerApi) { + return !!reducer.currentReducerState?.backup_state; +} + +interface CommonReducerProps { reducer: AnastasisReducerApi; reducerState: ReducerStateBackup | ReducerStateRecovery; } -function isBackup(reducer: AnastasisReducerApi) { - return !!reducer.currentReducerState?.backup_state; +function withProcessLabel(reducer: AnastasisReducerApi, text: string): string { + if (isBackup(reducer)) { + return "Backup: " + text; + } + return "Recovery: " + text; } -function ContinentSelection(props: ContinentSelectionProps) { +function ContinentSelection(props: CommonReducerProps) { const { reducer, reducerState } = props; return ( - <div class={style.home}> - <h1>{isBackup(reducer) ? "Backup" : "Recovery"}: Select Continent</h1> - <ErrorBanner reducer={reducer} /> - <div> - {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> - ); - })} - </div> - <div> - <button onClick={() => reducer.back()}>Back</button> - </div> - </div> + <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> + ); + })} + </AnastasisClientFrame> + ); +} + +function CountrySelection(props: CommonReducerProps) { + const { reducer, reducerState } = props; + 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> + ); + })} + </AnastasisClientFrame> ); } -interface CountrySelectionProps { +interface SolveEntryProps { reducer: AnastasisReducerApi; - reducerState: ReducerStateBackup | ReducerStateRecovery; + challenge: ChallengeInfo; + feedback?: ChallengeFeedback; } -function CountrySelection(props: CountrySelectionProps) { - const { reducer, reducerState } = props; +function SolveQuestionEntry(props: SolveEntryProps) { + const [answer, setAnswer] = useState(""); + const { reducer, challenge, feedback } = props; + const next = () => + reducer.transition("solve_challenge", { + answer, + }); return ( - <div class={style.home}> - <h1>Backup: Select Country</h1> - <ErrorBanner reducer={reducer} /> + <AnastasisClientFrame + title="Recovery: Solve challenge" + onNext={() => next()} + > + <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> + </AnastasisClientFrame> + ); +} + +function SecretEditor(props: BackupReducerProps) { + const { reducer } = props; + const [secretName, setSecretName] = useState(""); + const [secretValue, setSecretValue] = useState(""); + 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 ( + <AnastasisClientFrame + title="Backup: Provide secret" + onNext={() => secretNext()} + > <div> - {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> - ); - })} + <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> </div> + or: <div> - <button onClick={() => reducer.back()}>Back</button> + <label> + File Upload: <input type="file" /> + </label> </div> - </div> + </AnastasisClientFrame> ); } -const Home: FunctionalComponent = () => { +export interface BackupReducerProps { + reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; +} + +function ReviewPolicies(props: BackupReducerProps) { + const { reducer, backupState } = props; + const authMethods = backupState.authentication_methods!; + return ( + <AnastasisClientFrame title="Backup: Review Recovery Policies"> + {backupState.policies?.map((p, i) => { + const policyName = p.methods + .map((x) => authMethods[x.authentication_method].type) + .join(" + "); + return ( + <div class={style.policy}> + <h3> + Policy #{i + 1}: {policyName} + </h3> + Required Authentications: + <ul> + {p.methods.map((x) => { + const m = authMethods[x.authentication_method]; + return ( + <li> + {m.type} ({m.instructions}) at provider {x.provider} + </li> + ); + })} + </ul> + <div> + <button + onClick={() => + reducer.transition("delete_policy", { policy_index: i }) + } + > + Delete Policy + </button> + </div> + </div> + ); + })} + </AnastasisClientFrame> + ); +} + +export interface RecoveryReducerProps { + reducer: AnastasisReducerApi; + recoveryState: ReducerStateRecovery; +} + +function SecretSelection(props: RecoveryReducerProps) { + const { reducer, recoveryState } = props; + const [selectingVersion, setSelectingVersion] = useState<boolean>(false); + const [otherVersion, setOtherVersion] = useState<number>( + recoveryState.recovery_document?.version ?? 0, + ); + const [otherProvider, setOtherProvider] = useState<string>(""); + 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 ( + <AnastasisClientFrame hideNav title="Recovery: Select secret"> + <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>; + }, + )} + </select> + <div> + <input + value={otherVersion} + onChange={(e) => + setOtherVersion(Number((e.target as HTMLInputElement).value)) + } + type="number" + /> + <button onClick={() => selectVersion(otherProvider, otherVersion)}> + Select + </button> + </div> + <div> + <button onClick={() => selectVersion(otherProvider, 0)}> + Use latest version + </button> + </div> + <div> + <button onClick={() => setSelectingVersion(false)}>Cancel</button> + </div> + </AnastasisClientFrame> + ); + } + 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> + <button onClick={() => setSelectingVersion(true)}> + Select different secret + </button> + </AnastasisClientFrame> + ); +} + +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) { + 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", {}); + } + }; + 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> + ); + }} + </WithReducer.Consumer> + ); +} + +const AnastasisClient: FunctionalComponent = () => { const reducer = useAnastasisReducer(); + return ( + <WithReducer.Provider value={reducer}> + <AnastasisClientImpl /> + </WithReducer.Provider> + ); +}; + +const AnastasisClientImpl: FunctionalComponent = () => { + const reducer = useContext(WithReducer)!; const reducerState = reducer.currentReducerState; if (!reducerState) { return ( - <div class={style.home}> - <h1>Home</h1> - <p> - <button autoFocus onClick={() => reducer.startBackup()}> - Backup - </button> - <button onClick={() => reducer.startRecover()}>Recover</button> - </p> - </div> + <AnastasisClientFrame hideNav title="Home"> + <button autoFocus onClick={() => reducer.startBackup()}> + Backup + </button> + <button onClick={() => reducer.startRecover()}>Recover</button> + </AnastasisClientFrame> ); } console.log("state", reducer.currentReducerState); @@ -122,109 +386,17 @@ const Home: FunctionalComponent = () => { <AuthenticationEditor backupState={reducerState} reducer={reducer} /> ); } - if (reducerState.backup_state === BackupStates.PoliciesReviewing) { - const backupState: ReducerStateBackup = reducerState; - const authMethods = backupState.authentication_methods!; - return ( - <div class={style.home}> - <h1>Backup: Review Recovery Policies</h1> - <ErrorBanner reducer={reducer} /> - <div> - {backupState.policies?.map((p, i) => { - const policyName = p.methods - .map((x) => authMethods[x.authentication_method].type) - .join(" + "); - return ( - <div class={style.policy}> - <h3> - Policy #{i + 1}: {policyName} - </h3> - Required Authentications: - <ul> - {p.methods.map((x) => { - const m = authMethods[x.authentication_method]; - return ( - <li> - {m.type} ({m.instructions}) at provider {x.provider} - </li> - ); - })} - </ul> - <div> - <button - onClick={() => - reducer.transition("delete_policy", { policy_index: i }) - } - > - Delete Policy - </button> - </div> - </div> - ); - })} - </div> - <div> - <button onClick={() => reducer.back()}>Back</button> - <button onClick={() => reducer.transition("next", {})}>Next</button> - </div> - </div> - ); + return <ReviewPolicies reducer={reducer} backupState={reducerState} />; } - if (reducerState.backup_state === BackupStates.SecretEditing) { - const [secretName, setSecretName] = useState(""); - const [secretValue, setSecretValue] = useState(""); - const secretNext = () => { - reducer.runTransaction(async (tx) => { - await tx.transition("enter_secret_name", { - name: secretName, - }); - await tx.transition("enter_secret", { - secret: { - value: "EDJP6WK5EG50", - mime: "text/plain", - }, - expiration: { - t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5, - }, - }); - await tx.transition("next", {}); - }); - }; - return ( - <div class={style.home}> - <h1>Backup: Provide secret</h1> - <ErrorBanner reducer={reducer} /> - <div> - <label> - Secret name: <input type="text" /> - </label> - </div> - <div> - <label> - Secret value: <input type="text" /> - </label> - </div> - or: - <div> - <label> - File Upload: <input type="file" /> - </label> - </div> - <div> - <button onClick={() => reducer.back()}>Back</button> - <button onClick={() => secretNext()}>Next</button> - </div> - </div> - ); + return <SecretEditor reducer={reducer} backupState={reducerState} />; } if (reducerState.backup_state === BackupStates.BackupFinished) { const backupState: ReducerStateBackup = reducerState; return ( - <div class={style.home}> - <h1>Backup finished</h1> + <AnastasisClientFrame hideNext title="Backup finished"> <p> Your backup of secret "{backupState.secret_name ?? "??"}" was successful. @@ -240,10 +412,8 @@ const Home: FunctionalComponent = () => { ); })} </ul> - <button onClick={() => reducer.reset()}> - Start a new backup/recovery - </button> - </div> + <button onClick={() => reducer.reset()}>Back to start</button> + </AnastasisClientFrame> ); } @@ -251,8 +421,10 @@ const Home: FunctionalComponent = () => { const backupState: ReducerStateBackup = reducerState; const payments = backupState.payments ?? []; return ( - <div class={style.home}> - <h1>Backup: Authentication Storage Payments</h1> + <AnastasisClientFrame + hideNext + title="Backup: Authentication Storage Payments" + > <p> Some of the providers require a payment to store the encrypted authentication information. @@ -262,22 +434,19 @@ const Home: FunctionalComponent = () => { return <li>{x}</li>; })} </ul> - <div> - <button onClick={() => reducer.back()}>Back</button> - <button onClick={() => reducer.transition("pay", {})}> - Check payment(s) - </button> - </div> - </div> + <button onClick={() => reducer.transition("pay", {})}> + Check payment status now + </button> + </AnastasisClientFrame> ); } if (reducerState.backup_state === BackupStates.PoliciesPaying) { const backupState: ReducerStateBackup = reducerState; const payments = backupState.policy_payment_requests ?? []; + return ( - <div class={style.home}> - <h1>Backup: Recovery Document Payments</h1> + <AnastasisClientFrame hideNext title="Backup: Recovery Document Payments"> <p> Some of the providers require a payment to store the encrypted recovery document. @@ -291,23 +460,111 @@ const Home: FunctionalComponent = () => { ); })} </ul> - <div> - <button onClick={() => reducer.back()}>Back</button> - <button onClick={() => reducer.transition("pay", {})}> - Check payment(s) - </button> - </div> - </div> + <button onClick={() => reducer.transition("pay", {})}> + Check payment status now + </button> + </AnastasisClientFrame> + ); + } + + if (reducerState.recovery_state === RecoveryStates.SecretSelecting) { + return <SecretSelection reducer={reducer} recoveryState={reducerState} />; + } + + 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> + ); + } + + 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]; + 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> + ); + } + } + + if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { + return ( + <AnastasisClientFrame title="Recovery Finished" hideNext> + <h1>Recovery Finished</h1> + <p> + Secret: {bytesToString(decodeCrock(reducerState.core_secret?.value!))} + </p> + </AnastasisClientFrame> ); } console.log("unknown state", reducer.currentReducerState); return ( - <div class={style.home}> - <h1>Home</h1> + <AnastasisClientFrame hideNav title="Bug"> <p>Bug: Unknown state.</p> <button onClick={() => reducer.reset()}>Reset</button> - </div> + </AnastasisClientFrame> ); }; @@ -328,9 +585,12 @@ function AuthMethodSmsSetup(props: AuthMethodSetupProps) { }, }); }; + //const inputRef = useRef<HTMLInputElement>(null); + // useLayoutEffect(() => { + // inputRef.current?.focus(); + // }, []); return ( - <div class={style.home}> - <h1>Add {props.method} authentication</h1> + <AnastasisClientFrame hideNav title="Add SMS authentication"> <div> <p> For SMS authentication, you need to provide a mobile number. When @@ -338,9 +598,11 @@ function AuthMethodSmsSetup(props: AuthMethodSetupProps) { receive via SMS. </p> <label> - Mobile number{" "} + Mobile number:{" "} <input value={mobileNumber} + //ref={inputRef} + style={{ display: "block" }} autoFocus onChange={(e) => setMobileNumber((e.target as any).value)} type="text" @@ -351,7 +613,7 @@ function AuthMethodSmsSetup(props: AuthMethodSetupProps) { <button onClick={() => addSmsAuth()}>Add</button> </div> </div> - </div> + </AnastasisClientFrame> ); } @@ -359,8 +621,7 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { const [questionText, setQuestionText] = useState(""); const [answerText, setAnswerText] = useState(""); return ( - <div class={style.home}> - <h1>Add {props.method} authentication</h1> + <AnastasisClientFrame hideNav title="Add Security Question"> <div> <p> For security question authentication, you need to provide a question @@ -370,9 +631,10 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { </p> <div> <label> - Security question + Security question:{" "} <input value={questionText} + style={{ display: "block" }} autoFocus onChange={(e) => setQuestionText((e.target as any).value)} type="text" @@ -381,9 +643,10 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { </div> <div> <label> - Answer + Answer:{" "} <input value={answerText} + style={{ display: "block" }} autoFocus onChange={(e) => setAnswerText((e.target as any).value)} type="text" @@ -407,50 +670,48 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { </button> </div> </div> - </div> + </AnastasisClientFrame> ); } function AuthMethodEmailSetup(props: AuthMethodSetupProps) { const [email, setEmail] = useState(""); return ( - <div class={style.home}> - <h1>Add {props.method} authentication</h1> + <AnastasisClientFrame hideNav title="Add email authentication"> + <p> + 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. + </p> <div> - <p> - For email authentication, you need to provid an email address. When - recovering your secret, you need to enter the code you will receive by - email. - </p> - <div> - <label> - Email address - <input - value={email} - autoFocus - onChange={(e) => setEmail((e.target as any).value)} - type="text" - /> - </label> - </div> - <div> - <button onClick={() => props.cancel()}>Cancel</button> - <button - onClick={() => - props.addAuthMethod({ - authentication_method: { - type: "email", - instructions: `Email to ${email}`, - challenge: encodeCrock(stringToBytes(email)), - }, - }) - } - > - Add - </button> - </div> + <label> + Email address:{" "} + <input + style={{ display: "block" }} + value={email} + autoFocus + onChange={(e) => setEmail((e.target as any).value)} + type="text" + /> + </label> </div> - </div> + <div> + <button onClick={() => props.cancel()}>Cancel</button> + <button + onClick={() => + props.addAuthMethod({ + authentication_method: { + type: "email", + instructions: `Email to ${email}`, + challenge: encodeCrock(stringToBytes(email)), + }, + }) + } + > + Add + </button> + </div> + </AnastasisClientFrame> ); } @@ -460,6 +721,28 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) { const [city, setCity] = useState(""); const [postcode, setPostcode] = useState(""); 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, + }), + ), + ), + }, + }); + }; + return ( <div class={style.home}> <h1>Add {props.method} authentication</h1> @@ -526,29 +809,7 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) { </div> <div> <button onClick={() => props.cancel()}>Cancel</button> - <button - onClick={() => - 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, - }), - ), - ), - }, - }) - } - > - Add - </button> + <button onClick={() => addPostAuth()}>Add</button> </div> </div> </div> @@ -557,15 +818,10 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) { function AuthMethodNotImplemented(props: AuthMethodSetupProps) { return ( - <div class={style.home}> - <h1>Add {props.method} authentication</h1> - <div> - <p> - This auth method is not implemented yet, please choose another one. - </p> - <button onClick={() => props.cancel()}>Cancel</button> - </div> - </div> + <AnastasisClientFrame hideNav title={`Add ${props.method} authentication`}> + <p>This auth method is not implemented yet, please choose another one.</p> + <button onClick={() => props.cancel()}>Cancel</button> + </AnastasisClientFrame> ); } @@ -583,8 +839,10 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { const authAvailableSet = new Set<string>(); for (const provKey of Object.keys(providers)) { const p = providers[provKey]; - for (const meth of p.methods) { - authAvailableSet.add(meth.type); + if (p.methods) { + for (const meth of p.methods) { + authAvailableSet.add(meth.type); + } } } if (selectedMethod) { @@ -653,10 +911,7 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { backupState.authentication_methods ?? []; const haveMethodsConfigured = configuredAuthMethods.length; return ( - <div class={style.home}> - <h1>Backup: Configure Authentication Methods</h1> - <ErrorBanner reducer={reducer} /> - <h2>Add authentication method</h2> + <AnastasisClientFrame title="Backup: Configure Authentication Methods"> <div> <MethodButton method="sms" label="SMS" /> <MethodButton method="email" label="Email" /> @@ -686,11 +941,7 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { ) : ( <p>No authentication methods configured yet.</p> )} - <div> - <button onClick={() => reducer.back()}>Back</button> - <button onClick={() => reducer.transition("next", {})}>Next</button> - </div> - </div> + </AnastasisClientFrame> ); } @@ -701,36 +952,29 @@ export interface AttributeEntryProps { function AttributeEntry(props: AttributeEntryProps) { const { reducer, reducerState: backupState } = props; - const [attrs, setAttrs] = useState<Record<string, string>>({}); + const [attrs, setAttrs] = useState<Record<string, string>>( + props.reducerState.identity_attributes ?? {}, + ); return ( - <div class={style.home}> - <h1>Backup: Enter Basic User Attributes</h1> - <ErrorBanner reducer={reducer} /> - <div> - {backupState.required_attributes.map((x: any, i: number) => { - return ( - <AttributeEntryField - isFirst={i == 0} - setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })} - spec={x} - value={attrs[x.name]} - /> - ); - })} - </div> - <div> - <button onClick={() => reducer.back()}>Back</button> - <button - onClick={() => - reducer.transition("enter_user_attributes", { - identity_attributes: attrs, - }) - } - > - Next - </button> - </div> - </div> + <AnastasisClientFrame + title={withProcessLabel(reducer, "Select Country")} + onNext={() => + reducer.transition("enter_user_attributes", { + identity_attributes: attrs, + }) + } + > + {backupState.required_attributes.map((x: any, i: number) => { + return ( + <AttributeEntryField + isFirst={i == 0} + setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })} + spec={x} + value={attrs[x.name]} + /> + ); + })} + </AnastasisClientFrame> ); } @@ -744,8 +988,9 @@ export interface AttributeEntryFieldProps { function AttributeEntryField(props: AttributeEntryFieldProps) { return ( <div> - <label>{props.spec.label}</label> + <label>{props.spec.label}:</label> <input + style={{ display: "block" }} autoFocus={props.isFirst} type="text" value={props.value} @@ -777,4 +1022,4 @@ function ErrorBanner(props: ErrorBannerProps) { return null; } -export default Home; +export default AnastasisClient; diff --git a/packages/anastasis-webui/src/routes/home/style.css b/packages/anastasis-webui/src/routes/home/style.css index c9f34e6c8..b94981f10 100644 --- a/packages/anastasis-webui/src/routes/home/style.css +++ b/packages/anastasis-webui/src/routes/home/style.css @@ -1,5 +1,5 @@ .home { - padding: 56px 20px; + padding: 1em 1em; min-height: 100%; width: 100%; } |