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 ( -
-
- - - - - - -
- ); + return ( +
+ +
+ ); }; 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 ( -
-

Preact App

- -
- ); -}; - -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 { - reducer: AnastasisReducerApi; - reducerState: ReducerStateBackup | ReducerStateRecovery; -} +const WithReducer = createContext(undefined); function isBackup(reducer: AnastasisReducerApi) { return !!reducer.currentReducerState?.backup_state; } -function ContinentSelection(props: ContinentSelectionProps) { - const { reducer, reducerState } = props; - return ( -
-

{isBackup(reducer) ? "Backup" : "Recovery"}: Select Continent

- -
- {reducerState.continents.map((x: any) => { - const sel = (x: string) => - reducer.transition("select_continent", { continent: x }); - return ( - - ); - })} -
-
- -
-
- ); -} - -interface CountrySelectionProps { +interface CommonReducerProps { reducer: AnastasisReducerApi; reducerState: ReducerStateBackup | ReducerStateRecovery; } -function CountrySelection(props: CountrySelectionProps) { +function withProcessLabel(reducer: AnastasisReducerApi, text: string): string { + if (isBackup(reducer)) { + return "Backup: " + text; + } + return "Recovery: " + text; +} + +function ContinentSelection(props: CommonReducerProps) { const { reducer, reducerState } = props; return ( -
-

Backup: Select Country

- -
- {reducerState.countries.map((x: any) => { - const sel = (x: any) => - reducer.transition("select_country", { - country_code: x.code, - currencies: [x.currency], - }); - return ( - - ); - })} -
-
- -
-
+ + {reducerState.continents.map((x: any) => { + const sel = (x: string) => + reducer.transition("select_continent", { continent: x }); + return ( + + ); + })} + ); } -const Home: FunctionalComponent = () => { +function CountrySelection(props: CommonReducerProps) { + const { reducer, reducerState } = props; + return ( + + {reducerState.countries.map((x: any) => { + const sel = (x: any) => + reducer.transition("select_country", { + country_code: x.code, + currencies: [x.currency], + }); + return ( + + ); + })} + + ); +} + +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 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 ( + secretNext()} + > +
+ +
+
+ +
+ or: +
+ +
+
+ ); +} + +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 [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: {recoveryState.recovery_document!.provider_url}

+

Secret version: {recoveryState.recovery_document!.version}

+

Secret name: {recoveryState.recovery_document!.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) { + return ( + + {(reducer) => { + if (!reducer) { + return

Fatal: Reducer must be in context.

; + } + const next = () => { + if (props.onNext) { + props.onNext(); + } else { + reducer.transition("next", {}); + } + }; + return ( +
+ +

{props.title}

+ + {props.children} + {!props.hideNav ? ( +
+ + {!props.hideNext ? ( + + ) : null} +
+ ) : null} +
+ ); + }} +
+ ); +} + +const AnastasisClient: FunctionalComponent = () => { const reducer = useAnastasisReducer(); + return ( + + + + ); +}; + +const AnastasisClientImpl: FunctionalComponent = () => { + const reducer = useContext(WithReducer)!; const reducerState = reducer.currentReducerState; if (!reducerState) { return ( -
-

Home

-

- - -

-
+ + + + ); } console.log("state", reducer.currentReducerState); @@ -122,109 +386,17 @@ const Home: FunctionalComponent = () => { ); } - if (reducerState.backup_state === BackupStates.PoliciesReviewing) { - const backupState: ReducerStateBackup = reducerState; - const authMethods = backupState.authentication_methods!; - return ( -
-

Backup: Review Recovery Policies

- -
- {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} -
  • - ); - })} -
-
- -
-
- ); - })} -
-
- - -
-
- ); + return ; } - 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 ( -
-

Backup: Provide secret

- -
- -
-
- -
- or: -
- -
-
- - -
-
- ); + return ; } if (reducerState.backup_state === BackupStates.BackupFinished) { const backupState: ReducerStateBackup = reducerState; return ( -
-

Backup finished

+

Your backup of secret "{backupState.secret_name ?? "??"}" was successful. @@ -240,10 +412,8 @@ const Home: FunctionalComponent = () => { ); })} - -

