Skip to content

Commit ab672e5

Browse files
committed
Use SubtleCrypto to store key #1013
1 parent 91cbfcb commit ab672e5

File tree

27 files changed

+1255
-754
lines changed

27 files changed

+1255
-754
lines changed

browser/data-browser/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"codemirror-json-schema": "^0.8.1",
6060
"downshift": "^9.0.10",
6161
"emoji-mart": "^5.6.0",
62+
"idb-keyval": "^6.2.2",
6263
"ollama-ai-provider-v2": "^1.5.5",
6364
"polished": "^4.3.1",
6465
"prismjs": "^1.30.0",

browser/data-browser/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { StoreContext, Store, enableYjs } from '@tomic/react';
22

33
import { isDev } from './config';
44
import { registerHandlers } from './handlers';
5-
import { getAgentFromLocalStorage } from './helpers/agentStorage';
5+
import { getAgentFromIDB } from './helpers/agentStorage';
66
import { registerCustomCreateActions } from './components/forms/NewForm/CustomCreateActions';
77
import { serverURLStorage } from './helpers/serverURLStorage';
88

@@ -25,7 +25,7 @@ function fixDevUrl(url: string) {
2525
*/
2626

2727
const serverUrl = fixDevUrl(serverURLStorage.get() ?? window.location.origin);
28-
const initalAgent = getAgentFromLocalStorage();
28+
const initalAgent = await getAgentFromIDB();
2929

3030
// Initialize the store
3131
const store = new Store({

browser/data-browser/src/Providers.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ const ErrBoundary = window.bugsnagApiKey
2828
? initBugsnag(window.bugsnagApiKey)
2929
: ErrorBoundary;
3030

31+
const VALID_PROPS = ['popover', 'closedby'];
32+
3133
// This implements the default behavior from styled-components v5
3234
const shouldForwardProp: ShouldForwardProp<'web'> = (propName, target) => {
3335
if (typeof target === 'string') {
3436
// @emotion/is-prop-valid does not support popover, so we need to forward it manually.
35-
if (propName === 'popover') {
37+
if (VALID_PROPS.includes(propName)) {
3638
return true;
3739
}
3840

browser/data-browser/src/components/Button.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,6 @@ export const ButtonSubtle = styled(ButtonDefault)`
191191
--button-border-color-hover: ${p => p.theme.colors.main};
192192
--button-text-color: ${p => p.theme.colors.textLight};
193193
--button-text-color-hover: ${p => p.theme.colors.main};
194-
195-
box-shadow: ${p => (p.theme.darkMode ? 'none' : p.theme.boxShadow)};
196194
`;
197195

198196
export const ButtonAlert = styled(ButtonDefault)`

browser/data-browser/src/components/CodeBlock.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,44 @@
1-
import { useState } from 'react';
1+
import { useRef, useState } from 'react';
22
import toast from 'react-hot-toast';
33
import { FaCheck, FaCopy } from 'react-icons/fa';
44
import { styled } from 'styled-components';
55
import { Button } from './Button';
6+
import clsx from 'clsx';
67

78
interface CodeBlockProps {
89
content?: string;
910
loading?: boolean;
1011
wordWrap?: boolean;
12+
className?: string;
13+
onCopy?: () => void;
1114
}
1215

1316
export function CodeBlock({
1417
content,
1518
loading,
1619
wordWrap = false,
20+
className,
21+
onCopy,
1722
}: CodeBlockProps) {
23+
const preRef = useRef<HTMLPreElement>(null);
1824
const [isCopied, setIsCopied] = useState<string | undefined>(undefined);
1925

2026
function copyToClipboard() {
2127
setIsCopied(content);
2228
navigator.clipboard.writeText(content || '');
2329
toast.success('Copied to clipboard');
30+
onCopy?.();
2431
}
2532

2633
return (
2734
<CodeBlockStyled
35+
onCopy={() => {
36+
onCopy?.();
37+
setIsCopied(content);
38+
}}
39+
ref={preRef}
2840
data-code-content={content}
29-
className={wordWrap ? 'word-wrap' : ''}
41+
className={clsx({ 'word-wrap': wordWrap }, className)}
3042
>
3143
{loading ? (
3244
'loading...'

browser/data-browser/src/components/Dialog/index.tsx

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface InternalDialogProps {
2323
show: boolean;
2424
onClose: (success: boolean) => void;
2525
onClosed: () => void;
26+
disableLightDismiss?: boolean;
2627
width?: CSS.Property.Width;
2728
}
2829

@@ -57,7 +58,7 @@ type DialogSlotComponent = React.FC<
5758
* return (
5859
* <button onClick={show}>Open</button>
5960
* <Dialog {...props}>
60-
* <DialogTitle>Title</DialogTitle>
61+
* <Dialog.Title>Title</Dialog.Title>
6162
* ...
6263
* </Dialog>
6364
* );
@@ -82,6 +83,7 @@ const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
8283
children,
8384
show,
8485
width,
86+
disableLightDismiss = false,
8587
onClose,
8688
onClosed,
8789
}) => {
@@ -100,6 +102,10 @@ const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
100102
React.MouseEventHandler<HTMLDialogElement>
101103
>(
102104
e => {
105+
if (disableLightDismiss) {
106+
return;
107+
}
108+
103109
if (!isTopLevel) {
104110
// Don't react to closing events if the dialog is not on top.
105111

@@ -113,16 +119,49 @@ const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
113119
cancelDialog();
114120
}
115121
},
116-
[cancelDialog, isTopLevel],
122+
[cancelDialog, isTopLevel, disableLightDismiss],
117123
);
118124

125+
// Prevent native dialog cancel event when disableLightDismiss is true
126+
// This must be set up before the dialog is shown
127+
// Only needed for safary right now because it doesn't support the closedby attribute.
128+
// https://caniuse.com/wf-dialog-closedby
129+
useEffect(() => {
130+
const dialog = dialogRef.current;
131+
132+
if (!dialog) {
133+
return;
134+
}
135+
136+
const handleCancel = (e: Event) => {
137+
if (disableLightDismiss) {
138+
e.preventDefault();
139+
e.stopPropagation();
140+
} else if (isTopLevel && !hasOpenInnerPopup) {
141+
// Only handle cancel if we're the top level dialog
142+
// The useHotkeys below will call cancelDialog
143+
}
144+
};
145+
146+
// Use capture phase to ensure we get the event first
147+
dialog.addEventListener('cancel', handleCancel, true);
148+
149+
return () => {
150+
dialog.removeEventListener('cancel', handleCancel, true);
151+
};
152+
}, [disableLightDismiss, isTopLevel, hasOpenInnerPopup]);
153+
119154
// Close the dialog when the escape key is pressed
120155
useHotkeys(
121156
'esc',
122157
() => {
123-
cancelDialog();
158+
if (!disableLightDismiss) {
159+
cancelDialog();
160+
}
161+
},
162+
{
163+
enabled: show && !hasOpenInnerPopup && isTopLevel,
124164
},
125-
{ enabled: show && !hasOpenInnerPopup && isTopLevel },
126165
);
127166

128167
// When closing the `data-closing` attribute must be set before rendering so the animation has started when the regular useEffect is called.
@@ -158,15 +197,18 @@ const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
158197
onMouseDown={handleOutSideClick}
159198
$width={width}
160199
data-top-level={isTopLevel}
200+
closedby={disableLightDismiss ? 'none' : 'closerequest'}
161201
>
162202
<StyledInnerDialog ref={innerDialogRef}>
163203
<PopoverContainer>
164204
<DropdownContainer>
165-
<CloseButtonSlot slot='close'>
166-
<Button icon onClick={cancelDialog} aria-label='close'>
167-
<FaTimes />
168-
</Button>
169-
</CloseButtonSlot>
205+
{!disableLightDismiss && (
206+
<CloseButtonSlot slot='close'>
207+
<Button icon onClick={cancelDialog} aria-label='close'>
208+
<FaTimes />
209+
</Button>
210+
</CloseButtonSlot>
211+
)}
170212
{children}
171213
</DropdownContainer>
172214
</PopoverContainer>

browser/data-browser/src/components/ErrorLook.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export const ErrorLook = styled.span`
1616
${errorLookStyle}
1717
`;
1818

19+
export const SimpleErrorBlock = styled.div`
20+
color: ${props => props.theme.colors.alert};
21+
border-radius: ${props => props.theme.radius};
22+
border: 1px solid ${props => props.theme.colors.alert};
23+
padding: ${props => props.theme.size(2)};
24+
`;
25+
1926
export interface ErrorBlockProps {
2027
error: Error;
2128
showTrace?: boolean;

browser/data-browser/src/components/SideBar/SideBarDrive.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { paths } from '../../routes/paths';
1616
import { Button } from '../Button';
1717
import { ResourceSideBar } from './ResourceSideBar/ResourceSideBar';
1818
import { SideBarHeader } from './SideBarHeader';
19-
import { ErrorLook } from '../ErrorLook';
19+
import { SimpleErrorBlock } from '../ErrorLook';
2020
import { DriveSwitcher } from './DriveSwitcher';
2121
import { Row } from '../Row';
2222
import { useCurrentSubject } from '../../helpers/useCurrentSubject';
@@ -70,6 +70,10 @@ export function SideBarDrive({
7070
});
7171
}, [store, currentResource]);
7272

73+
const driveName = driveResource.isUnauthorized()
74+
? 'Unauthorized'
75+
: title || drive;
76+
7377
return (
7478
<>
7579
<SideBarHeader>
@@ -85,7 +89,7 @@ export function SideBarDrive({
8589
}}
8690
>
8791
<DriveTitle data-testid='current-drive-title'>
88-
{title || drive}{' '}
92+
{driveName}{' '}
8993
</DriveTitle>
9094
</TitleButton>
9195
<HeadingButtonWrapper gap='0'>
@@ -126,7 +130,7 @@ export function SideBarDrive({
126130
(driveResource.isUnauthorized()
127131
? agent
128132
? 'unauthorized'
129-
: driveResource.error.message
133+
: 'This drive is private, sign in to view it'
130134
: driveResource.error.message)}
131135
</SideBarErr>
132136
)}
@@ -185,8 +189,9 @@ const TitleButton = styled(Button)<{ current?: boolean }>`
185189
}
186190
`;
187191

188-
const SideBarErr = styled(ErrorLook)`
189-
padding-left: ${props => props.theme.margin}rem;
192+
const SideBarErr = styled(SimpleErrorBlock)`
193+
margin-inline-start: ${props => props.theme.size(2)};
194+
margin-inline-end: ${props => props.theme.size()};
190195
`;
191196

192197
const ListWrapper = styled.div`

browser/data-browser/src/handlers/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Store, StoreEvents } from '@tomic/react';
2-
import { saveAgentToLocalStorage } from '../helpers/agentStorage';
32
import { errorHandler } from './errorHandler';
43
import {
54
buildSideBarNewResourceHandler,
@@ -16,5 +15,4 @@ export function registerHandlers(store: Store) {
1615
buildSideBarRemoveResourceHandler(store),
1716
);
1817
store.on(StoreEvents.Error, errorHandler);
19-
store.on(StoreEvents.AgentChanged, saveAgentToLocalStorage);
2018
}
Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,65 @@
1-
import { Agent } from '@tomic/react';
1+
import { Agent, SubtleCryptoProvider } from '@tomic/react';
2+
import { del, get, set } from 'idb-keyval';
23

3-
const AGENT_LOCAL_STORAGE_KEY = 'agent';
4+
const AGENT_IDB_KEY = 'atomic.agent';
45

5-
export function getAgentFromLocalStorage(): Agent | undefined {
6-
const secret = localStorage.getItem(AGENT_LOCAL_STORAGE_KEY);
6+
interface StoredAgent {
7+
keyPair: CryptoKeyPair;
8+
subject: string;
9+
}
10+
11+
export async function getAgentFromIDB(): Promise<Agent | undefined> {
12+
const storedAgent = (await get(AGENT_IDB_KEY)) as StoredAgent | undefined;
713

8-
if (!secret) {
14+
if (!storedAgent) {
915
return undefined;
1016
}
1117

1218
try {
13-
return Agent.fromSecret(secret);
19+
return new Agent(
20+
new SubtleCryptoProvider(storedAgent.keyPair),
21+
storedAgent.subject,
22+
);
1423
} catch (e) {
1524
console.error(e);
1625

1726
return undefined;
1827
}
1928
}
29+
export async function saveAgentToIDB(
30+
keyPair: CryptoKeyPair,
31+
subject: string,
32+
): Promise<void>;
33+
export async function saveAgentToIDB(secret: string | undefined): Promise<void>;
34+
export async function saveAgentToIDB(
35+
keyPairOrSecret: CryptoKeyPair | string | undefined,
36+
subject?: string,
37+
): Promise<void> {
38+
let storedAgent: StoredAgent;
2039

21-
export function saveAgentToLocalStorage(agent: Agent | undefined): void {
22-
if (agent) {
23-
localStorage.setItem(AGENT_LOCAL_STORAGE_KEY, agent.buildSecret());
40+
if (keyPairOrSecret === undefined) {
41+
await del(AGENT_IDB_KEY);
42+
43+
return;
44+
}
45+
46+
if (typeof keyPairOrSecret === 'string') {
47+
const [keyPair, newSubject] =
48+
await SubtleCryptoProvider.createKeysFromSecret(keyPairOrSecret);
49+
storedAgent = {
50+
keyPair,
51+
subject: newSubject,
52+
};
2453
} else {
25-
localStorage.removeItem(AGENT_LOCAL_STORAGE_KEY);
54+
if (!subject) {
55+
throw new Error('Subject is required');
56+
}
57+
58+
storedAgent = {
59+
keyPair: keyPairOrSecret,
60+
subject,
61+
};
2662
}
63+
64+
await set(AGENT_IDB_KEY, storedAgent);
2765
}

0 commit comments

Comments
 (0)