diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ad42180b4..d0d2182f8 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/CHANGELOG.md b/CHANGELOG.md index b770a373b..c77313e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ This changelog covers all three packages, as they are (for now) updated as a who ## UNRELEASED +- 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 +- `store.createSubject` allows creating nested paths +- Add `useChildren` hook and `Store.getChildren` method ## v0.35.0 diff --git a/data-browser/index.html b/data-browser/index.html index 83f6c9e67..48cc22277 100644 --- a/data-browser/index.html +++ b/data-browser/index.html @@ -34,6 +34,8 @@ + + diff --git a/data-browser/src/components/ClassDetail.tsx b/data-browser/src/components/ClassDetail.tsx index 452767d61..bf4a29f66 100644 --- a/data-browser/src/components/ClassDetail.tsx +++ b/data-browser/src/components/ClassDetail.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { properties, Resource, useString } from '@tomic/react'; import { ResourceInline } from '../views/ResourceInline'; import { Detail } from './Detail'; +import { getIconForClass } from '../views/FolderPage/iconMap'; type Props = { resource: Resource; @@ -15,8 +16,11 @@ export function ClassDetail({ resource }: Props): JSX.Element { {klass && ( - {'is a '} - + <> + {'is a '} + {getIconForClass(klass)} + + )} 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/Dropdown/index.tsx b/data-browser/src/components/Dropdown/index.tsx index 5989d5811..fe1766258 100644 --- a/data-browser/src/components/Dropdown/index.tsx +++ b/data-browser/src/components/Dropdown/index.tsx @@ -26,6 +26,8 @@ interface DropdownMenuProps { /** The list of menu items */ items: Item[]; trigger: DropdownTriggerRenderFunction; + /** Enables the keyboard shortcut */ + isMainMenu?: boolean; } /** Gets the index of an array and loops around when at the beginning or end */ @@ -88,6 +90,7 @@ function normalizeItems(items: Item[]) { export function DropdownMenu({ items, trigger, + isMainMenu, }: DropdownMenuProps): JSX.Element { const menuId = useId(); const dropdownRef = useRef(null); @@ -167,7 +170,7 @@ export function DropdownMenu({ handleToggle(); setUseKeys(true); }, - {}, + { enabled: !!isMainMenu }, [isActive], ); // Click / open the item diff --git a/data-browser/src/components/ErrorLook.tsx b/data-browser/src/components/ErrorLook.tsx index 9e493364d..b4ef84677 100644 --- a/data-browser/src/components/ErrorLook.tsx +++ b/data-browser/src/components/ErrorLook.tsx @@ -2,6 +2,7 @@ import { lighten } from 'polished'; import styled from 'styled-components'; import React from 'react'; import { FaExclamationTriangle } from 'react-icons/fa'; +import { Column } from './Row'; export const ErrorLook = styled.span` color: ${props => props.theme.colors.alert}; @@ -20,13 +21,21 @@ export function ErrorBlock({ error, showTrace }: ErrorBlockProps): JSX.Element { Something went wrong - {error.message} - {showTrace && ( - <> - Stack trace: - {error.stack} - - )} + + {error.message} + {showTrace && ( + <> + Stack trace: + + {error.stack} + + + )} + ); } diff --git a/data-browser/src/components/Guard.tsx b/data-browser/src/components/Guard.tsx new file mode 100644 index 000000000..60554e845 --- /dev/null +++ b/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/data-browser/src/components/Navigation.tsx b/data-browser/src/components/Navigation.tsx index 35986a936..b59ca357f 100644 --- a/data-browser/src/components/Navigation.tsx +++ b/data-browser/src/components/Navigation.tsx @@ -136,6 +136,7 @@ function NavBar(): JSX.Element { {showButtons && subject && ( 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({ - - - - - - ); -} diff --git a/data-browser/src/components/NewInstanceButton/index.tsx b/data-browser/src/components/NewInstanceButton/index.tsx index 9277c09e0..af291bfcf 100644 --- a/data-browser/src/components/NewInstanceButton/index.tsx +++ b/data-browser/src/components/NewInstanceButton/index.tsx @@ -4,14 +4,12 @@ import { NewBookmarkButton } from './NewBookmarkButton'; import { NewInstanceButtonProps } from './NewInstanceButtonProps'; import { NewInstanceButtonDefault } from './NewInstanceButtonDefault'; import { useSettings } from '../../helpers/AppSettings'; -import { NewFolderButton } from './NewFolderButton'; type InstanceButton = (props: NewInstanceButtonProps) => JSX.Element; /** If your New Instance button requires custom logic, such as a custom dialog */ const classMap = new Map([ [classes.bookmark, NewBookmarkButton], - [classes.folder, NewFolderButton], ]); /** A button for creating a new instance of some thing */ diff --git a/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts b/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts index 9a8c3ba24..9ce168b52 100644 --- a/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts +++ b/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts @@ -12,7 +12,7 @@ import { useNavigate } from 'react-router-dom'; import { constructOpenURL } from '../../helpers/navigation'; /** - * Hook that builds a function that will create a new resoure with the given + * Hook that builds a function that will create a new resource with the given * properties and then navigate to it. * * @param klass The type of resource to create a new instance of. @@ -34,7 +34,7 @@ export function useCreateAndNavigate(klass: string, parent?: string) { /** Do not set a parent for the new resource. Useful for top-level resources */ noParent?: boolean, ): Promise => { - const subject = store.createSubject(className); + const subject = store.createSubject(className, parent); const resource = new Resource(subject, true); await Promise.all([ diff --git a/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx b/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx index feb60307b..5b948958b 100644 --- a/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx +++ b/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx @@ -11,6 +11,13 @@ import { useSettings } from '../../helpers/AppSettings'; import { newURL } from '../../helpers/navigation'; import { useCreateAndNavigate } from './useCreateAndNavigate'; +/** + * Returns a function that can be used to create a new instance of the given Class. + * This is the place where you can add custom behavior for certain classes. + * By default, we're redirected to an empty Form for the new instance. + * For some Classes, though, we'd rather have some values are pre-filled (e.g. a new ChatRoom with a `new chatroom` title). + * For others, we want to render a custom form, perhaps with a different layout. + */ export function useDefaultNewInstanceHandler(klass: string, parent?: string) { const store = useStore(); const { setDrive } = useSettings(); @@ -22,61 +29,77 @@ export function useDefaultNewInstanceHandler(klass: string, parent?: string) { const createResourceAndNavigate = useCreateAndNavigate(klass, parent); const onClick = useCallback(async () => { - switch (klass) { - case classes.chatRoom: { - createResourceAndNavigate('chatRoom', { - [properties.name]: 'New ChatRoom', - [properties.isA]: [classes.chatRoom], - }); - break; - } - - case classes.document: { - createResourceAndNavigate('documents', { - [properties.isA]: [classes.document], - [properties.name]: 'Untitled Document', - }); - break; - } + try { + switch (klass) { + case classes.chatRoom: { + createResourceAndNavigate('chatRoom', { + [properties.name]: 'Untitled ChatRoom', + [properties.isA]: [classes.chatRoom], + }); + break; + } - case classes.importer: { - createResourceAndNavigate('importer', { - [properties.isA]: [classes.importer], - }); - break; - } + case classes.document: { + createResourceAndNavigate('document', { + [properties.isA]: [classes.document], + [properties.name]: 'Untitled Document', + }); + break; + } - case classes.drive: { - const agent = store.getAgent(); + case classes.folder: { + createResourceAndNavigate('folder', { + [properties.isA]: [classes.folder], + [properties.name]: 'Untitled Folder', + [properties.displayStyle]: classes.displayStyles.list, + }); + break; + } - if (!agent || agent.subject === undefined) { - throw new Error( - 'No agent set in the Store, required when creating a Drive', - ); + case classes.importer: { + createResourceAndNavigate('importer', { + [properties.isA]: [classes.importer], + }); + break; } - const newResource = await createResourceAndNavigate( - 'drive', - { - [properties.isA]: [classes.drive], - [properties.write]: [agent.subject], - [properties.read]: [agent.subject], - }, - undefined, - true, - ); + case classes.drive: { + const agent = store.getAgent(); - const agentResource = await store.getResourceAsync(agent.subject); - agentResource.pushPropVal(properties.drives, newResource.getSubject()); - agentResource.save(store); - setDrive(newResource.getSubject()); - break; - } + if (!agent || agent.subject === undefined) { + throw new Error( + 'No agent set in the Store, required when creating a Drive', + ); + } + + const newResource = await createResourceAndNavigate( + 'drive', + { + [properties.isA]: [classes.drive], + [properties.write]: [agent.subject], + [properties.read]: [agent.subject], + }, + undefined, + true, + ); - default: { - // Opens an `Edit` form with the class and a decent subject name - navigate(newURL(klass, parent, store.createSubject(shortname))); + const agentResource = await store.getResourceAsync(agent.subject); + agentResource.pushPropVal( + properties.drives, + newResource.getSubject(), + ); + agentResource.save(store); + setDrive(newResource.getSubject()); + break; + } + + default: { + // Opens an `Edit` form with the class and a decent subject name + navigate(newURL(klass, parent, store.createSubject(shortname))); + } } + } catch (e) { + store.notifyError(e); } }, [klass, store, parent, createResourceAndNavigate]); diff --git a/data-browser/src/components/Parent.tsx b/data-browser/src/components/Parent.tsx index 44e4ff690..02504989e 100644 --- a/data-browser/src/components/Parent.tsx +++ b/data-browser/src/components/Parent.tsx @@ -65,6 +65,7 @@ const ParentWrapper = styled.nav` flex-direction: row; align-items: center; justify-content: flex-start; + overflow-y: hidden; `; type NestedParentProps = { @@ -111,6 +112,7 @@ const BreadCrumbBase = css` font-family: ${props => props.theme.fontFamily}; padding: 0.1rem 0.5rem; color: ${p => p.theme.colors.textLight}; + white-space: nowrap; `; const BreadCrumbCurrent = styled.div` diff --git a/data-browser/src/components/RegisterSignIn.tsx b/data-browser/src/components/RegisterSignIn.tsx new file mode 100644 index 000000000..9201e3afc --- /dev/null +++ b/data-browser/src/components/RegisterSignIn.tsx @@ -0,0 +1,294 @@ +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, + 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 succesful 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(''); + + if (agent) { + return <>{children}; + } else + 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/data-browser/src/components/ResourceContextMenu/index.tsx b/data-browser/src/components/ResourceContextMenu/index.tsx index 5b839e0b0..95d66a180 100644 --- a/data-browser/src/components/ResourceContextMenu/index.tsx +++ b/data-browser/src/components/ResourceContextMenu/index.tsx @@ -31,6 +31,8 @@ export interface ResourceContextMenuProps { hide?: string[]; trigger?: DropdownTriggerRenderFunction; simple?: boolean; + /** If it's the primary menu in the navbar. Used for triggering keyboard shortcut */ + isMainMenu?: boolean; } /** Dropdown menu that opens a bunch of actions for some resource */ @@ -39,6 +41,7 @@ function ResourceContextMenu({ hide, trigger, simple, + isMainMenu, }: ResourceContextMenuProps) { const store = useStore(); const navigate = useNavigate(); @@ -149,7 +152,13 @@ function ResourceContextMenu({ const triggerComp = trigger ?? buildDefaultTrigger(); - return ; + return ( + + ); } export default ResourceContextMenu; diff --git a/data-browser/src/components/SettingsAgent.tsx b/data-browser/src/components/SettingsAgent.tsx new file mode 100644 index 000000000..94c5e678e --- /dev/null +++ b/data-browser/src/components/SettingsAgent.tsx @@ -0,0 +1,199 @@ +import { Agent } from '@tomic/react'; +import React from 'react'; +import { 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.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/SideBar/DriveSwitcher.tsx b/data-browser/src/components/SideBar/DriveSwitcher.tsx index 5dd36c148..ad5a50b1d 100644 --- a/data-browser/src/components/SideBar/DriveSwitcher.tsx +++ b/data-browser/src/components/SideBar/DriveSwitcher.tsx @@ -1,4 +1,10 @@ -import { classes, Resource, urls, useResources } from '@tomic/react'; +import { + classes, + Resource, + truncateUrl, + urls, + useResources, +} from '@tomic/react'; import React, { useMemo } from 'react'; import { FaCog, @@ -21,7 +27,8 @@ const Trigger = buildDefaultTrigger(, 'Open Drive Settings'); function getTitle(resource: Resource): string { return ( - (resource.get(urls.properties.name) as string) ?? resource.getSubject() + (resource.get(urls.properties.name) as string) ?? + truncateUrl(resource.getSubject(), 20) ); } @@ -31,7 +38,7 @@ function dedupeAFromB(a: Map, b: Map): Map { export function DriveSwitcher() { const navigate = useNavigate(); - const { drive, setDrive, agent } = useSettings(); + const { drive, setDrive } = useSettings(); const [savedDrives] = useSavedDrives(); const [history, addToHistory] = useDriveHistory(savedDrives, 5); @@ -44,10 +51,7 @@ export function DriveSwitcher() { navigate(constructOpenURL(subject)); }; - const createNewDrive = useDefaultNewInstanceHandler( - classes.drive, - agent?.subject, - ); + const createNewDrive = useDefaultNewInstanceHandler(classes.drive); const items = useMemo( () => [ diff --git a/data-browser/src/components/Toaster.tsx b/data-browser/src/components/Toaster.tsx index 5f052454a..8e91beb8d 100644 --- a/data-browser/src/components/Toaster.tsx +++ b/data-browser/src/components/Toaster.tsx @@ -1,6 +1,6 @@ import React from 'react'; import toast, { ToastBar, Toaster as ReactHotToast } from 'react-hot-toast'; -import { FaTimes } from 'react-icons/fa'; +import { FaCopy, FaTimes } from 'react-icons/fa'; import { useTheme } from 'styled-components'; import { zIndex } from '../styling'; import { Button } from './Button'; @@ -37,22 +37,48 @@ export function Toaster(): JSX.Element { }} > {({ icon, message }) => ( - <> - {icon} - {message} - {t.type !== 'loading' && ( - - )} - + )} )} ); } + +function ToastMessage({ icon, message, t }) { + let text = message.props.children; + + function handleCopy() { + toast.success('Copied error to clipboard'); + navigator.clipboard.writeText(message.props.children); + toast.dismiss(t.id); + } + + if (text.length > 100) { + text = text.substring(0, 100) + '...'; + } + + return ( + <> + {icon} + {text} + {t.type !== 'loading' && ( +
+ + {t.type !== 'success' && ( + + )} +
+ )} + + ); +} diff --git a/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx b/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx index 4c1728445..af0e29657 100644 --- a/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx +++ b/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx @@ -8,7 +8,7 @@ import { useUpload } from '../../../hooks/useUpload'; export interface FileDropzoneInputProps { parentResource: Resource; - onFilesUploaded?: (files: string[]) => void; + onFilesUploaded?: (fileSubjects: string[]) => void; } /** diff --git a/data-browser/src/components/forms/NewForm/index.tsx b/data-browser/src/components/forms/NewForm/index.tsx index 19414d9ea..abc2f222a 100644 --- a/data-browser/src/components/forms/NewForm/index.tsx +++ b/data-browser/src/components/forms/NewForm/index.tsx @@ -1,6 +1,6 @@ import { properties, useResource, useStore, useTitle } from '@tomic/react'; import React, { useCallback, useState } from 'react'; -import { useQueryString } from '../../../helpers/navigation'; +import { newURLParams, useQueryString } from '../../../helpers/navigation'; import { useEffectOnce } from '../../../hooks/useEffectOnce'; import { Button } from '../../Button'; import { DialogActions, DialogContent, DialogTitle } from '../../Dialog'; @@ -28,14 +28,16 @@ export const NewFormFullPage = ({ classSubject, }: NewFormProps): JSX.Element => { const klass = useResource(classSubject); - const [subject, setSubject] = useQueryString('newSubject'); - const [parentSubject] = useQueryString('parent'); + const [subject, setSubject] = useQueryString(newURLParams.newSubject); + const [parent] = useQueryString(newURLParams.parent); const { subjectErr, subjectValue, setSubjectValue, resource } = useNewForm( klass, - subject!, + subject + ? subject + : `${parent}/${Math.random().toString(36).substring(2, 9)}`, setSubject, - parentSubject, + parent, ); return ( diff --git a/data-browser/src/components/forms/ResourceSelector.tsx b/data-browser/src/components/forms/ResourceSelector.tsx index ff259fe3d..0977f5577 100644 --- a/data-browser/src/components/forms/ResourceSelector.tsx +++ b/data-browser/src/components/forms/ResourceSelector.tsx @@ -80,7 +80,12 @@ export const ResourceSelector = React.memo(function ResourceSelector({ const requiredClass = useResource(classType); const [classTypeTitle] = useTitle(requiredClass); const store = useStore(); - const [dialogProps, showDialog, closeDialog, isDialogOpen] = useDialog(); + const { + dialogProps, + show: showDialog, + close: closeDialog, + isOpen: isDialogOpen, + } = useDialog(); const { drive } = useSettings(); const [ diff --git a/data-browser/src/handlers/errorHandler.ts b/data-browser/src/handlers/errorHandler.ts index 71713c7ae..947bde90d 100644 --- a/data-browser/src/handlers/errorHandler.ts +++ b/data-browser/src/handlers/errorHandler.ts @@ -4,11 +4,7 @@ import { handleError } from '../helpers/loggingHandlers'; export const errorHandler = (e: Error) => { handleError(e); - let message = e.message; - - if (e.message.length > 100) { - message = e.message.substring(0, 100) + '...'; - } + const message = e.message; toast.error(message); }; diff --git a/data-browser/src/helpers/AppSettings.tsx b/data-browser/src/helpers/AppSettings.tsx index 1eb1d30f0..fd9237574 100644 --- a/data-browser/src/helpers/AppSettings.tsx +++ b/data-browser/src/helpers/AppSettings.tsx @@ -31,7 +31,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 + '/'); function setDrive(newDrive: string) { const url = new URL(newDrive); diff --git a/data-browser/src/helpers/loggingHandlers.tsx b/data-browser/src/helpers/loggingHandlers.tsx index e2374ee76..0cea23394 100644 --- a/data-browser/src/helpers/loggingHandlers.tsx +++ b/data-browser/src/helpers/loggingHandlers.tsx @@ -6,6 +6,8 @@ import React from 'react'; import { isDev } from '../config'; export function handleError(e: Error): void { + // We already toast in the `errorHandler` + // toast.error(e.message); console.error(e); if (!isDev) { diff --git a/data-browser/src/helpers/navigation.tsx b/data-browser/src/helpers/navigation.tsx index 5187d1572..4f482163e 100644 --- a/data-browser/src/helpers/navigation.tsx +++ b/data-browser/src/helpers/navigation.tsx @@ -66,6 +66,13 @@ export function useSearchQuery() { return useQueryString('query'); } +/** Query parameters used by the `/new` route */ +export const newURLParams = { + classSubject: 'classSubject', + parent: 'parent', + newSubject: 'newSubject', +}; + /** Constructs a URL for the New Resource form */ export function newURL( classUrl: string, @@ -74,9 +81,9 @@ export function newURL( ): string { const navTo = new URL(location.origin); navTo.pathname = paths.new; - navTo.searchParams.append('classSubject', classUrl); - parentURL && navTo.searchParams.append('parent', parentURL); - subject && navTo.searchParams.append('newSubject', subject); + navTo.searchParams.append(newURLParams.classSubject, classUrl); + parentURL && navTo.searchParams.append(newURLParams.parent, parentURL); + subject && navTo.searchParams.append(newURLParams.newSubject, subject); return paths.new + navTo.search; } diff --git a/data-browser/src/hooks/useSavedDrives.ts b/data-browser/src/hooks/useSavedDrives.ts index 849780773..c98ca68b6 100644 --- a/data-browser/src/hooks/useSavedDrives.ts +++ b/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/data-browser/src/hooks/useUpload.ts b/data-browser/src/hooks/useUpload.ts index 062c2b146..1a58fde0d 100644 --- a/data-browser/src/hooks/useUpload.ts +++ b/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/data-browser/src/routes/AboutRoute.tsx b/data-browser/src/routes/AboutRoute.tsx index 830646e1e..30f4c4a5b 100644 --- a/data-browser/src/routes/AboutRoute.tsx +++ b/data-browser/src/routes/AboutRoute.tsx @@ -43,7 +43,7 @@ export const About: React.FunctionComponent = () => {

The back-end of this app is{' '} - + atomic-server , which you can think of as an open source, web-native database. @@ -63,7 +63,7 @@ export const About: React.FunctionComponent = () => {

Run your own server

The easiest way to run an{' '} - + atomic-server {' '} is by using Docker: diff --git a/data-browser/src/routes/ConfirmEmail.tsx b/data-browser/src/routes/ConfirmEmail.tsx new file mode 100644 index 000000000..23ca32e7f --- /dev/null +++ b/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/data-browser/src/routes/NewRoute.tsx b/data-browser/src/routes/NewRoute.tsx index ab08d3126..6174723df 100644 --- a/data-browser/src/routes/NewRoute.tsx +++ b/data-browser/src/routes/NewRoute.tsx @@ -57,11 +57,13 @@ function New(): JSX.Element { } const onUploadComplete = useCallback( - (files: string[]) => { - toast.success(`Uploaded ${files.length} files.`); + (fileSubjects: string[]) => { + toast.success(`Uploaded ${fileSubjects.length} files.`); - if (parentSubject) { + if (fileSubjects.length > 1 && parentSubject) { navigate(constructOpenURL(parentSubject)); + } else { + navigate(constructOpenURL(fileSubjects[0])); } }, [parentSubject, navigate], @@ -82,15 +84,6 @@ function New(): JSX.Element { )} -
- -
{classInput && ( @@ -107,6 +100,15 @@ function New(): JSX.Element { )} +
+ +
} /> } /> - } /> + } /> } /> } /> } /> @@ -43,6 +45,7 @@ export function AppRoutes(): JSX.Element { } /> } /> {isDev && } />} + } /> } /> } /> diff --git a/data-browser/src/routes/SearchRoute.tsx b/data-browser/src/routes/SearchRoute.tsx index 0e1faf1fd..b71cc8880 100644 --- a/data-browser/src/routes/SearchRoute.tsx +++ b/data-browser/src/routes/SearchRoute.tsx @@ -69,29 +69,33 @@ export function Search(): JSX.Element { { enableOnTags: ['INPUT'] }, ); - let message = 'No hits'; + let message = 'No results for'; if (query?.length === 0) { message = 'Enter a search query'; } if (loading) { - message = 'Loading results...'; + message = 'Loading results for'; + } + + if (results.length > 0) { + message = `${results.length} results for`; } return ( - {error ? ( - {error.message} - ) : query?.length !== 0 && results.length !== 0 ? ( - <> - - - - {results.length} {results.length > 1 ? 'Results' : 'Result'} for{' '} - {query} - - + <> + + + + {message + ' '} + {query} + + + {error ? ( + {error.message} + ) : (
{results.map((subject, index) => ( ))}
- - ) : ( - <>{message} - )} + )} +
); } diff --git a/data-browser/src/routes/SettingsAgent.tsx b/data-browser/src/routes/SettingsAgent.tsx index a19a0dd9f..7ab2dcb2b 100644 --- a/data-browser/src/routes/SettingsAgent.tsx +++ b/data-browser/src/routes/SettingsAgent.tsx @@ -1,70 +1,22 @@ import * as React from 'react'; -import { useState } from 'react'; -import { Agent } from '@tomic/react'; -import { FaCog, FaEye, FaEyeSlash, FaUser } from 'react-icons/fa'; +import { 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 { LabelStyled } from '../components/forms/InputStyles'; +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 { useNavigate } from 'react-router'; import { ErrorLook } from '../components/ErrorLook'; +import { Guard } from '../components/Guard'; +import { useNavigate } from 'react-router'; +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( @@ -72,76 +24,20 @@ 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 ? ( +

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') && ( + {agent?.subject?.includes('localhost') && (

Warning: { @@ -153,126 +49,24 @@ const SettingsAgent: React.FunctionComponent = () => { You{"'"}re signed in as

- +

- - - ) : ( -

- 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/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/ChatRoomPage.tsx b/data-browser/src/views/ChatRoomPage.tsx index e16e8eb9f..4e6717c80 100644 --- a/data-browser/src/views/ChatRoomPage.tsx +++ b/data-browser/src/views/ChatRoomPage.tsx @@ -21,6 +21,8 @@ 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'; import { ResourcePageProps } from './ResourcePage'; @@ -73,7 +75,7 @@ export function ChatRoomPage({ resource }: ResourcePageProps) { e && e.preventDefault(); if (!disableSend) { - const subject = store.createSubject('messages'); + const subject = store.createSubject('messages', resource.getSubject()); const msgResource = new Resource(subject, true); await msgResource.set( properties.parent, @@ -136,7 +138,7 @@ export function ChatRoomPage({ resource }: ResourcePageProps) { // in Firefox, scrollHeight only works if overflow is set to scroll const height = e.target.scrollHeight; e.target.style.overflow = overflowStyle; - const rowHeight = 25; + const rowHeight = 30; const trows = Math.ceil(height / rowHeight) - 1; if (trows !== textAreaHight) { @@ -161,25 +163,28 @@ export function ChatRoomPage({ resource }: ResourcePageProps) { )} - - - - Send - - + + + + + Send + + + + ); } diff --git a/data-browser/src/views/CollectionPage.tsx b/data-browser/src/views/CollectionPage.tsx index d5c542912..4fa78ad7f 100644 --- a/data-browser/src/views/CollectionPage.tsx +++ b/data-browser/src/views/CollectionPage.tsx @@ -14,6 +14,7 @@ import { FaArrowLeft, FaArrowRight, FaInfo, + FaPlus, FaTable, FaThLarge, } from 'react-icons/fa'; @@ -168,8 +169,9 @@ function Collection({ resource }: ResourcePageProps): JSX.Element { {isClass && ( 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/data-browser/src/views/DocumentPage.tsx b/data-browser/src/views/DocumentPage.tsx index 02fd581a7..aaf7f8a3c 100644 --- a/data-browser/src/views/DocumentPage.tsx +++ b/data-browser/src/views/DocumentPage.tsx @@ -187,7 +187,10 @@ function DocumentPageEdit({ async function addElement(position: number) { // When an element is created, it should be a Resource that has this document as its parent. // or maybe a nested resource? - const elementSubject = store.createSubject('element'); + const elementSubject = store.createSubject( + 'element', + resource.getSubject(), + ); elements.splice(position, 0, elementSubject); try { diff --git a/data-browser/src/views/DrivePage.tsx b/data-browser/src/views/DrivePage.tsx index 70110aa7a..24e30e656 100644 --- a/data-browser/src/views/DrivePage.tsx +++ b/data-browser/src/views/DrivePage.tsx @@ -54,7 +54,7 @@ function DrivePage({ resource }: ResourcePageProps): JSX.Element { - {baseURL.startsWith('http://localhost') && ( + {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 diff --git a/data-browser/src/views/ErrorPage.tsx b/data-browser/src/views/ErrorPage.tsx index 7c452f1c0..7e674dfed 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 { 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 { Guard } from '../components/Guard'; /** * A View for Resource Errors. Not to be confused with the CrashPage, which is @@ -18,13 +18,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." + } +