Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial auth implementation #30

Merged
merged 10 commits into from
Apr 1, 2025
Merged
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
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions packages/client/.env
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# App metadata
APP_TITLE=STAC Manager
APP_DESCRIPTION=Plugin based STAC editor
## Don't set the public url here. Check the README.md file for more information
# PUBLIC_URL= Do not set here

# Api variables
REACT_APP_STAC_BROWSER=
REACT_APP_STAC_API=

## Keycloak auth variables
REACT_APP_KEYCLOAK_URL=
REACT_APP_KEYCLOAK_CLIENT_ID=
REACT_APP_KEYCLOAK_REALM=

## Theming
# REACT_APP_THEME_PRIMARY_COLOR='#6A5ACD'
# REACT_APP_THEME_SECONDARY_COLOR='#048A81'

## Don't set the public url here. Check the README.md file for more information
# PUBLIC_URL= Do not set here
12 changes: 10 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
@@ -14,14 +14,18 @@ Some client options are controlled by environment variables. These are:
## Title and description of the app for metadata
APP_TITLE
APP_DESCRIPTION
## If the app is being served in from a subfolder, the domain url must be set.
PUBLIC_URL
# API
## If the app is being served in from a subfolder, the domain url must be set.
PUBLIC_URL
REACT_APP_STAC_BROWSER
REACT_APP_STAC_API
# Auth
REACT_APP_KEYCLOAK_URL
REACT_APP_KEYCLOAK_CLIENT_ID
REACT_APP_KEYCLOAK_REALM
# Theming
REACT_APP_THEME_PRIMARY_COLOR
REACT_APP_THEME_SECONDARY_COLOR
@@ -40,6 +44,10 @@ You must provide a value for the `REACT_APP_STAC_API` environment variable. This

