diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4ee9e12ca..229c7fb32 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -12,6 +12,17 @@ "isBackground": true, "group": "build" }, + { + "type": "npm", + "script": "build-server", + "problemMatcher": [ + "$tsc-watch" + ], + "label": "build server JS assets", + "detail": "pnpm workspace @tomic/data-browser build-server", + "isBackground": true, + "group": "build" + }, { "type": "npm", "script": "test", diff --git a/data-browser/src/components/Dialog/useDialog.tsx b/data-browser/src/components/Dialog/useDialog.tsx index 476dfd316..bdc1c098b 100644 --- a/data-browser/src/components/Dialog/useDialog.tsx +++ b/data-browser/src/components/Dialog/useDialog.tsx @@ -23,6 +23,7 @@ export const useDialog = (): UseDialogReturnType => { }, []); const close = useCallback(() => { + console.log('close', close); setShowDialog(false); }, []); diff --git a/data-browser/src/components/ErrorLook.tsx b/data-browser/src/components/ErrorLook.tsx index 0b4752dcd..b4ef84677 100644 --- a/data-browser/src/components/ErrorLook.tsx +++ b/data-browser/src/components/ErrorLook.tsx @@ -26,7 +26,13 @@ export function ErrorBlock({ error, showTrace }: ErrorBlockProps): JSX.Element { {showTrace && ( <> Stack trace: - {error.stack} + + {error.stack} + )} diff --git a/data-browser/src/components/RegisterSignIn.tsx b/data-browser/src/components/RegisterSignIn.tsx index fb939fc77..1697dea71 100644 --- a/data-browser/src/components/RegisterSignIn.tsx +++ b/data-browser/src/components/RegisterSignIn.tsx @@ -8,18 +8,11 @@ import { import React, { FormEvent, useCallback, useEffect, useState } from 'react'; import { useSettings } from '../helpers/AppSettings'; import { Button } from './Button'; -import { - Agent, - nameRegex, - register, - useServerURL, - useStore, -} from '@tomic/react'; +import { nameRegex, register, useServerURL, useStore } from '@tomic/react'; import Field from './forms/Field'; import { InputWrapper, InputStyled } from './forms/InputStyles'; import { Row } from './Row'; import { ErrorLook } from './ErrorLook'; -import { CodeBlock } from './CodeBlock'; import { SettingsAgent } from './SettingsAgent'; interface RegisterSignInProps { @@ -34,9 +27,9 @@ interface RegisterSignInProps { export function RegisterSignIn({ children, }: React.PropsWithChildren): JSX.Element { - const { dialogProps, show } = useDialog(); + const { dialogProps, show, close } = useDialog(); const { agent } = useSettings(); - const [isRegister, setRegister] = useState(true); + const [isRegistering, setRegister] = useState(true); if (agent) { return <>{children}; @@ -63,23 +56,19 @@ export function RegisterSignIn({ - {isRegister ? : } + {isRegistering ? : } ); } -function Register() { +function Register({ close }) { const [name, setName] = useState(''); const [email, setEmail] = useState(''); - const [secret, setSecret] = useState(''); - const [driveURL, setDriveURL] = useState(''); - const [newAgent, setNewAgent] = useState(undefined); const [serverUrlStr] = useServerURL(); const [nameErr, setErr] = useState(undefined); - const doRegister = useCallback(register, []); - const { setAgent } = useSettings(); const store = useStore(); + const [mailSent, setMailSent] = useState(false); const serverUrl = new URL(serverUrlStr); serverUrl.host = `${name}.${serverUrl.host}`; @@ -104,14 +93,8 @@ function Register() { } try { - const { driveURL: newDriveURL, agent } = await doRegister( - store, - name, - email, - ); - setDriveURL(newDriveURL); - setSecret(agent.buildSecret()); - setNewAgent(agent); + await register(store, name, email); + setMailSent(true); } catch (er) { setErr(er); } @@ -119,29 +102,22 @@ function Register() { [name, email], ); - const handleSaveAgent = useCallback(() => { - setAgent(newAgent); - }, [newAgent]); - - if (driveURL) { + if (mailSent) { return ( <> -

Save your Passphrase, {name}

+

Go to your email inbox

- 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! + {"We've sent a confirmation link to "} + {email} + {'.'}

- +

Your account will be created when you open that link.

- - - Open my new Drive! - + ); diff --git a/data-browser/src/routes/ConfirmEmail.tsx b/data-browser/src/routes/ConfirmEmail.tsx new file mode 100644 index 000000000..f73291d98 --- /dev/null +++ b/data-browser/src/routes/ConfirmEmail.tsx @@ -0,0 +1,73 @@ +import { confirmEmail, useStore } from '@tomic/react'; +import * as React from 'react'; +import { useState } from 'react'; +import { CodeBlock } from '../components/CodeBlock'; +import { ContainerNarrow } from '../components/Containers'; +import { isDev } from '../config'; +import { useSettings } from '../helpers/AppSettings'; +import { handleError } from '../helpers/handlers'; +import { + useCurrentSubject, + useSubjectParam, +} from '../helpers/useCurrentSubject'; +import { paths } from './paths'; + +/** Route that connects to `/confirm-email`, which confirms an email and creates a secret key. */ +const ConfirmEmail: React.FunctionComponent = () => { + // Value shown in navbar, after Submitting + const [subject] = useCurrentSubject(); + const [secret, setSecret] = useState(''); + const store = useStore(); + const [token] = useSubjectParam('token'); + const { agent, setAgent } = useSettings(); + const [destinationToGo, setDestination] = useState(); + + const handleConfirm = async () => { + let tokenUrl = subject as string; + + if (isDev()) { + const url = new URL(store.getServerUrl()); + url.pathname = paths.confirmEmail; + url.searchParams.set('token', token as string); + tokenUrl = url.href; + } + + try { + const { agent: newAgent, destination } = await confirmEmail( + store, + tokenUrl, + ); + setAgent(newAgent); + setSecret(newAgent.buildSecret()); + setDestination(destination); + } catch (e) { + handleError(e); + } + }; + + if (!agent) { + return ( + + + + ); + } + + return ( + +

Save your Passphrase

+

+ 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! + +
+ ); +}; + +export default ConfirmEmail; diff --git a/data-browser/src/routes/Routes.tsx b/data-browser/src/routes/Routes.tsx index 4fdd9adf8..fe7986216 100644 --- a/data-browser/src/routes/Routes.tsx +++ b/data-browser/src/routes/Routes.tsx @@ -17,6 +17,7 @@ import { paths } from './paths'; import ResourcePage from '../views/ResourcePage'; import { ShareRoute } from './ShareRoute'; import { Sandbox } from './Sandbox'; +import ConfirmEmail from './ConfirmEmail'; /** Server URLs should have a `/` at the end */ const homeURL = window.location.origin + '/'; @@ -44,6 +45,7 @@ export function AppRoutes(): JSX.Element { } /> } /> {isDev && } />} + } /> } /> } /> diff --git a/data-browser/src/routes/paths.tsx b/data-browser/src/routes/paths.tsx index df079864c..e984614ce 100644 --- a/data-browser/src/routes/paths.tsx +++ b/data-browser/src/routes/paths.tsx @@ -12,5 +12,6 @@ export const paths = { about: '/app/about', allVersions: '/all-versions', sandbox: '/sandbox', + confirmEmail: '/confirm-email', fetchBookmark: '/fetch-bookmark', }; diff --git a/data-browser/src/views/CrashPage.tsx b/data-browser/src/views/CrashPage.tsx index 520f82586..f5d559913 100644 --- a/data-browser/src/views/CrashPage.tsx +++ b/data-browser/src/views/CrashPage.tsx @@ -14,6 +14,32 @@ type ErrorPageProps = { clearError: () => void; }; +const githubIssueTemplate = ( + message, + stack, +) => `**Describe what you did to produce the bug** + +## Error message +\`\`\` +${message} +\`\`\` + +## Stack trace +\`\`\` +${stack} +\`\`\` +`; + +function createGithubIssueLink(error: Error): string { + const url = new URL( + 'https://github.com/atomicdata-dev/atomic-data-browser/issues/new', + ); + url.searchParams.set('body', githubIssueTemplate(error.message, error.stack)); + url.searchParams.set('labels', 'bug'); + + return url.href; +} + /** If the entire app crashes, show this page */ function CrashPage({ resource, @@ -26,6 +52,7 @@ function CrashPage({ {children ? children : } + Create Github issue {clearError && } diff --git a/lib/src/authentication.ts b/lib/src/authentication.ts index b52de4f63..05152710c 100644 --- a/lib/src/authentication.ts +++ b/lib/src/authentication.ts @@ -10,6 +10,7 @@ import { /** Returns a JSON-AD resource of an Authentication */ export async function createAuthentication(subject: string, agent: Agent) { + console.log('create authentication', subject); const timestamp = getTimestampNow(); if (!agent.subject) { @@ -114,48 +115,102 @@ export const checkAuthenticationCookie = (): boolean => { return matches.length > 0; }; -export interface RegisterResult { - agent: Agent; - driveURL: string; -} - -/** Only lowercase chars, numbers and a hyphen */ +/** Only allows lowercase chars and numbers */ export const nameRegex = '^[a-z0-9_-]+'; -/** Creates a new Agent + Drive using a shortname and email. Uses the serverURL from the Store. */ -export const register = async ( +/** Asks the server to create an Agent + a Drive. + * Sends the confirmation email to the user. + * Throws if the name is not available or the email is invalid. + * The Agent and Drive are only created after the Email is confirmed. */ +export async function register( store: Store, name: string, email: string, -): Promise => { - const keypair = await generateKeyPair(); - const agent = new Agent(keypair.privateKey); - const publicKey = await agent.getPublicKey(); +): Promise { const url = new URL('/register', store.getServerUrl()); url.searchParams.set('name', name); - url.searchParams.set('public-key', publicKey); url.searchParams.set('email', email); const resource = await store.getResourceAsync(url.toString()); - const driveURL = resource.get(properties.redirect.destination) as string; - const agentSubject = resource.get( - properties.redirect.redirectAgent, - ) as string; + + if (!resource) { + throw new Error('No resource received'); + } if (resource.error) { throw resource.error; } - if (!driveURL) { - throw new Error('No redirect destination'); + const description = resource.get(properties.description) as string; + + if (!description.includes('success')) { + throw new Error('ERRORORRRR'); + } + + return; +} + +/** When the user receives a confirmation link, call this function with the provided URL. + * If there is no agent in the store, a new one will be created. */ +export async function confirmEmail( + store: Store, + tokenURL: string, +): Promise<{ agent: Agent; destination: string }> { + const url = new URL(tokenURL); + const token = url.searchParams.get('token'); + + if (!token) { + throw new Error('No token provided'); + } + + const parsed = parseJwt(token); + + if (!parsed.name || !parsed.email) { + throw new Error('token does not contain name or email'); } - if (!agentSubject) { - throw new Error('No agent returned'); + let agent = store.getAgent(); + + if (!agent) { + const keypair = await generateKeyPair(); + const newAgent = new Agent(keypair.privateKey); + newAgent.subject = `${store.getServerUrl()}/agents/${parsed.name}`; + agent = newAgent; } - agent.subject = agentSubject; + url.searchParams.set('public-key', await agent.getPublicKey()); + const resource = await store.getResourceAsync(url.toString()); + + if (!resource) { + throw new Error('no resource!'); + } + + if (resource.error) { + throw resource.error; + } + + const destination = resource.get(properties.redirect.destination) as string; + + if (!destination) { + throw new Error('No redirect destination in response'); + } store.setAgent(agent); - return { driveURL, agent }; -}; + return { agent, destination }; +} + +function parseJwt(token) { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + window + .atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(''), + ); + + return JSON.parse(jsonPayload); +} diff --git a/lib/src/websockets.ts b/lib/src/websockets.ts index d73d397b3..799e90f2a 100644 --- a/lib/src/websockets.ts +++ b/lib/src/websockets.ts @@ -29,6 +29,7 @@ export function startWebsocket(url: string, store: Store): WebSocket { } function handleOpen(store: Store, client: WebSocket) { + console.log('open client', client); // Make sure user is authenticated before sending any messages authenticate(client, store).then(() => { // Subscribe to all existing messages @@ -71,8 +72,8 @@ export async function authenticate(client: WebSocket, store: Store) { } if ( - !client.url.startsWith('ws://localhost:') && - agent?.subject?.startsWith('http://localhost') + agent?.subject?.startsWith('http://localhost') && + !client.url.includes('localhost') ) { console.warn("Can't authenticate localhost Agent over websocket");