From 821e775e21d1b8e4df69aa746a613e68ba38aaba Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Thu, 22 Aug 2024 10:33:01 +0200 Subject: [PATCH] Add register form + email confirm #640 #544 #489 #276 --- CONTRIBUTING.md | 1 + browser/CHANGELOG.md | 13 + .../data-browser/src/components/CodeBlock.tsx | 15 +- .../src/components/Dialog/useDialog.tsx | 10 +- .../data-browser/src/components/ErrorLook.tsx | 20 +- browser/data-browser/src/components/Guard.tsx | 17 + .../src/components/RegisterSignIn.tsx | 299 +++++++ .../src/components/SettingsAgent.tsx | 198 +++++ .../src/components/SideBar/DriveSwitcher.tsx | 12 +- .../forms/FileDropzone/FileDropzoneInput.tsx | 2 +- .../CustomForms/NewBookmarkDialog.tsx | 58 +- .../src/components/forms/ResourceSelector.tsx | 206 +++++ .../data-browser/src/helpers/AppSettings.tsx | 3 +- .../data-browser/src/hooks/useSavedDrives.ts | 6 +- browser/data-browser/src/hooks/useUpload.ts | 3 +- .../data-browser/src/routes/ConfirmEmail.tsx | 108 +++ .../src/routes/NewResource/NewRoute.tsx | 10 +- browser/data-browser/src/routes/Routes.tsx | 9 +- .../data-browser/src/routes/SearchRoute.tsx | 2 +- .../data-browser/src/routes/SettingsAgent.tsx | 272 +----- browser/data-browser/src/routes/paths.tsx | 1 + .../data-browser/src/views/ChatRoomPage.tsx | 42 +- .../data-browser/src/views/CollectionPage.tsx | 4 +- browser/data-browser/src/views/CrashPage.tsx | 29 +- browser/data-browser/src/views/DrivePage.tsx | 88 +- browser/data-browser/src/views/ErrorPage.tsx | 17 +- browser/data-browser/tests/e2e.spec.ts | 841 ++++++++++++++++++ browser/lib/src/authentication.ts | 174 +++- browser/lib/src/store.ts | 12 + browser/lib/src/urls.ts | 4 + browser/react/src/index.ts | 1 + browser/react/src/useServerSupports.ts | 20 + lib/src/commit.rs | 1 - lib/src/db.rs | 11 +- lib/src/db/test.rs | 1 + lib/src/resources.rs | 1 - lib/src/urls.rs | 1 - server/build.rs | 2 +- 38 files changed, 2123 insertions(+), 391 deletions(-) create mode 100644 browser/data-browser/src/components/Guard.tsx create mode 100644 browser/data-browser/src/components/RegisterSignIn.tsx create mode 100644 browser/data-browser/src/components/SettingsAgent.tsx create mode 100644 browser/data-browser/src/components/forms/ResourceSelector.tsx create mode 100644 browser/data-browser/src/routes/ConfirmEmail.tsx create mode 100644 browser/data-browser/tests/e2e.spec.ts create mode 100644 browser/react/src/useServerSupports.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aae4512ab..ce41983eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,7 @@ TL;DR Clone the repo and run `cargo run` from each folder (e.g. `cli` or `server - Visit your `localhost` in your locally running `atomic-data-browser` instance: (e.g. `http://localhost:5173/app/show?subject=http%3A%2F%2Flocalhost`) - use `cargo watch -- cargo run` to automatically recompile `atomic-server` when you update JS assets in `browser` - use `cargo watch -- cargo run --bin atomic-server -- --env-file server/.env` to automatically recompile `atomic-server` when you update code or JS assets. +- If you want to debug emails: `brew install mailhog` => `mailhog` => `http://localhost:8025` and add `ATOMIC_SMTP_HOST=localhost` `ATOMIC_SMTP_PORT=1025` to your `.env`. ### IDE setup (VSCode) diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 0b594ac0f..7d392d5bb 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -185,11 +185,18 @@ This changelog covers all five packages, as they are (for now) updated as a whol - Add `store.getResourceAncestry` method, which returns the ancestry of a resource, including the resource itself. - Add `resource.title` property, which returns the name of a resource, or the first property that is can be used to name the resource. - `store.createSubject` now accepts a `parent` argument, which allows creating nested subjects. +- Add `store.getServerSupports` to know which features a Server supports + +### @tomic/react + +- Add `useServerSupports` hook to see supported features of the server ## v0.35.0 ### @tomic/browser +- Let users register using e-mail address, improve sign-up UX. +- Add `Store.parseMetaTags` to load JSON-AD objects stored in the DOM. Speeds up initial page load by allowing server to set JSON-AD objects in the initial HTML response. - Move static assets around, align build with server and fix PWA #292 - Add `useChildren` hook and `Store.getChildren` method - Add new file preview UI for images, audio, text and PDF files. @@ -197,8 +204,14 @@ This changelog covers all five packages, as they are (for now) updated as a whol - Fix Dialogue form #308 - Refactor search, escape query strings for Tantivy - Add `import` context menu, allows importing anywhere +- Let users register using e-mail address, improve sign-up UX. ### @tomic/react +- `store.createSubject` allows creating nested paths +- `store.createSubject` allows creating nested paths +- Add `useChildren` hook and `Store.getChildren` method +- Add `Store.postToServer` method, add `endpoints`, `import_json_ad_string` +- Add `store.preloadClassesAndProperties` and remove `urls.properties.getAll` and `urls.classes.getAll`. This enables using `atomic-data-browser` without relying on `atomicdata.dev` being available. - Add more options to `useSearch` diff --git a/browser/data-browser/src/components/CodeBlock.tsx b/browser/data-browser/src/components/CodeBlock.tsx index aafe0fa56..fc6b85e68 100644 --- a/browser/data-browser/src/components/CodeBlock.tsx +++ b/browser/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,12 @@ export function CodeBlock({ content, loading }: CodeBlockProps) { ); } -export const CodeBlockStyled = styled.pre` +interface Props { + /** Renders all in a single line */ + wrapContent?: boolean; +} + +export const CodeBlockStyled = styled.pre` position: relative; background-color: ${p => p.theme.colors.bg1}; border-radius: ${p => p.theme.radius}; @@ -55,4 +62,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' : 'pre')}; `; diff --git a/browser/data-browser/src/components/Dialog/useDialog.tsx b/browser/data-browser/src/components/Dialog/useDialog.tsx index 7ef7d5740..ffbd9510a 100644 --- a/browser/data-browser/src/components/Dialog/useDialog.tsx +++ b/browser/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: (success?: boolean) => void, /** Boolean indicating wether the dialog is currently open */ - isOpen: boolean, -]; + isOpen: boolean; +}; export type UseDialogOptions = { bindShow?: React.Dispatch; diff --git a/browser/data-browser/src/components/ErrorLook.tsx b/browser/data-browser/src/components/ErrorLook.tsx index 71c1469dd..d6ba24275 100644 --- a/browser/data-browser/src/components/ErrorLook.tsx +++ b/browser/data-browser/src/components/ErrorLook.tsx @@ -2,6 +2,7 @@ import { lighten } from 'polished'; import { styled, css } from 'styled-components'; import { FaExclamationTriangle } from 'react-icons/fa'; +import { Column } from './Row'; export const errorLookStyle = css` color: ${props => props.theme.colors.alert}; @@ -25,18 +26,21 @@ export function ErrorBlock({ error, showTrace }: ErrorBlockProps): JSX.Element { Something went wrong -
-        {error.message}
+      
+        {error.message}
         {showTrace && (
           <>
-            
-
- Stack trace: -
- {error.stack} + Stack trace: + + {error.stack} + )} -
+ ); } diff --git a/browser/data-browser/src/components/Guard.tsx b/browser/data-browser/src/components/Guard.tsx new file mode 100644 index 000000000..60554e845 --- /dev/null +++ b/browser/data-browser/src/components/Guard.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { useSettings } from '../helpers/AppSettings'; +import { RegisterSignIn } from './RegisterSignIn'; + +/** + * 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 { agent } = useSettings(); + + if (agent) { + return <>{children}; + } else return ; +} diff --git a/browser/data-browser/src/components/RegisterSignIn.tsx b/browser/data-browser/src/components/RegisterSignIn.tsx new file mode 100644 index 000000000..e893114cc --- /dev/null +++ b/browser/data-browser/src/components/RegisterSignIn.tsx @@ -0,0 +1,299 @@ +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + useDialog, +} from './Dialog'; +import React, { FormEvent, useCallback, useEffect, useState } from 'react'; +import { useSettings } from '../helpers/AppSettings'; +import { Button } from './Button'; +import { + addPublicKey, + nameRegex, + register as createRegistration, + useServerSupports, + 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 { SettingsAgent } from './SettingsAgent'; + +interface RegisterSignInProps { + // URL where to send the user to after successful registration + redirect?: string; +} + +/** What is currently showing */ +enum PageStateOpts { + none, + signIn, + register, + reset, + mailSentRegistration, + mailSentAddPubkey, +} + +/** + * Two buttons: Register / Sign in. + * Opens a Dialog / Modal with the appropriate form. + */ +export function RegisterSignIn({ + children, +}: React.PropsWithChildren): JSX.Element { + const { dialogProps, show, close } = useDialog(); + const { agent } = useSettings(); + const [pageState, setPageState] = useState(PageStateOpts.none); + const [email, setEmail] = useState(''); + const { emailRegister } = useServerSupports(); + + if (agent) { + return <>{children}; + } else if (!emailRegister) { + return ; + } + + return ( + <> + + + + + + {pageState === PageStateOpts.register && ( + + )} + {pageState === PageStateOpts.signIn && ( + + )} + {pageState === PageStateOpts.reset && ( + + )} + {pageState === PageStateOpts.mailSentRegistration && ( + + )} + {pageState === PageStateOpts.mailSentAddPubkey && ( + + )} + + + ); +} + +function Reset({ email, setEmail, setPageState }) { + const store = useStore(); + const [err, setErr] = useState(undefined); + + const handleRequestReset = useCallback(async () => { + try { + await addPublicKey(store, email); + setPageState(PageStateOpts.mailSentAddPubkey); + } catch (e) { + setErr(e); + } + }, [email]); + + return ( + <> + +

Reset your PassKey

+
+ +

+ { + "Lost it? No worries, we'll send a link that let's you create a new one." + } +

+ { + setErr(undefined); + setEmail(e); + }} + /> + {err && {err.message}} +
+ + + + + ); +} + +function MailSentConfirm({ email, close, message }) { + return ( + <> + +

Go to your email inbox

+
+ +

+ {"We've sent a confirmation link to "} + {email} + {'.'} +

+

{message}

+
+ + + + + ); +} + +function Register({ setPageState, email, setEmail }) { + const [name, setName] = useState(''); + const [serverUrlStr] = useServerURL(); + const [nameErr, setErr] = useState(undefined); + const store = useStore(); + + 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, email]); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + + if (!name) { + setErr(new Error('Name is required')); + + return; + } + + try { + await createRegistration(store, name, email); + setPageState(PageStateOpts.mailSentRegistration); + } catch (er) { + setErr(er); + } + }, + [name, email], + ); + + return ( + <> + +

Register

+
+ +
+ + + { + setName(e.target.value); + }} + /> + + + + {name && nameErr && {nameErr.message}} + +
+ + + + + + ); +} + +function SignIn({ setPageState }) { + return ( + <> + +

Sign in

+
+ + + + + + + + + ); +} + +function EmailField({ setEmail, email }) { + return ( + + + { + setEmail(e.target.value); + }} + /> + + + ); +} diff --git a/browser/data-browser/src/components/SettingsAgent.tsx b/browser/data-browser/src/components/SettingsAgent.tsx new file mode 100644 index 000000000..edda04afa --- /dev/null +++ b/browser/data-browser/src/components/SettingsAgent.tsx @@ -0,0 +1,198 @@ +import { Agent } from '@tomic/react'; +import React, { useState } from 'react'; +import { FaCog, FaEye, FaEyeSlash } from 'react-icons/fa'; +import { useSettings } from '../helpers/AppSettings'; +import { ButtonInput } from './Button'; +import Field from './forms/Field'; +import { InputStyled, InputWrapper } from './forms/InputStyles'; + +/** Form where users can post their Private Key, or edit their Agent */ +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.verifyPublicKeyWithServer(); + + 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/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx b/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx index 6d4ba73c7..27089ecae 100644 --- a/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx +++ b/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx @@ -1,4 +1,10 @@ -import { Resource, core, server, useResources } from '@tomic/react'; +import { + Resource, + server, + truncateUrl, + urls, + useResources, +} from '@tomic/react'; import { useMemo } from 'react'; import { FaCog, @@ -21,7 +27,8 @@ const Trigger = buildDefaultTrigger(, 'Open Drive Settings'); function getTitle(resource: Resource): string { return ( - (resource.get(core.properties.name) as string) ?? resource.getSubject() + (resource.get(urls.properties.name) as string) ?? + truncateUrl(resource.getSubject(), 20) ); } @@ -45,6 +52,7 @@ export function DriveSwitcher() { }; const createNewResource = useNewResourceUI(); + // const createNewDrive = useDefaultNewInstanceHandler(classes.drive); const items = useMemo( () => [ diff --git a/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx b/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx index 61f51ecec..5d74e34b6 100644 --- a/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx +++ b/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx @@ -8,11 +8,11 @@ import { useUpload } from '../../../hooks/useUpload'; export interface FileDropzoneInputProps { parentResource: Resource; - onFilesUploaded?: (files: string[]) => void; text?: string; maxFiles?: number; className?: string; accept?: string[]; + onFilesUploaded?: (fileSubjects: string[]) => void; } /** diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewBookmarkDialog.tsx b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewBookmarkDialog.tsx index f2b7b4811..482f56bbf 100644 --- a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewBookmarkDialog.tsx +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewBookmarkDialog.tsx @@ -27,7 +27,7 @@ export const NewBookmarkDialog: FC = ({ }) => { const [url, setUrl] = useState(''); - const [dialogProps, show, hide] = useDialog({ onCancel: onClose }); + const { dialogProps, show, close } = useDialog({ onCancel: onClose }); const createResourceAndNavigate = useCreateAndNavigate(); @@ -58,32 +58,34 @@ export const NewBookmarkDialog: FC = ({ }, []); return ( - - -

New Bookmark

-
- -
- - - setUrl(e.target.value)} - /> - - -
-
- - - - -
+ <> + + +

