diff --git a/data-browser/src/components/CodeBlock.tsx b/data-browser/src/components/CodeBlock.tsx index 91c4e6884..481d8854d 100644 --- a/data-browser/src/components/CodeBlock.tsx +++ b/data-browser/src/components/CodeBlock.tsx @@ -7,9 +7,11 @@ import { Button } from './Button'; interface CodeBlockProps { content?: string; loading?: boolean; + wrapContent?: boolean; } -export function CodeBlock({ content, loading }: CodeBlockProps) { +/** Codeblock with copy feature */ +export function CodeBlock({ content, loading, wrapContent }: CodeBlockProps) { const [isCopied, setIsCopied] = useState(undefined); function copyToClipboard() { @@ -19,7 +21,7 @@ export function CodeBlock({ content, loading }: CodeBlockProps) { } return ( - + {loading ? ( 'loading...' ) : ( @@ -46,7 +48,11 @@ export function CodeBlock({ content, loading }: CodeBlockProps) { ); } -export const CodeBlockStyled = styled.pre` +interface Props { + wrapContent?: boolean; +} + +export const CodeBlockStyled = styled.pre` position: relative; background-color: ${p => p.theme.colors.bg1}; border-radius: ${p => p.theme.radius}; @@ -55,4 +61,6 @@ export const CodeBlockStyled = styled.pre` font-family: monospace; width: 100%; overflow-x: auto; + word-wrap: ${p => (p.wrapContent ? 'break-word' : 'initial')}; + white-space: ${p => (p.wrapContent ? 'pre-wrap' : 'initial')}; `; diff --git a/data-browser/src/components/Dialog/useDialog.tsx b/data-browser/src/components/Dialog/useDialog.tsx index f405ee74b..476dfd316 100644 --- a/data-browser/src/components/Dialog/useDialog.tsx +++ b/data-browser/src/components/Dialog/useDialog.tsx @@ -1,16 +1,16 @@ import { useCallback, useMemo, useState } from 'react'; import { InternalDialogProps } from './index'; -export type UseDialogReturnType = [ +export type UseDialogReturnType = { /** Props meant to pass to a {@link Dialog} component */ - dialogProps: InternalDialogProps, + dialogProps: InternalDialogProps; /** Function to show the dialog */ - show: () => void, + show: () => void; /** Function to close the dialog */ - close: () => void, + close: () => void; /** Boolean indicating wether the dialog is currently open */ - isOpen: boolean, -]; + isOpen: boolean; +}; /** Sets up state, and functions to use with a {@link Dialog} */ export const useDialog = (): UseDialogReturnType => { @@ -40,5 +40,5 @@ export const useDialog = (): UseDialogReturnType => { [showDialog, close, handleClosed], ); - return [dialogProps, show, close, visible]; + return { dialogProps, show, close, isOpen: visible }; }; diff --git a/data-browser/src/components/Guard.tsx b/data-browser/src/components/Guard.tsx new file mode 100644 index 000000000..f3a7bc4f0 --- /dev/null +++ b/data-browser/src/components/Guard.tsx @@ -0,0 +1,375 @@ +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + useDialog, +} from './Dialog'; +import React, { FormEvent, useCallback, useEffect, useState } from 'react'; +import { useSettings } from '../helpers/AppSettings'; +import { Button, ButtonInput } from './Button'; +import { Agent, nameRegex, useRegister, useServerURL } from '@tomic/react'; +import { FaEyeSlash, FaEye, FaCog } from 'react-icons/fa'; +import Field from './forms/Field'; +import { InputWrapper, InputStyled } from './forms/InputStyles'; +import { Row } from './Row'; +import { ErrorLook } from './ErrorLook'; +import { CodeBlock } from './CodeBlock'; + +/** + * The Guard can be wrapped around a Component that depends on a user being logged in. + * If the user is not logged in, it will show a button to sign up / sign in. + * Show to users after a new Agent has been created. + * Instructs them to save their secret somewhere safe + */ +export function Guard({ children }: React.PropsWithChildren): JSX.Element { + const { dialogProps, show } = useDialog(); + const { agent } = useSettings(); + const [register, setRegister] = useState(true); + + if (agent) { + return <>{children}; + } else + return ( + <> + + + + + {register ? : } + + ); +} + +function Register() { + const [name, setName] = useState(''); + const [secret, setSecret] = useState(''); + const [driveURL, setDriveURL] = useState(''); + const [newAgent, setNewAgent] = useState(undefined); + const [serverUrlStr] = useServerURL(); + const [err, setErr] = useState(undefined); + const register = useRegister(); + const { setAgent } = useSettings(); + + const serverUrl = new URL(serverUrlStr); + serverUrl.host = `${name}.${serverUrl.host}`; + + useEffect(() => { + // check regex of name, set error + if (!name.match(nameRegex)) { + setErr(new Error('Name must be lowercase and only contain numbers')); + } else { + setErr(undefined); + } + }, [name]); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + + if (!name) { + setErr(new Error('Name is required')); + + return; + } + + try { + const { driveURL: newDriveURL, agent } = await register(name); + setDriveURL(newDriveURL); + setSecret(agent.buildSecret()); + setNewAgent(agent); + } catch (er) { + setErr(er); + } + }, + [name], + ); + + const handleSaveAgent = useCallback(() => { + setAgent(newAgent); + }, [newAgent]); + + if (driveURL) { + return ( + <> + +

Save your Passphrase, {name}

+
+ +

+ Your Passphrase is like your password. Never share it with anyone. + Use a password manager to store it securely. You will need this to + log in next! +

+ +
+ + + + Open my new Drive! + + + + ); + } + + return ( + <> + +

Register

+
+ +
+ + + { + setName(e.target.value); + }} + /> + + + {!err && name?.length > 0 && {serverUrl.toString()}} + {name && err && {err.message}} +
+
+ + + + + ); +} + +function SignIn() { + return ( + <> + +

Sign in

+
+ + +

Lost your passphrase?

+
+ + ); +} + +export const SettingsAgent: React.FunctionComponent = () => { + const { agent, setAgent } = useSettings(); + const [subject, setSubject] = useState(undefined); + const [privateKey, setPrivateKey] = useState(undefined); + const [error, setError] = useState(undefined); + const [showPrivateKey, setShowPrivateKey] = useState(false); + const [advanced, setAdvanced] = useState(false); + const [secret, setSecret] = useState(undefined); + + // When there is an agent, set the advanced values + // Otherwise, reset the secret value + React.useEffect(() => { + if (agent !== undefined) { + fillAdvanced(); + } else { + setSecret(''); + } + }, [agent]); + + // When the key or subject changes, update the secret + React.useEffect(() => { + renewSecret(); + }, [subject, privateKey]); + + function renewSecret() { + if (agent) { + setSecret(agent.buildSecret()); + } + } + + function fillAdvanced() { + try { + if (!agent) { + throw new Error('No agent set'); + } + + setSubject(agent.subject); + setPrivateKey(agent.privateKey); + } catch (e) { + const err = new Error('Cannot fill subject and privatekey fields.' + e); + setError(err); + setSubject(''); + } + } + + function setAgentIfChanged(oldAgent: Agent | undefined, newAgent: Agent) { + if (JSON.stringify(oldAgent) !== JSON.stringify(newAgent)) { + setAgent(newAgent); + } + } + + /** Called when the secret or the subject is updated manually */ + async function handleUpdateSubjectAndKey() { + renewSecret(); + setError(undefined); + + try { + const newAgent = new Agent(privateKey!, subject); + await newAgent.getPublicKey(); + await newAgent.checkPublicKey(); + + setAgentIfChanged(agent, newAgent); + } catch (e) { + const err = new Error('Invalid Agent' + e); + setError(err); + } + } + + function handleCopy() { + secret && navigator.clipboard.writeText(secret); + } + + /** When the Secret updates, parse it and try if the */ + async function handleUpdateSecret(updateSecret: string) { + setSecret(updateSecret); + + if (updateSecret === '') { + setSecret(''); + setError(undefined); + + return; + } + + setError(undefined); + + try { + const newAgent = Agent.fromSecret(updateSecret); + setAgentIfChanged(agent, newAgent); + setPrivateKey(newAgent.privateKey); + setSubject(newAgent.subject); + // This will fail and throw if the agent is not public, which is by default + // await newAgent.checkPublicKey(); + } catch (e) { + const err = new Error('Invalid secret. ' + e); + setError(err); + } + } + + return ( +
+ + + handleUpdateSecret(e.target.value)} + type={showPrivateKey ? 'text' : 'password'} + disabled={agent !== undefined} + name='secret' + id='current-password' + autoComplete='current-password' + spellCheck='false' + placeholder='Paste your Passphrase' + /> + setShowPrivateKey(!showPrivateKey)} + > + {showPrivateKey ? : } + + setAdvanced(!advanced)} + > + + + {agent && ( + + copy + + )} + + + {advanced ? ( + + + + { + setSubject(e.target.value); + handleUpdateSubjectAndKey(); + }} + /> + + + + + { + setPrivateKey(e.target.value); + handleUpdateSubjectAndKey(); + }} + /> + setShowPrivateKey(!showPrivateKey)} + > + {showPrivateKey ? : } + + + + + ) : null} +
+ ); +}; diff --git a/data-browser/src/components/NewInstanceButton/NewBookmarkButton.tsx b/data-browser/src/components/NewInstanceButton/NewBookmarkButton.tsx index c21ac23c9..c4feb4787 100644 --- a/data-browser/src/components/NewInstanceButton/NewBookmarkButton.tsx +++ b/data-browser/src/components/NewInstanceButton/NewBookmarkButton.tsx @@ -36,7 +36,7 @@ export function NewBookmarkButton({ const [url, setUrl] = useState(''); - const [dialogProps, show, hide] = useDialog(); + const { dialogProps, show, close } = useDialog(); const createResourceAndNavigate = useCreateAndNavigate(klass, parent); @@ -86,7 +86,7 @@ export function NewBookmarkButton({ - - - ) : ( -

- You can create your own Agent by hosting an{' '} - - atomic-server - - . Alternatively, you can use{' '} - - an Invite - {' '} - to get a guest Agent on someone else{"'s"} Atomic Server. -

- )} - - - handleUpdateSecret(e.target.value)} - type={showPrivateKey ? 'text' : 'password'} - disabled={agent !== undefined} - name='secret' - id='current-password' - autoComplete='current-password' - spellCheck='false' - /> - setShowPrivateKey(!showPrivateKey)} - > - {showPrivateKey ? : } - - setAdvanced(!advanced)} - > - - - {agent && ( - - copy - - )} - - - {advanced ? ( - - + + sign out + + )} - + ); -}; - -export default SettingsAgent; +} diff --git a/data-browser/src/views/ChatRoomPage.tsx b/data-browser/src/views/ChatRoomPage.tsx index e16e8eb9f..46c425ec1 100644 --- a/data-browser/src/views/ChatRoomPage.tsx +++ b/data-browser/src/views/ChatRoomPage.tsx @@ -21,6 +21,7 @@ import { CommitDetail } from '../components/CommitDetail'; import Markdown from '../components/datatypes/Markdown'; import { Detail } from '../components/Detail'; import { EditableTitle } from '../components/EditableTitle'; +import { Guard } from '../components/Guard'; import { editURL } from '../helpers/navigation'; import { ResourceInline } from './ResourceInline'; import { ResourcePageProps } from './ResourcePage'; @@ -161,25 +162,27 @@ export function ChatRoomPage({ resource }: ResourcePageProps) { )} - - - - Send - - + + + + + Send + + + ); } diff --git a/data-browser/src/views/ErrorPage.tsx b/data-browser/src/views/ErrorPage.tsx index ec24136ee..dd7900598 100644 --- a/data-browser/src/views/ErrorPage.tsx +++ b/data-browser/src/views/ErrorPage.tsx @@ -3,11 +3,11 @@ import { isUnauthorized, useStore } from '@tomic/react'; import { ContainerNarrow } from '../components/Containers'; import { ErrorLook } from '../components/ErrorLook'; import { Button } from '../components/Button'; -import { SignInButton } from '../components/SignInButton'; import { useSettings } from '../helpers/AppSettings'; import { ResourcePageProps } from './ResourcePage'; import { Row } from '../components/Row'; import CrashPage from './CrashPage'; +import { Guard } from '../components/Guard'; /** * A View for Resource Errors. Not to be confused with the CrashPage, which is @@ -18,6 +18,11 @@ function ErrorPage({ resource }: ResourcePageProps): JSX.Element { const store = useStore(); const subject = resource.getSubject(); + React.useEffect(() => { + // Try again when agent changes + store.fetchResource(subject); + }, [agent]); + if (isUnauthorized(resource.error)) { return ( @@ -30,7 +35,7 @@ function ErrorPage({ resource }: ResourcePageProps): JSX.Element { ) : ( <>

{"You don't have access to this, try signing in:"}

- + )}
diff --git a/lib/src/authentication.ts b/lib/src/authentication.ts index 2d3640e10..f09eed511 100644 --- a/lib/src/authentication.ts +++ b/lib/src/authentication.ts @@ -53,7 +53,6 @@ export async function signRequest( agent: Agent, headers: HeadersObject | Headers, ): Promise { - console.log('sign request', subject); const timestamp = getTimestampNow(); if (agent?.subject && !localTryingExternal(subject, agent)) { diff --git a/react/src/index.ts b/react/src/index.ts index e264d8b90..cdb379771 100644 --- a/react/src/index.ts +++ b/react/src/index.ts @@ -30,4 +30,5 @@ export * from './useImporter'; export * from './useLocalStorage'; export * from './useMarkdown'; export * from './useServerSearch'; +export * from './useRegister'; export * from '@tomic/lib'; diff --git a/react/src/useRegister.ts b/react/src/useRegister.ts new file mode 100644 index 000000000..a53d89aa4 --- /dev/null +++ b/react/src/useRegister.ts @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { Agent, generateKeyPair, properties, useStore } from '.'; + +/** Only allows lowercase chars and numbers */ +export const nameRegex = '^[a-z0-9_-]+'; + +interface RegisterResult { + agent: Agent; + driveURL: string; +} + +// Allow users to register and create a drive on the `/register` route. +export function useRegister(): (userName: string) => Promise { + const store = useStore(); + + const register = useCallback( + /** Returns redirect URL of new drie on success */ + async (name: string): Promise => { + const keypair = await generateKeyPair(); + const newAgent = new Agent(keypair.privateKey); + const publicKey = await newAgent.getPublicKey(); + const url = new URL('/register', store.getServerUrl()); + url.searchParams.set('name', name); + url.searchParams.set('public-key', publicKey); + const resource = await store.getResourceAsync(url.toString()); + const destination = resource.get( + properties.redirect.destination, + ) as string; + const agentSubject = resource.get( + properties.redirect.redirectAgent, + ) as string; + + if (resource.error) { + throw resource.error; + } + + if (!destination) { + throw new Error('No redirect destination'); + } + + if (!agentSubject) { + throw new Error('No agent returned'); + } + + newAgent.subject = agentSubject; + + store.setAgent(newAgent); + + return { driveURL: destination, agent: newAgent }; + }, + [], + ); + + return register; +}