+ + ); } @@ -251,8 +421,10 @@ const Home: FunctionalComponent = () => { const backupState: ReducerStateBackup = reducerState; const payments = backupState.payments ?? []; return ( -
-

Backup: Authentication Storage Payments

+

Some of the providers require a payment to store the encrypted authentication information. @@ -262,22 +434,19 @@ const Home: FunctionalComponent = () => { return

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

    Backup: Recovery Document Payments

    +

    Some of the providers require a payment to store the encrypted recovery document. @@ -291,23 +460,111 @@ const Home: FunctionalComponent = () => { ); })} -

    - - -
    -
    + + + ); + } + + if (reducerState.recovery_state === RecoveryStates.SecretSelecting) { + return ; + } + + 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 ( + +

    Policies

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

    Policy #{i + 1}

    + {x.map((x) => { + const ch = challenges[x.uuid]; + return ( +
    + {ch.type} ({ch.instructions}) + +
    + ); + })} +
    + ); + })} +
    + ); + } + + 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 ( + + ); + } else { + return ( + +

    {JSON.stringify(selectedChallenge)}

    +

    Challenge not supported.

    +
    + ); + } + } + + if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { + return ( + +

    Recovery Finished

    +

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

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

    Home

    +

    Bug: Unknown state.

    -
    + ); }; @@ -328,9 +585,12 @@ function AuthMethodSmsSetup(props: AuthMethodSetupProps) { }, }); }; + //const inputRef = useRef(null); + // useLayoutEffect(() => { + // inputRef.current?.focus(); + // }, []); return ( -
    -

    Add {props.method} authentication

    +

    For SMS authentication, you need to provide a mobile number. When @@ -338,9 +598,11 @@ function AuthMethodSmsSetup(props: AuthMethodSetupProps) { receive via SMS.

    - + ); } @@ -359,8 +621,7 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { const [questionText, setQuestionText] = useState(""); const [answerText, setAnswerText] = useState(""); return ( -
    -

    Add {props.method} authentication

    +

    For security question authentication, you need to provide a question @@ -370,9 +631,10 @@ function AuthMethodQuestionSetup(props: AuthMethodSetupProps) {

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

    Add {props.method} authentication

    + +

    + 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. +

    -

    - 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. -

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

    Add {props.method} authentication

    @@ -526,29 +809,7 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) {
    - +
    @@ -557,15 +818,10 @@ function AuthMethodPostSetup(props: AuthMethodSetupProps) { function AuthMethodNotImplemented(props: AuthMethodSetupProps) { return ( -
    -

    Add {props.method} authentication

    -
    -

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

    - -
    -
    + +

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

    + +
    ); } @@ -583,8 +839,10 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { const authAvailableSet = new Set(); 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 ( -
    -

    Backup: Configure Authentication Methods

    - -

    Add authentication method

    +
    @@ -686,11 +941,7 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { ) : (

    No authentication methods configured yet.

    )} -
    - - -
    -
    +
    ); } @@ -701,36 +952,29 @@ export interface AttributeEntryProps { function AttributeEntry(props: AttributeEntryProps) { const { reducer, reducerState: backupState } = props; - const [attrs, setAttrs] = useState>({}); + const [attrs, setAttrs] = useState>( + props.reducerState.identity_attributes ?? {}, + ); return ( -
    -

    Backup: Enter Basic User Attributes

    - -
    - {backupState.required_attributes.map((x: any, i: number) => { - return ( - setAttrs({ ...attrs, [x.name]: v })} - spec={x} - value={attrs[x.name]} - /> - ); - })} -
    -
    - - -
    -
    + + 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]} + /> + ); + })} + ); } @@ -744,8 +988,9 @@ export interface AttributeEntryFieldProps { function AttributeEntryField(props: AttributeEntryFieldProps) { return (
    - +