New Bookmark

+
+ +
+ + + setUrl(e.target.value)} + /> + + +
+
+ + + + +
+ ); }; diff --git a/browser/data-browser/src/components/forms/ResourceSelector.tsx b/browser/data-browser/src/components/forms/ResourceSelector.tsx new file mode 100644 index 000000000..558b76299 --- /dev/null +++ b/browser/data-browser/src/components/forms/ResourceSelector.tsx @@ -0,0 +1,206 @@ +import { + ArrayError, + urls, + useArray, + useResource, + useStore, + useTitle, +} from '@tomic/react'; +import React, { + Dispatch, + SetStateAction, + useContext, + useState, + useCallback, +} from 'react'; +import { ErrMessage, InputWrapper } from './InputStyles'; +import { DropdownInput } from './DropdownInput'; +import { Dialog, useDialog } from '../Dialog'; +import { DialogTreeContext } from '../Dialog/dialogContext'; +import { useSettings } from '../../helpers/AppSettings'; +import styled from 'styled-components'; +import { NewFormDialog } from './NewForm/NewFormDialog'; + +interface ResourceSelectorProps { + /** + * Whether a certain type of Class is required here. Pass the URL of the + * class. Is used for constructing a list of options. + */ + classType?: string; + /** If true, the form will show an error if it is left empty. */ + required?: boolean; + /** + * This callback is called when the Subject Changes. You can pass an Error + * Handler as the second argument to set an error message. Take the second + * argument of a `useString` hook and pass the setString part to this property + */ + setSubject: ( + subject: string | undefined, + errHandler?: Dispatch>, + ) => void; + /** The value (URL of the Resource that is selected) */ + value?: string; + /** A function to remove this item. Only relevant in arrays. */ + handleRemove?: () => void; + /** Only pass an error if it is applicable to this specific field */ + error?: Error; + /** + * Set an ArrayError. A special type, because the parent needs to know where + * in the Array the error occurred + */ + setError?: Dispatch>; + disabled?: boolean; + autoFocus?: boolean; + /** Is used when a new item is created using the ResourceSelector */ + parent?: string; +} + +/** + * Form field for selecting a single resource. Needs external subject & + * setSubject properties + */ +export const ResourceSelector = React.memo(function ResourceSelector({ + required, + setSubject, + value, + handleRemove, + error, + setError, + classType, + disabled, + parent, + ...props +}: ResourceSelectorProps): JSX.Element { + // TODO: This list should use the user's Pod instead of a hardcoded collection; + const classesCollection = useResource(getCollectionURL(classType)); + let [options] = useArray( + classesCollection, + urls.properties.collection.members, + ); + const requiredClass = useResource(classType); + const [classTypeTitle] = useTitle(requiredClass); + const store = useStore(); + const { + dialogProps, + show: showDialog, + close: closeDialog, + isOpen: isDialogOpen, + } = useDialog(); + const { drive } = useSettings(); + + const [ + /** The value of the input underneath, updated through a callback */ + inputValue, + setInputValue, + ] = useState(value || ''); + + const handleUpdate = useCallback( + (newval?: string) => { + // Pass the error setter for validation purposes + // Pass the Error handler to its parent, so validation errors appear locally + setSubject(newval, setError); + // Reset the error every time anything changes + // setError(null); + }, + [setSubject], + ); + + const handleBlur = useCallback(() => { + value === undefined && handleUpdate(inputValue); + }, [inputValue, value]); + + const isInDialogTree = useContext(DialogTreeContext); + + if (options.length === 0) { + options = store.getAllSubjects(); + } + + let placeholder = 'Enter an Atomic URL...'; + + if (classType && classTypeTitle?.length > 0) { + placeholder = `Select a ${classTypeTitle} or enter a ${classTypeTitle} URL...`; + } + + if (classType && !requiredClass.isReady()) { + placeholder = 'Loading Class...'; + } + + return ( + + + {value && value !== '' && error && ( + {error?.message} + )} + {!isInDialogTree && ( + + {isDialogOpen && ( + + )} + + )} + {required && value === '' && Required} + + ); +}); + +/** For a given class URL, this tries to return a URL of a Collection containing these. */ +// TODO: Scope to current store / make adjustable https://github.com/atomicdata-dev/atomic-data-browser/issues/295 +export function getCollectionURL(classtypeUrl?: string): string | undefined { + switch (classtypeUrl) { + case urls.classes.property: + return 'https://atomicdata.dev/properties/?page_size=999'; + case urls.classes.class: + return 'https://atomicdata.dev/classes/?page_size=999'; + case urls.classes.agent: + return 'https://atomicdata.dev/agents/'; + case urls.classes.commit: + return 'https://atomicdata.dev/commits'; + case urls.classes.datatype: + return 'https://atomicdata.dev/datatypes'; + default: + return undefined; + } +} + +const Wrapper = styled.div` + flex: 1; + --radius: ${props => props.theme.radius}; + ${InputWrapper} { + border-radius: 0; + } + + &:first-of-type ${InputWrapper} { + border-top-left-radius: var(--radius); + border-top-right-radius: var(--radius); + } + + &:last-of-type ${InputWrapper} { + border-bottom-left-radius: var(--radius); + border-bottom-right-radius: var(--radius); + } + + &:not(:last-of-type) ${InputWrapper} { + border-bottom: none; + } +`; diff --git a/browser/data-browser/src/helpers/AppSettings.tsx b/browser/data-browser/src/helpers/AppSettings.tsx index 0f001258e..ae629a73a 100644 --- a/browser/data-browser/src/helpers/AppSettings.tsx +++ b/browser/data-browser/src/helpers/AppSettings.tsx @@ -42,7 +42,8 @@ export const AppSettingsContextProvider = ( const [agent, setAgent] = useCurrentAgent(); const [baseURL, setBaseURL] = useServerURL(); - const [drive, innerSetDrive] = useLocalStorage('drive', baseURL); + // By default, we want to use the current URL's origin with a trailing slash. + const [drive, innerSetDrive] = useLocalStorage('drive', baseURL + '/'); const setDrive = useCallback( (newDrive: string) => { diff --git a/browser/data-browser/src/hooks/useSavedDrives.ts b/browser/data-browser/src/hooks/useSavedDrives.ts index a58e1b2eb..44c25ab88 100644 --- a/browser/data-browser/src/hooks/useSavedDrives.ts +++ b/browser/data-browser/src/hooks/useSavedDrives.ts @@ -4,9 +4,9 @@ import { isDev } from '../config'; import { useSettings } from '../helpers/AppSettings'; const rootDrives = [ - window.location.origin, - 'https://atomicdata.dev', - ...(isDev() ? ['http://localhost:9883'] : []), + window.location.origin + '/', + 'https://atomicdata.dev/', + ...(isDev() ? ['http://localhost:9883/'] : []), ]; const arrayOpts = { diff --git a/browser/data-browser/src/hooks/useUpload.ts b/browser/data-browser/src/hooks/useUpload.ts index 062c2b146..1a58fde0d 100644 --- a/browser/data-browser/src/hooks/useUpload.ts +++ b/browser/data-browser/src/hooks/useUpload.ts @@ -39,7 +39,8 @@ export function useUpload(parentResource: Resource): UseUploadResult { ); const allUploaded = [...netUploaded]; setIsUploading(false); - setSubResources([...subResources, ...allUploaded]); + await setSubResources([...subResources, ...allUploaded]); + await parentResource.save(store); return allUploaded; } catch (e) { diff --git a/browser/data-browser/src/routes/ConfirmEmail.tsx b/browser/data-browser/src/routes/ConfirmEmail.tsx new file mode 100644 index 000000000..23ca32e7f --- /dev/null +++ b/browser/data-browser/src/routes/ConfirmEmail.tsx @@ -0,0 +1,108 @@ +import { confirmEmail, useStore } from '@tomic/react'; +import * as React from 'react'; +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import { Button } from '../components/Button'; +import { CodeBlockStyled } from '../components/CodeBlock'; +import { ContainerNarrow } from '../components/Containers'; +import { isDev } from '../config'; +import { useSettings } from '../helpers/AppSettings'; +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 { setAgent } = useSettings(); + const [destinationToGo, setDestination] = useState(); + const [err, setErr] = useState(undefined); + const [triedConfirm, setTriedConfirm] = useState(false); + + const handleConfirm = React.useCallback(async () => { + setTriedConfirm(true); + 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, + ); + setSecret(newAgent.buildSecret()); + setDestination(destination); + setAgent(newAgent); + toast.success('Email confirmed!'); + } catch (e) { + setErr(e); + } + }, [subject]); + + if (!triedConfirm && subject) { + handleConfirm(); + } + + if (err) { + if (err.message.includes('expired')) { + return ( + + The link has expired. Request a new one by Registering again. + + ); + } + + return {err?.message}; + } + + if (secret) { + return ; + } + + return Verifying token...; +}; + +function SavePassphrase({ secret, destination }) { + const [copied, setCopied] = useState(false); + + function copyToClipboard() { + setCopied(secret); + navigator.clipboard.writeText(secret || ''); + toast.success('Copied to clipboard'); + } + + return ( + +

Mail confirmed, please save your passphrase

+

+ Your Passphrase is like your password. Never share it with anyone. Use a + password manager like{' '} + + BitWarden + {' '} + to store it securely. +

+ {secret} + {copied ? ( + + {"I've saved my PassPhrase, open my new Drive!"} + + ) : ( + + )} +
+ ); +} + +export default ConfirmEmail; diff --git a/browser/data-browser/src/routes/NewResource/NewRoute.tsx b/browser/data-browser/src/routes/NewResource/NewRoute.tsx index c6e353b07..22bf97ce2 100644 --- a/browser/data-browser/src/routes/NewResource/NewRoute.tsx +++ b/browser/data-browser/src/routes/NewResource/NewRoute.tsx @@ -49,11 +49,13 @@ function NewResourceSelector() { } const onUploadComplete = useCallback( - (files: string[]) => { - toast.success(`Uploaded ${files.length} files.`); + (fileSubjects: string[]) => { + toast.success(`Uploaded ${fileSubjects.length} files.`); - if (calculatedParent) { - navigate(constructOpenURL(calculatedParent)); + if (fileSubjects.length > 1 && parentSubject) { + navigate(constructOpenURL(parentSubject)); + } else { + navigate(constructOpenURL(fileSubjects[0])); } }, [parentSubject, navigate], diff --git a/browser/data-browser/src/routes/Routes.tsx b/browser/data-browser/src/routes/Routes.tsx index 3c4cc7d40..c1e11f927 100644 --- a/browser/data-browser/src/routes/Routes.tsx +++ b/browser/data-browser/src/routes/Routes.tsx @@ -11,7 +11,7 @@ import Data from './DataRoute'; import { Shortcuts } from './ShortcutsRoute'; import { About as About } from './AboutRoute'; import Local from './LocalRoute'; -import SettingsAgent from './SettingsAgent'; +import { SettingsAgentRoute } from './SettingsAgent'; import { SettingsServer } from './SettingsServer'; import { paths } from './paths'; import ResourcePage from '../views/ResourcePage'; @@ -21,8 +21,10 @@ import { TokenRoute } from './TokenRoute'; import { ImporterPage } from '../views/ImporterPage'; import { History } from './History'; import { PruneTestsRoute } from './PruneTestsRoute'; +import ConfirmEmail from './ConfirmEmail'; -const homeURL = window.location.origin; +/** Server URLs should have a `/` at the end */ +const homeURL = window.location.origin + '/'; const isDev = import.meta.env.MODE === 'development'; @@ -37,7 +39,7 @@ export function AppRoutes(): JSX.Element { } /> } /> - } /> + } /> } /> } /> } /> @@ -51,6 +53,7 @@ export function AppRoutes(): JSX.Element { } /> {isDev && } />} {isDev && } />} + } /> } /> } /> diff --git a/browser/data-browser/src/routes/SearchRoute.tsx b/browser/data-browser/src/routes/SearchRoute.tsx index d979baa2a..21b5e8de3 100644 --- a/browser/data-browser/src/routes/SearchRoute.tsx +++ b/browser/data-browser/src/routes/SearchRoute.tsx @@ -94,7 +94,7 @@ export function Search(): JSX.Element { } if (loading) { - message = 'Loading results...'; + message = 'Loading results for'; } if (results.length > 0) { diff --git a/browser/data-browser/src/routes/SettingsAgent.tsx b/browser/data-browser/src/routes/SettingsAgent.tsx index 44dc5ed41..c646027d5 100644 --- a/browser/data-browser/src/routes/SettingsAgent.tsx +++ b/browser/data-browser/src/routes/SettingsAgent.tsx @@ -1,72 +1,19 @@ import * as React from 'react'; -import { useState } from 'react'; -import { Agent } from '@tomic/react'; -import { FaCog, FaEye, FaEyeSlash, FaUser } from 'react-icons/fa'; import { useSettings } from '../helpers/AppSettings'; -import { - InputStyled, - InputWrapper, - LabelStyled, -} from '../components/forms/InputStyles'; -import { ButtonInput, Button } from '../components/Button'; +import { Button } from '../components/Button'; import { Margin } from '../components/Card'; -import Field from '../components/forms/Field'; import { ResourceInline } from '../views/ResourceInline'; import { ContainerNarrow } from '../components/Containers'; -import { AtomicLink } from '../components/AtomicLink'; import { editURL } from '../helpers/navigation'; +import { Guard } from '../components/Guard'; import { useNavigate } from 'react-router'; -import { Main } from '../components/Main'; -import { Column } from '../components/Row'; -import { WarningBlock } from '../components/WarningBlock'; +import { SettingsAgent } from '../components/SettingsAgent'; -const SettingsAgent: React.FunctionComponent = () => { +export function SettingsAgentRoute() { 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); const navigate = useNavigate(); - // 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 handleSignOut() { if ( window.confirm( @@ -74,196 +21,27 @@ const SettingsAgent: React.FunctionComponent = () => { ) ) { setAgent(undefined); - setError(undefined); - setSubject(''); - setPrivateKey(''); - } - } - - 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.verifyPublicKeyWithServer(); - - 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 ( -
- -
-

User Settings

-

- An Agent is a user, consisting of a Subject (its URL) and Private - Key. Together, these can be used to edit data and sign Commits. -

- {agent ? ( - - {agent.subject?.startsWith('http://localhost') && ( - - Warning: - { - "You're using a local Agent, which cannot authenticate on other domains, because its URL does not resolve." - } - - )} -
- - You{"'"}re signed in as - - -
- - -
- ) : ( + +

User Settings

+

+ An Agent is a user, consisting of a Subject (its URL) and Private Key. + Together, these can be used to edit data and sign Commits. +

+ + {agent && ( + <>

- 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 ? ( - <> - - - { - setSubject(e.target.value); - handleUpdateSubjectAndKey(); - }} - /> - - - - - { - setPrivateKey(e.target.value); - handleUpdateSubjectAndKey(); - }} - /> - setShowPrivateKey(!showPrivateKey)} - > - {showPrivateKey ? : } - - - - - ) : null} - {agent && ( + + + - )} - -
-
+ + )} + + ); -}; - -export default SettingsAgent; +} diff --git a/browser/data-browser/src/routes/paths.tsx b/browser/data-browser/src/routes/paths.tsx index 66531d2a7..bd6b27b3e 100644 --- a/browser/data-browser/src/routes/paths.tsx +++ b/browser/data-browser/src/routes/paths.tsx @@ -15,6 +15,7 @@ export const paths = { history: '/app/history', allVersions: '/all-versions', sandbox: '/sandbox', + confirmEmail: '/confirm-email', fetchBookmark: '/fetch-bookmark', pruneTests: '/prunetests', }; diff --git a/browser/data-browser/src/views/ChatRoomPage.tsx b/browser/data-browser/src/views/ChatRoomPage.tsx index ebd8eb281..562e30011 100644 --- a/browser/data-browser/src/views/ChatRoomPage.tsx +++ b/browser/data-browser/src/views/ChatRoomPage.tsx @@ -22,6 +22,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 { NavBarSpacer } from '../components/NavBarSpacer'; import { editURL } from '../helpers/navigation'; import { ResourceInline } from './ResourceInline'; @@ -147,25 +148,28 @@ export function ChatRoomPage({ resource }: ResourcePageProps) { )} - - - - Send - - + + + + + Send + + + + ); diff --git a/browser/data-browser/src/views/CollectionPage.tsx b/browser/data-browser/src/views/CollectionPage.tsx index e467a39c6..a83d7a85c 100644 --- a/browser/data-browser/src/views/CollectionPage.tsx +++ b/browser/data-browser/src/views/CollectionPage.tsx @@ -13,6 +13,7 @@ import { FaArrowLeft, FaArrowRight, FaInfo, + FaPlus, FaTable, FaThLarge, } from 'react-icons/fa'; @@ -171,8 +172,9 @@ function Collection({ resource }: ResourcePageProps): JSX.Element { {isClass && ( diff --git a/browser/data-browser/src/views/CrashPage.tsx b/browser/data-browser/src/views/CrashPage.tsx index 520f82586..f5d559913 100644 --- a/browser/data-browser/src/views/CrashPage.tsx +++ b/browser/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/browser/data-browser/src/views/DrivePage.tsx b/browser/data-browser/src/views/DrivePage.tsx index 8c725b7b0..302608a25 100644 --- a/browser/data-browser/src/views/DrivePage.tsx +++ b/browser/data-browser/src/views/DrivePage.tsx @@ -18,7 +18,7 @@ import { FaPlus } from 'react-icons/fa'; import { paths } from '../routes/paths'; import { ResourcePageProps } from './ResourcePage'; import { EditableTitle } from '../components/EditableTitle'; -import { Column, Row } from '../components/Row'; +import { Row } from '../components/Row'; import { styled } from 'styled-components'; import InputSwitcher from '../components/forms/InputSwitcher'; @@ -39,53 +39,51 @@ function DrivePage({ resource }: ResourcePageProps): JSX.Element { return ( - - - - {baseURL !== resource.subject && ( - - )} - - + + {baseURL !== resource.subject && ( + + )} + + +
+ Default Ontology + -
- Default Ontology - -
- - Resources: - - {subResources.map(child => ( - - - - ))} - - - Create new resource - +
+ + Resources: + + {subResources.map(child => ( + + - - - {baseURL.startsWith('http://localhost') && ( -

- You are running Atomic-Server on `localhost`, which means that it - will not be available from any other machine than your current local - device. If you want your Atomic-Server to be available from the web, - you should set this up at a Domain on a server. -

- )} -
+ ))} + + + Create new resource + + + + + {baseURL.includes('localhost') && ( +

+ You are running Atomic-Server on `localhost`, which means that it will + not be available from any other machine than your current local + device. If you want your Atomic-Server to be available from the web, + you should set this up at a Domain on a server. +

+ )}
); } diff --git a/browser/data-browser/src/views/ErrorPage.tsx b/browser/data-browser/src/views/ErrorPage.tsx index 4be11ec79..95a708ace 100644 --- a/browser/data-browser/src/views/ErrorPage.tsx +++ b/browser/data-browser/src/views/ErrorPage.tsx @@ -3,12 +3,12 @@ import { isUnauthorized, useStore } from '@tomic/react'; import { ContainerWide } from '../components/Containers'; import { ErrorBlock } from '../components/ErrorLook'; import { Button } from '../components/Button'; -import { SignInButton } from '../components/SignInButton'; import { useSettings } from '../helpers/AppSettings'; import { ResourcePageProps } from './ResourcePage'; import { Column, Row } from '../components/Row'; import CrashPage from './CrashPage'; import { clearAllLocalData } from '../helpers/clearData'; +import { Guard } from '../components/Guard'; /** * A View for Resource Errors. Not to be confused with the CrashPage, which is @@ -19,13 +19,26 @@ function ErrorPage({ resource }: ResourcePageProps): JSX.Element { const store = useStore(); const subject = resource.getSubject(); + React.useEffect(() => { + // Try again when agent changes + store.fetchResourceFromServer(subject); + }, [agent]); + if (isUnauthorized(resource.error)) { + // This might be a bit too aggressive, but it fixes 'Unauthorized' messages after signing in to a new drive. + store.fetchResourceFromServer(subject); + return (

Unauthorized

{agent ? ( <> +

+ { + "You don't have access to this. Try asking for access, or sign in with a different account." + } +