If the `REACT_APP_STAC_BROWSER` environment variable is not set, [Radiant Earth's STAC Browser](https://radiantearth.github.io/stac-browser/) will be used by default, which will connect to the STAC API specified in `REACT_APP_STAC_API`.

**Auth**
The client uses Keycloack for authentication, which is disabled by default. To
enable it you must provide values for the `REACT_APP_KEYCLOAK_*` environment variables. These can be obtained through the Keycloak server.

### Theming

The Stac manager client allows for simple theming to give the instance a different look and feel.
2 changes: 2 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
@@ -69,11 +69,13 @@
"@turf/bbox": "^7.1.0",
"@turf/bbox-polygon": "^7.1.0",
"@types/jest": "^29.5.14",
"@types/keycloak-js": "^2.5.4",
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"formik": "^2.4.6",
"framer-motion": "^10.16.5",
"keycloak-js": "^26.1.4",
"mapbox-gl-draw-rectangle-mode": "^1.0.4",
"maplibre-gl": "^3.6.2",
"polished": "^4.3.1",
270 changes: 158 additions & 112 deletions packages/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,131 +1,177 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import {
ChakraProvider,
Box,
Container,
Flex,
Heading,
Text,
Badge,
Divider,
Fade,
Image
} from '@chakra-ui/react';
import { StacApiProvider } from '@developmentseed/stac-react';
import { PluginConfigProvider } from '@stac-manager/data-core';
import { keyframes } from '@emotion/react';
import { Route, Routes } from 'react-router-dom';
import {
CollecticonCog,
CollecticonHeart
} from '@devseed-ui/collecticons-chakra';

import theme from './theme/theme';
import { MainNavigation } from './components';
import Home from './pages/Home';
import CollectionList from './pages/CollectionList';
import { CollectionForm } from './pages/CollectionForm';
import ItemDetail from './pages/ItemDetail';
import NotFound from './pages/NotFound';
import CollectionDetail from './pages/CollectionDetail';
import Sandbox from './pages/Sandbox';
import { config } from './plugin-system/config';
import { RequireAuth } from '$components/auth/RequireAuth';
import MainNavigation from '$components/MainNavigation';
import Home from '$pages/Home';
import CollectionList from '$pages/CollectionList';
import { CollectionForm } from '$pages/CollectionForm';
import ItemDetail from '$pages/ItemDetail';
import NotFound from '$pages/NotFound';
import CollectionDetail from '$pages/CollectionDetail';
import Sandbox from '$pages/Sandbox';

const publicUrl = process.env.PUBLIC_URL || '';
import { useKeycloak } from './auth/Context';
import SmartLink from '$components/SmartLink';

let basename: string | undefined;
if (publicUrl) {
try {
basename = new URL(publicUrl).pathname;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// no-op
const rotate = keyframes`
from {
transform: rotate(0deg);
}
}
to {
transform: rotate(360deg);
}
`;

const rotate2 = keyframes`
from {
transform: rotate(22.5deg);
}
to {
transform: rotate(382.5deg);
}
`;

export function App() {
const { initStatus } = useKeycloak();

export const App = () => (
<ChakraProvider theme={theme}>
<StacApiProvider apiUrl={process.env.REACT_APP_STAC_API!}>
<PluginConfigProvider config={config}>
<Router basename={basename}>
<Container
maxW='container.xl'
minH='100vh'
display='flex'
flexDirection='column'
const isLoading = initStatus === 'loading';

return (
<>
<Fade in={isLoading} unmountOnExit>
<Flex
minW='100vw'
minH='100vh'
bg='white'
align='center'
justify='center'
>
<CollecticonCog
size='5em'
color='base.300'
animation={`${rotate} 4s linear infinite`}
/>
<CollecticonCog
ml={-2}
size='5em'
color='base.300'
animation={`${rotate2} 4s linear infinite reverse`}
/>
</Flex>
</Fade>
{!isLoading && (
<Container
maxW='container.lg'
minH='100vh'
display='flex'
flexDirection='column'
gap={4}
>
<Flex
as='header'
gap={4}
alignItems='center'
justifyContent='space-between'
py={8}
>
<Flex
as='header'
gap={4}
alignItems='center'
justifyContent='space-between'
py={8}
>
<Flex gap={4} alignItems='center'>
<Image
src={`${publicUrl}/meta/icon-512.png`}
width={8}
aspectRatio={1}
borderRadius='md'
/>
<Divider
orientation='vertical'
borderColor='base.200a'
h='1rem'
borderLeftWidth='2px'
/>
<Heading as='p' size='sm'>
STAC Manager
</Heading>
</Flex>

<MainNavigation />
<Flex gap={4} alignItems='center'>
<Image
src={`${process.env.PUBLIC_URL || ''}/meta/icon-512.png`}
width={8}
aspectRatio={1}
borderRadius='md'
/>
<Divider
orientation='vertical'
borderColor='base.200a'
h='1rem'
borderLeftWidth='2px'
/>
<Heading as='p' size='sm'>
STAC Manager
</Heading>
</Flex>
<Box as='main'>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/collections/' element={<CollectionList />} />
<Route path='/collections/new/' element={<CollectionForm />} />
<Route
path='/collections/:collectionId/'
element={<CollectionDetail />}
/>
<Route
path='/collections/:collectionId/edit/'
element={<CollectionForm />}
/>
<Route
path='/collections/:collectionId/items/:itemId/'
element={<ItemDetail />}
/>
<Route path='/sandbox' element={<Sandbox />} />
<Route path='*' element={<NotFound />} />
</Routes>
</Box>
<Flex
as='footer'
gap={4}
alignItems='center'
justifyContent='space-between'
mt='auto'
py={8}
>
<Flex gap={4} alignItems='center'>
<Text as='span'>
Powered by{' '}
<strong>
STAC Manager{' '}
<Badge bg='base.400a' color='surface.500' px='0.375rem'>
{process.env.APP_VERSION}
</Badge>
</strong>{' '}
</Text>
<Divider
orientation='vertical'
borderColor='base.200a'
h='1em'
/>
{new Date().getFullYear()}
</Flex>
</Flex>
</Container>
</Router>
</PluginConfigProvider>
</StacApiProvider>
</ChakraProvider>
);

<MainNavigation />
</Flex>
<Box as='main'>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/collections/' element={<CollectionList />} />
<Route
path='/collections/new/'
element={<RequireAuth Component={CollectionForm} />}
/>
<Route
path='/collections/:collectionId/'
element={<CollectionDetail />}
/>
<Route
path='/collections/:collectionId/edit/'
element={<RequireAuth Component={CollectionForm} />}
/>
<Route
path='/collections/:collectionId/items/:itemId/'
element={<ItemDetail />}
/>
<Route path='/sandbox' element={<Sandbox />} />
<Route path='*' element={<NotFound />} />
</Routes>
</Box>
<AppFooter />
</Container>
)}
</>
);
}

function AppFooter() {
return (
<Flex
as='footer'
gap={4}
alignItems='center'
justifyContent='space-between'
mt='auto'
p={4}
>
<Flex gap={4} alignItems='center' width='100%'>
<Text as='span'>
Powered by{' '}
<strong>
STAC Manager{' '}
<Badge bg='base.400a' color='surface.500' px='0.375rem'>
{process.env.APP_VERSION}
</Badge>
</strong>{' '}
</Text>
<Divider orientation='vertical' borderColor='base.200a' h='1em' />
{new Date().getFullYear()}
<Text as='span' ml='auto'>
Made with <CollecticonHeart meaningful title='love' /> by{' '}
<SmartLink to='https://developmentseed.org' color='inherit'>
Development Seed
</SmartLink>
.
</Text>
</Flex>
</Flex>
);
}
118 changes: 118 additions & 0 deletions packages/client/src/auth/Context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React, {
createContext,
useContext,
useEffect,
useRef,
useState
} from 'react';
import Keycloak, { KeycloakInstance } from 'keycloak-js';

const url = process.env.REACT_APP_KEYCLOAK_URL;
const realm = process.env.REACT_APP_KEYCLOAK_REALM;
const clientId = process.env.REACT_APP_KEYCLOAK_CLIENT_ID;

const isAuthEnabled = !!(url && realm && clientId);

const keycloak: KeycloakInstance | undefined = isAuthEnabled
? new (Keycloak as any)({
url,
realm,
clientId
})
: undefined;

export type KeycloakContextProps = {
initStatus: 'loading' | 'success' | 'error';
isLoading: boolean;
profile?: Keycloak.KeycloakProfile;
} & (
| {
keycloak: KeycloakInstance;
isEnabled: true;
}
| {
keycloak: undefined;
isEnabled: false;
}
);

const KeycloakContext = createContext<KeycloakContextProps>({
initStatus: 'loading',
isEnabled: isAuthEnabled
} as KeycloakContextProps);

export const KeycloakProvider = (props: { children: React.ReactNode }) => {
const [initStatus, setInitStatus] =
useState<KeycloakContextProps['initStatus']>('loading');
const [profile, setProfile] = useState<
Keycloak.KeycloakProfile | undefined
>();

const wasInit = useRef(false);

useEffect(() => {
async function initialize() {
if (!keycloak) return;
// Keycloak can only be initialized once. This is a workaround to avoid
// multiple initialization attempts, specially by React double rendering.
if (wasInit.current) return;
wasInit.current = true;

try {
await keycloak.init({
// onLoad: 'login-required',
onLoad: 'check-sso',
checkLoginIframe: false
});
if (keycloak.authenticated) {
const profile =
await (keycloak.loadUserProfile() as unknown as Promise<Keycloak.KeycloakProfile>);
setProfile(profile);
}

setInitStatus('success');
} catch (err) {
setInitStatus('error');
// eslint-disable-next-line no-console
console.error('Failed to initialize keycloak adapter:', err);
}
}
initialize();
}, []);

const base = {
initStatus,
isLoading: isAuthEnabled && initStatus === 'loading',
profile
};

return (
<KeycloakContext.Provider
value={
isAuthEnabled
? {
...base,
keycloak: keycloak!,
isEnabled: true
}
: {
...base,
keycloak: undefined,
isEnabled: false
}
}
>
{props.children}
</KeycloakContext.Provider>
);
};

export const useKeycloak = () => {
const ctx = useContext(KeycloakContext);

if (!ctx) {
throw new Error('useKeycloak must be used within a KeycloakProvider');
}

return ctx;
};
19 changes: 19 additions & 0 deletions packages/client/src/components/DeleteMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';
import { MenuItemProps } from '@chakra-ui/react';
import { CollecticonTrashBin } from '@devseed-ui/collecticons-chakra';

import { MenuItemWithAuth } from './auth/MenuItemWithAuth';

export function DeleteMenuItem(props: MenuItemProps) {
return (
<MenuItemWithAuth
icon={<CollecticonTrashBin />}
color='danger.500'
_hover={{ bg: 'danger.200' }}
_focus={{ bg: 'danger.200' }}
{...props}
>
Delete
</MenuItemWithAuth>
);
}
34 changes: 27 additions & 7 deletions packages/client/src/components/MainNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import React from 'react';
import { Box, List, ListItem, Button, ButtonProps } from '@chakra-ui/react';
import {
List,
ListItem,
Button,
ButtonProps,
Flex,
Divider
} from '@chakra-ui/react';
import {
CollecticonFolder,
CollecticonPlusSmall
} from '@devseed-ui/collecticons-chakra';

import SmartLink, { SmartLinkProps } from './SmartLink';
import { UserInfo } from './auth/UserInfo';
import { useKeycloak } from 'src/auth/Context';

function NavItem(props: ButtonProps & SmartLinkProps) {
return (
@@ -25,17 +34,28 @@ function NavItem(props: ButtonProps & SmartLinkProps) {
}

function MainNavigation() {
const { keycloak } = useKeycloak();

return (
<Box as='nav' aria-label='Main'>
<Flex as='nav' aria-label='Main' gap={4} alignItems='center'>
<List display='flex' gap={2}>
<NavItem to='/collections/' leftIcon={<CollecticonFolder />}>
<NavItem to='/collections/' rightIcon={<CollecticonFolder />}>
Browse
</NavItem>
<NavItem to='/collections/new' leftIcon={<CollecticonPlusSmall />}>
Create
</NavItem>
{keycloak?.authenticated && (
<NavItem to='/collections/new' rightIcon={<CollecticonPlusSmall />}>
Create
</NavItem>
)}
</List>
</Box>
<Divider
orientation='vertical'
borderLeftWidth='2px'
borderColor='base.200'
h='1rem'
/>
<UserInfo />
</Flex>
);
}

19 changes: 19 additions & 0 deletions packages/client/src/components/auth/ButtonWithAuth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';
import { Button, ButtonProps, forwardRef } from '@chakra-ui/react';
import SmartLink, { SmartLinkProps } from '../SmartLink';
import { useKeycloak } from 'src/auth/Context';

export const ButtonWithAuth = forwardRef<
SmartLinkProps & ButtonProps,
typeof Button
>((props, ref) => {
const { isEnabled, keycloak } = useKeycloak();

if (!isEnabled) {
return <Button ref={ref} as={SmartLink} {...props} />;
}

return (
keycloak.authenticated && <Button ref={ref} as={SmartLink} {...props} />
);
});
21 changes: 21 additions & 0 deletions packages/client/src/components/auth/Callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { Flex, Text } from '@chakra-ui/react';

import { InnerPageHeader } from '$components/InnerPageHeader';
import usePageTitle from '$hooks/usePageTitle';

export default function Callback() {
usePageTitle('Authorizing');

return (
<Flex direction='column' gap={4}>
<InnerPageHeader
title='You are being logging in'
overline='Authorizing'
/>
<Flex direction='column' gap={2} p={4}>
<Text>Loading resources. Hang tight!</Text>
</Flex>
</Flex>
);
}
15 changes: 15 additions & 0 deletions packages/client/src/components/auth/MenuItemWithAuth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import { forwardRef, MenuItem, MenuItemProps } from '@chakra-ui/react';
import { useKeycloak } from 'src/auth/Context';

export const MenuItemWithAuth = forwardRef<MenuItemProps, typeof MenuItem>(
(props, ref) => {
const { isEnabled, keycloak } = useKeycloak();

if (!isEnabled) {
return <MenuItem ref={ref} {...props} />;
}

return keycloak.authenticated && <MenuItem ref={ref} {...props} />;
}
);
47 changes: 47 additions & 0 deletions packages/client/src/components/auth/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { Button, Flex, Text } from '@chakra-ui/react';

import { InnerPageHeader } from '$components/InnerPageHeader';
import usePageTitle from '$hooks/usePageTitle';
import { useKeycloak } from 'src/auth/Context';

export function RequireAuth(
props: {
Component: React.ElementType;
} & Record<string, any>
) {
const { Component, ...rest } = props;
usePageTitle('Authentication Required');

const auth = useKeycloak();

if (!auth.isEnabled) {
return <Component {...rest} />;
}
const { keycloak } = auth;

if (keycloak.authenticated) {
return <Component {...rest} />;
}

return (
<Flex direction='column' gap={4}>
<InnerPageHeader title='Authentication Required' overline='Oops' />
<Flex direction='column' gap={2} p={4}>
<Text>You need to login to access this content.</Text>
<Button
alignSelf='flex-start'
variant='solid'
colorScheme='primary'
onClick={() => {
keycloak.login({
redirectUri: window.location.href
});
}}
>
Login
</Button>
</Flex>
</Flex>
);
}
76 changes: 76 additions & 0 deletions packages/client/src/components/auth/UserInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { useEffect, useState } from 'react';
import { Avatar, Button } from '@chakra-ui/react';
import {
CollecticonLogin,
CollecticonLogout
} from '@devseed-ui/collecticons-chakra';

import { useKeycloak } from 'src/auth/Context';

async function hash(string: string) {
const utf8 = new TextEncoder().encode(string);
const hashBuffer = await crypto.subtle.digest('SHA-256', utf8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((bytes) => bytes.toString(16).padStart(2, '0'))
.join('');
return hashHex;
}

export function UserInfo() {
const { profile, isLoading, isEnabled, keycloak } = useKeycloak();

const [userEmailHash, setUserEmailHash] = useState<string>('');
useEffect(() => {
if (profile?.email) {
hash(profile.email).then(setUserEmailHash);
}
}, [profile?.email]);

if (!isEnabled) {
return null;
}

const isAuthenticated = keycloak.authenticated;

if (!isAuthenticated || !profile || isLoading) {
return (
<Button
variant='outline'
rightIcon={<CollecticonLogin />}
onClick={() => {
if (!isLoading) {
keycloak.login({
redirectUri: window.location.href
});
}
}}
>
Login
</Button>
);
}

const username =
`${profile.firstName} ${profile.lastName}`.trim() || profile.username;

return (
<Button
variant='outline'
rightIcon={<CollecticonLogout />}
leftIcon={
<Avatar
size='sm'
name={username}
bg='secondary.500'
color='white'
borderRadius='4px'
src={`https://www.gravatar.com/avatar/${userEmailHash}?d=404`}
/>
}
pl='2px'
>
Logout
</Button>
);
}
51 changes: 49 additions & 2 deletions packages/client/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import React, { useEffect } from 'react';
import { ColorModeScript } from '@chakra-ui/react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
import { StacApiProvider } from '@developmentseed/stac-react';
import { PluginConfigProvider } from '@stac-manager/data-core';

import { App } from './App';
import theme from './theme/theme';
import { config } from './plugin-system/config';
import { KeycloakProvider } from './auth/Context';

const publicUrl = process.env.PUBLIC_URL || '';

let basename: string | undefined;
if (publicUrl) {
try {
basename = new URL(publicUrl).pathname;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// no-op
}
}

const composingComponents = [
[ChakraProvider, { theme }],
[Router, { basename }],
[KeycloakProvider, {}],
[StacApiProvider, { apiUrl: process.env.REACT_APP_STAC_API! }],
[PluginConfigProvider, { config }]
] as const;

// Root component.
function Root() {
@@ -17,7 +43,9 @@ function Root() {
return (
<React.StrictMode>
<ColorModeScript />
<App />
<Composer components={composingComponents}>
<App />
</Composer>
</React.StrictMode>
);
}
@@ -26,6 +54,25 @@ const rootNode = document.querySelector('#app-container')!;
const root = createRoot(rootNode);
root.render(<Root />);

/**
* Composes components to to avoid deep nesting trees. Useful for contexts.
*
* @param {node} children Component children
* @param {array} components The components to compose.
*/
function Composer(props: {
children: React.ReactNode;
components: readonly (readonly [React.ComponentType<any>, any])[];
}) {
const { children, components } = props;
const itemToCompose = [...components].reverse();

return itemToCompose.reduce(
(acc, [Component, props = {}]) => <Component {...props}>{acc}</Component>,
children
);
}

Object.defineProperty(Array.prototype, 'last', {
enumerable: false,
configurable: true,
28 changes: 9 additions & 19 deletions packages/client/src/pages/CollectionDetail/index.tsx
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@ import {
Box,
Text,
Tag,
Button,
Flex,
IconButton,
Menu,
@@ -25,18 +24,19 @@ import { useCollection, useStacSearch } from '@developmentseed/stac-react';
import {
CollecticonEllipsisVertical,
CollecticonPencil,
CollecticonTextBlock,
CollecticonTrashBin
CollecticonTextBlock
} from '@devseed-ui/collecticons-chakra';
import { StacCollection, StacItem } from 'stac-ts';

import { usePageTitle } from '../../hooks';
import CollectionMap from './CollectionMap';
import SmartLink from '$components/SmartLink';
import { InnerPageHeader } from '$components/InnerPageHeader';
import { StacBrowserMenuItem } from '$components/StacBrowserMenuItem';
import { ItemCard, ItemCardLoading } from '$components/ItemCard';
import { zeroPad } from '$utils/format';
import { ButtonWithAuth } from '$components/auth/ButtonWithAuth';
import { DeleteMenuItem } from '$components/DeleteMenuItem';
import SmartLink from '$components/SmartLink';

const dateFormat: Intl.DateTimeFormatOptions = {
year: 'numeric',
@@ -128,16 +128,14 @@ function CollectionDetail() {
title={title || id}
actions={
<>
<Button
as={SmartLink}
to={`/collections/${id}/edit`}
<ButtonWithAuth
colorScheme='primary'
size='md'
to={`/collections/${id}/edit`}
leftIcon={<CollecticonPencil />}
>
Edit
</Button>
<Menu placement='bottom-end'>
</ButtonWithAuth>
<Menu>
<MenuButton
as={IconButton}
aria-label='Options'
@@ -147,15 +145,7 @@ function CollectionDetail() {
/>
<MenuList>
<StacBrowserMenuItem resourcePath={`/collections/${id}`} />
<MenuItem
icon={<CollecticonTrashBin />}
color='danger.500'
_hover={{ bg: 'danger.200' }}
_focus={{ bg: 'danger.200' }}
onClick={() => alert('Soon!')}
>
Delete
</MenuItem>
<DeleteMenuItem />
</MenuList>
</Menu>
</>
15 changes: 7 additions & 8 deletions packages/client/src/pages/CollectionList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useMemo, useState } from 'react';
import {
Button,
Flex,
Heading,
Box,
@@ -31,6 +30,8 @@ import { InnerPageHeader } from '$components/InnerPageHeader';
import SmartLink from '$components/SmartLink';
import { ItemCard, ItemCardLoading } from '$components/ItemCard';
import { zeroPad } from '$utils/format';
import { ButtonWithAuth } from '$components/auth/ButtonWithAuth';
import { MenuItemWithAuth } from '$components/auth/MenuItemWithAuth';

function CollectionList() {
usePageTitle('Collections');
@@ -76,15 +77,13 @@ function CollectionList() {
title='Catalog'
overline='Browsing'
actions={
<Button
as={SmartLink}
to='/collections/new'
<ButtonWithAuth
colorScheme='primary'
size='md'
to='/collections/new'
leftIcon={<CollecticonPlusSmall />}
>
Create
</Button>
</ButtonWithAuth>
}
/>
<Flex direction='column' gap='8' as='section'>
@@ -173,13 +172,13 @@ function CollectionList() {
>
View
</MenuItem>
<MenuItem
<MenuItemWithAuth
as={SmartLink}
to={`/collections/${col.id}/edit`}
icon={<CollecticonPencil />}
>
Edit
</MenuItem>
</MenuItemWithAuth>
</MenuList>
</Menu>
)}