Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@opendatacapture/schemas": "workspace:*",
"axios": "catalog:",
"esbuild-wasm": "catalog:",
"idb-keyval": "^6.2.2",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"jwt-decode": "^4.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useAppStore } from '@/store';
import { DeleteInstrumentDialog } from './DeleteInstrumentDialog';
import { LoginDialog } from './LoginDialog';
import { RestoreDefaultsDialog } from './RestoreDefaultsDialog';
import { StorageUsageDialog } from './StorageUsageDialog';
import { UploadBundleDialog } from './UploadBundleDialog';
import { UserSettingsDialog } from './UserSettingsDialog';

Expand All @@ -17,6 +18,7 @@ export const ActionsDropdown = () => {
const [showRestoreDefaultsDialog, setShowRestoreDefaultsDialog] = useState(false);
const [showUploadBundleDialog, setShowUploadBundleDialog] = useState(false);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [showStorageUsageDialog, setShowStorageUsageDialog] = useState(false);

const selectedInstrument = useAppStore((store) => store.selectedInstrument);

Expand Down Expand Up @@ -54,6 +56,11 @@ export const ActionsDropdown = () => {
User Settings
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild onSelect={() => setShowStorageUsageDialog(true)}>
<button className="w-full cursor-pointer disabled:cursor-not-allowed disabled:opacity-50" type="button">
Storage Usage
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item asChild onSelect={() => setShowDeleteInstrumentDialog(true)}>
<button
Expand Down Expand Up @@ -86,6 +93,7 @@ export const ActionsDropdown = () => {
}}
/>
<LoginDialog isOpen={showLoginDialog} setIsOpen={setShowLoginDialog} />
<StorageUsageDialog isOpen={showStorageUsageDialog} setIsOpen={setShowStorageUsageDialog} />
</React.Fragment>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ type $LoginData = z.infer<typeof $LoginData>;
const $LoginData = z.object({
apiBaseUrl: z.url(),
username: z.string().min(1),
password: z.string().min(1)
password: z.string().min(1),
legacyLogin: z.boolean()
});

export type LoginDialogProps = {
Expand All @@ -39,10 +40,13 @@ export const LoginDialog = ({ isOpen, setIsOpen }: LoginDialogProps) => {
revalidateToken();
}, [isOpen]);

const getAdminToken = (credentials: $LoginCredentials): ResultAsync<{ accessToken: string }, string> => {
const getAdminToken = (
credentials: $LoginCredentials,
baseUrl: string
): ResultAsync<{ accessToken: string }, string> => {
return asyncResultify(async () => {
try {
const response = await axios.post(`${apiBaseUrl}/v1/auth/login`, credentials, {
const response = await axios.post(`${baseUrl}/v1/auth/login`, credentials, {
headers: {
Accept: 'application/json'
},
Expand All @@ -59,10 +63,10 @@ export const LoginDialog = ({ isOpen, setIsOpen }: LoginDialogProps) => {
});
};

const getLimitedToken = (adminToken: string): ResultAsync<{ accessToken: string }, string> => {
const getLimitedToken = (adminToken: string, baseUrl: string): ResultAsync<{ accessToken: string }, string> => {
return asyncResultify(async () => {
try {
const response = await axios.get(`${apiBaseUrl}/v1/auth/create-instrument-token`, {
const response = await axios.get(`${baseUrl}/v1/auth/create-instrument-token`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${adminToken}`
Expand All @@ -80,20 +84,24 @@ export const LoginDialog = ({ isOpen, setIsOpen }: LoginDialogProps) => {
});
};

const handleSubmit = async ({ apiBaseUrl, ...credentials }: $LoginData) => {
const handleSubmit = async ({ apiBaseUrl, legacyLogin, ...credentials }: $LoginData) => {
updateSettings({ apiBaseUrl });
const adminTokenResult = await getAdminToken(credentials);
const adminTokenResult = await getAdminToken(credentials, apiBaseUrl);
if (adminTokenResult.isErr()) {
addNotification({ type: 'error', title: 'Login Failed', message: adminTokenResult.error });
return;
}
const limitedTokenResult = await getLimitedToken(adminTokenResult.value.accessToken);
if (limitedTokenResult.isErr()) {
addNotification({ type: 'error', title: 'Failed to Get Limited Token', message: limitedTokenResult.error });
return;
}

login(limitedTokenResult.value.accessToken);
if (legacyLogin) {
login(adminTokenResult.value.accessToken);
} else {
const limitedTokenResult = await getLimitedToken(adminTokenResult.value.accessToken, apiBaseUrl);
if (limitedTokenResult.isErr()) {
addNotification({ type: 'error', title: 'Failed to Get Limited Token', message: limitedTokenResult.error });
return;
}
login(limitedTokenResult.value.accessToken);
}

addNotification({ type: 'success' });
};
Expand Down Expand Up @@ -137,6 +145,20 @@ export const LoginDialog = ({ isOpen, setIsOpen }: LoginDialogProps) => {
placeholder: 'e.g., https://demo.opendatacapture.org/api',
label: 'API Base URL',
variant: 'input'
},
legacyLogin: {
description: [
"Use the user's full access token instead of a granular access token.",
'Note that this can introduce security risks and should not be used on shared machines.',
'It is required only for ODC versions prior to v1.12.0.'
].join(''),
kind: 'boolean',
label: 'Legacy Login Mode',
variant: 'radio',
options: {
false: 'No (Recommended)',
true: 'Yes'
}
}
}
},
Expand All @@ -156,7 +178,7 @@ export const LoginDialog = ({ isOpen, setIsOpen }: LoginDialogProps) => {
}
}
]}
initialValues={{ apiBaseUrl }}
initialValues={{ apiBaseUrl, legacyLogin: false }}
validationSchema={$LoginData}
onSubmit={async (data) => {
await handleSubmit(data);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';

import { formatByteSize } from '@douglasneuroinformatics/libjs';
import { Button, Dialog } from '@douglasneuroinformatics/libui/components';

import { useAppStore } from '@/store';
export type StorageUsageDialogProps = {
isOpen: boolean;
setIsOpen: (value: boolean) => void;
};

export const StorageUsageDialog = ({ isOpen, setIsOpen }: StorageUsageDialogProps) => {
const [storageEstimate, setStorageEstimate] = useState<null | StorageEstimate>(null);
const [updateKey, setUpdateKey] = useState(0);
const [message, setMessage] = useState<null | string>('Loading...');

const updateStorage = async () => {
setMessage('Loading...');
const [updated] = await Promise.all([
await navigator.storage.estimate(),
new Promise((resolve) => setTimeout(resolve, 500))
]);
setStorageEstimate(updated);
setMessage(null);
};

useEffect(() => {
void updateStorage();
}, [isOpen, updateKey]);

return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Storage Usage</Dialog.Title>
<Dialog.Description>
Check the details below to see how much storage your browser is using for instruments and how much space is
still available.
</Dialog.Description>
</Dialog.Header>
<Dialog.Body>
{message ? (
<p>{message}</p>
) : (
<>
<p>Usage: {storageEstimate?.usage ? formatByteSize(storageEstimate.usage, true) : 'N/A'}</p>
<p>Quota: {storageEstimate?.quota ? formatByteSize(storageEstimate.quota, true) : 'N/A'} </p>
</>
)}
</Dialog.Body>
<Dialog.Footer>
<Button
variant="danger"
onClick={() => {
useAppStore.persist.clearStorage();
setMessage('Deleting...');
void new Promise((resolve) => setTimeout(resolve, 2000)).then(() => {
setUpdateKey(updateKey + 1);
});
}}
>
Drop Database (Irreversible)
</Button>
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
};
16 changes: 15 additions & 1 deletion apps/playground/src/store/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable import/exports-last */
import { del, get, set } from 'idb-keyval';
import { jwtDecode } from 'jwt-decode';
import { pick } from 'lodash-es';
import { create } from 'zustand';
import { createJSONStorage, devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import type { StateStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

import { resolveIndexFilename } from '@/utils/file';
Expand All @@ -16,6 +18,18 @@ import { createViewerSlice } from './slices/viewer.slice';

import type { AppStore } from './types';

const storage: StateStorage = {
getItem: async (name: string): Promise<null | string> => {
return (await get(name)) ?? null;
},
removeItem: async (name: string): Promise<void> => {
await del(name);
},
setItem: async (name: string, value: string): Promise<void> => {
await set(name, value);
}
};

export const useAppStore = create(
devtools(
persist(
Expand Down Expand Up @@ -64,7 +78,7 @@ export const useAppStore = create(
_accessToken: state.auth?.accessToken
};
},
storage: createJSONStorage(() => localStorage),
storage: createJSONStorage(() => storage),
version: 1
}
)
Expand Down
Loading
Loading