feat: manage passkey drawer

This commit is contained in:
httpjamesm 2023-12-24 16:24:31 -05:00
parent e36dadf97f
commit f572659dbc
No known key found for this signature in database
16 changed files with 595 additions and 12 deletions

View file

@ -0,0 +1,53 @@
import { EnteDrawer } from '@ente/shared/components/EnteDrawer';
import { PasskeysContext } from '.';
import { Stack } from '@mui/material';
import Titlebar from '@ente/shared/components/Titlebar';
import { MenuItemGroup } from '@ente/shared/components/Menu/MenuItemGroup';
import { EnteMenuItem } from '@ente/shared/components/Menu/EnteMenuItem';
import { useContext } from 'react';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import MenuItemDivider from '@ente/shared/components/Menu/MenuItemDivider';
interface IProps {
open: boolean;
}
const ManagePasskeyDrawer = (props: IProps) => {
const { setShowPasskeyDrawer } = useContext(PasskeysContext);
return (
<EnteDrawer
anchor="right"
open={props.open}
onClose={() => {
setShowPasskeyDrawer(false);
}}>
<Stack spacing={'4px'} py={'12px'}>
<Titlebar
onClose={() => {
setShowPasskeyDrawer(false);
}}
title="Manage Passkey"
onRootClose={() => {
setShowPasskeyDrawer(false);
}}
/>
<MenuItemGroup>
<EnteMenuItem
onClick={() => {}}
startIcon={<EditIcon />}
label={'Rename Passkey'}
/>
<MenuItemDivider />
<EnteMenuItem
onClick={() => {}}
startIcon={<DeleteIcon />}
label={'Delete Passkey'}
/>
</MenuItemGroup>
</Stack>
</EnteDrawer>
);
};
export default ManagePasskeyDrawer;

View file

@ -0,0 +1,28 @@
import { EnteMenuItem } from '@ente/shared/components/Menu/EnteMenuItem';
import { Passkey } from 'types/passkey';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useContext } from 'react';
import { PasskeysContext } from '.';
interface IProps {
passkey: Passkey;
}
const PasskeyListItem = (props: IProps) => {
const { setSelectedPasskey, setShowPasskeyDrawer } =
useContext(PasskeysContext);
return (
<EnteMenuItem
onClick={() => {
setSelectedPasskey(props.passkey);
setShowPasskeyDrawer(true);
}}
key={props.passkey.id}
endIcon={<ChevronRightIcon />}
label={props.passkey.friendlyName}
/>
);
};
export default PasskeyListItem;

View file

@ -0,0 +1,30 @@
import { MenuItemGroup } from '@ente/shared/components/Menu/MenuItemGroup';
import { useEffect, useState } from 'react';
import { getPasskeys } from 'services/passkeysService';
import { Passkey } from 'types/passkey';
import PasskeyListItem from './PasskeyListItem';
const PasskeyComponent = () => {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const init = async () => {
const data = await getPasskeys();
setPasskeys(data.passkeys);
};
useEffect(() => {
init();
}, []);
return (
<>
<MenuItemGroup>
{passkeys.map((passkey) => (
<PasskeyListItem key={passkey.id} passkey={passkey} />
))}
</MenuItemGroup>
</>
);
};
export default PasskeyComponent;

View file

@ -7,8 +7,26 @@ import {
} from '../../services/passkeysService'; } from '../../services/passkeysService';
import { logError } from '@ente/shared/sentry'; import { logError } from '@ente/shared/sentry';
import _sodium from 'libsodium-wrappers'; import _sodium from 'libsodium-wrappers';
import { Dispatch, SetStateAction, createContext, useState } from 'react';
import { Passkey } from 'types/passkey';
import PasskeysList from './PasskeysList';
import ManagePasskeyDrawer from './ManagePasskeyDrawer';
export const PasskeysContext = createContext(
{} as {
selectedPasskey: Passkey | null;
setSelectedPasskey: Dispatch<SetStateAction<Passkey | null>>;
setShowPasskeyDrawer: Dispatch<SetStateAction<boolean>>;
}
);
const Passkeys = () => { const Passkeys = () => {
const [selectedPasskey, setSelectedPasskey] = useState<Passkey | null>(
null
);
const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false);
const handleSubmit = async (inputValue: string) => { const handleSubmit = async (inputValue: string) => {
const response: { const response: {
options: { options: {
@ -50,18 +68,29 @@ const Passkeys = () => {
return ( return (
<> <>
<CenteredFlex> <PasskeysContext.Provider
<Box> value={{
<SingleInputForm selectedPasskey,
fieldType="text" setSelectedPasskey,
placeholder="Passkey Name" setShowPasskeyDrawer,
buttonText="Add Passkey" }}>
initialValue={''} <CenteredFlex>
blockButton <Box>
callback={handleSubmit} <SingleInputForm
/> fieldType="text"
</Box> placeholder="Passkey Name"
</CenteredFlex> buttonText="Add Passkey"
initialValue={''}
blockButton
callback={handleSubmit}
/>
<Box>
<PasskeysList />
</Box>
</Box>
</CenteredFlex>
<ManagePasskeyDrawer open={showPasskeyDrawer} />
</PasskeysContext.Provider>
</> </>
); );
}; };

View file

@ -5,6 +5,22 @@ import { logError } from '@ente/shared/sentry';
import _sodium from 'libsodium-wrappers'; import _sodium from 'libsodium-wrappers';
const ENDPOINT = getEndpoint(); const ENDPOINT = getEndpoint();
export const getPasskeys = async () => {
try {
const token = getToken();
if (!token) return;
const response = await HTTPService.get(
`${ENDPOINT}/passkeys`,
{},
{ 'X-Auth-Token': token }
);
return await response.data;
} catch (e) {
logError(e, 'get passkeys failed');
throw e;
}
};
export const getPasskeyRegistrationOptions = async () => { export const getPasskeyRegistrationOptions = async () => {
try { try {
const token = getToken(); const token = getToken();

View file

@ -0,0 +1,6 @@
export interface Passkey {
id: string;
userID: number;
friendlyName: string;
createdAt: number;
}

View file

@ -0,0 +1,42 @@
import { ButtonProps, Typography } from '@mui/material';
import { VerticallyCenteredFlex } from '@ente/shared/components/Container';
interface Iprops {
mainText: string;
subText?: string;
subIcon?: React.ReactNode;
color?: ButtonProps['color'];
}
const getSubTextColor = (color: ButtonProps['color']) => {
switch (color) {
case 'critical':
return 'critical.main';
default:
return 'text.faint';
}
};
export const CaptionedText = (props: Iprops) => {
return (
<VerticallyCenteredFlex gap={'4px'}>
<Typography> {props.mainText}</Typography>
<Typography variant="small" color={getSubTextColor(props.color)}>
{'•'}
</Typography>
{props.subText ? (
<Typography
variant="small"
color={getSubTextColor(props.color)}>
{props.subText}
</Typography>
) : (
<Typography
variant="small"
color={getSubTextColor(props.color)}>
{props.subIcon}
</Typography>
)}
</VerticallyCenteredFlex>
);
};

View file

@ -0,0 +1,63 @@
import React from 'react';
import { SwitchProps, Switch } from '@mui/material';
import { styled } from '@mui/material';
const PublicShareSwitch = styled((props: SwitchProps) => (
<Switch
focusVisibleClassName=".Mui-focusVisible"
disableRipple
{...props}
/>
))(({ theme }) => ({
width: 40,
height: 24,
padding: 0,
'& .MuiSwitch-switchBase': {
padding: 0,
margin: 2,
transitionDuration: '300ms',
'&.Mui-checked': {
transform: 'translateX(16px)',
color: '#fff',
'& + .MuiSwitch-track': {
backgroundColor:
theme.palette.mode === 'dark' ? '#2ECA45' : '#65C466',
opacity: 1,
border: 0,
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.5,
},
},
'&.Mui-focusVisible .MuiSwitch-thumb': {
color: '#33cf4d',
border: '6px solid #fff',
},
'&.Mui-disabled .MuiSwitch-thumb': {
color:
theme.palette.mode === 'light'
? theme.palette.grey[100]
: theme.palette.grey[600],
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: theme.palette.mode === 'light' ? 0.7 : 0.3,
},
},
'& .MuiSwitch-thumb': {
boxSizing: 'border-box',
width: 20,
height: 20,
},
'& .MuiSwitch-track': {
borderRadius: 22 / 2,
backgroundColor:
theme.palette.mode === 'light'
? '#E9E9EA'
: theme.colors.fill.muted,
opacity: 1,
transition: theme.transitions.create(['background-color'], {
duration: 500,
}),
},
}));
export default PublicShareSwitch;

View file

@ -0,0 +1,26 @@
import OverflowMenu from '@ente/shared/components/OverflowMenu/menu';
import { OverflowMenuOption } from '@ente/shared/components/OverflowMenu/option';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { t } from 'i18next';
import FolderIcon from '@mui/icons-material/Folder';
export default function ChangeDirectoryOption({
changeExportDirectory: changeDirectory,
}) {
return (
<OverflowMenu
triggerButtonProps={{
sx: {
ml: 1,
},
}}
ariaControls={'export-option'}
triggerButtonIcon={<MoreHoriz />}>
<OverflowMenuOption
onClick={changeDirectory}
startIcon={<FolderIcon />}>
{t('CHANGE_FOLDER')}
</OverflowMenuOption>
</OverflowMenu>
);
}

View file

@ -0,0 +1,34 @@
import { styled } from '@mui/material/styles';
import LinkButton from '@ente/shared/components/LinkButton';
import { Tooltip } from '@mui/material';
import ElectronAPIs from '@ente/shared/electron';
import { logError } from '@ente/shared/sentry';
const DirectoryPathContainer = styled(LinkButton)(
({ width }) => `
width: ${width}px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Beginning of string */
direction: rtl;
text-align: left;
`
);
export const DirectoryPath = ({ width, path }) => {
const handleClick = async () => {
try {
await ElectronAPIs.openDirectory(path);
} catch (e) {
logError(e, 'openDirectory failed');
}
};
return (
<DirectoryPathContainer width={width} onClick={handleClick}>
<Tooltip title={path}>
<span>{path}</span>
</Tooltip>
</DirectoryPathContainer>
);
};

View file

@ -0,0 +1,10 @@
import { Drawer, styled } from '@mui/material';
export const EnteDrawer = styled(Drawer)(({ theme }) => ({
'& .MuiPaper-root': {
maxWidth: '375px',
width: '100%',
scrollbarWidth: 'thin',
padding: theme.spacing(1),
},
}));

View file

@ -0,0 +1,127 @@
import {
MenuItem,
ButtonProps,
Box,
Typography,
TypographyProps,
} from '@mui/material';
import { CaptionedText } from '../CaptionedText';
import PublicShareSwitch from '../Collections/CollectionShare/publicShare/switch';
import {
SpaceBetweenFlex,
VerticallyCenteredFlex,
} from '@ente/shared/components/Container';
import React from 'react';
import ChangeDirectoryOption from '../Directory/changeOption';
interface Iprops {
onClick: () => void;
color?: ButtonProps['color'];
variant?:
| 'primary'
| 'captioned'
| 'toggle'
| 'secondary'
| 'mini'
| 'path';
fontWeight?: TypographyProps['fontWeight'];
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
label?: string;
subText?: string;
subIcon?: React.ReactNode;
checked?: boolean;
labelComponent?: React.ReactNode;
disabled?: boolean;
}
export function EnteMenuItem({
onClick,
color = 'primary',
startIcon,
endIcon,
label,
subText,
subIcon,
checked,
variant = 'primary',
fontWeight = 'bold',
labelComponent,
disabled = false,
}: Iprops) {
const handleButtonClick = () => {
if (variant === 'path' || variant === 'toggle') {
return;
}
onClick();
};
const handleIconClick = () => {
if (variant !== 'path' && variant !== 'toggle') {
return;
}
onClick();
};
return (
<MenuItem
disabled={disabled}
onClick={handleButtonClick}
sx={{
width: '100%',
color: (theme) =>
variant !== 'captioned' && theme.palette[color].main,
...(variant !== 'secondary' &&
variant !== 'mini' && {
backgroundColor: (theme) => theme.colors.fill.faint,
}),
'&:hover': {
backgroundColor: (theme) => theme.colors.fill.faintPressed,
},
'& .MuiSvgIcon-root': {
fontSize: '20px',
},
p: 0,
borderRadius: '4px',
}}>
<SpaceBetweenFlex sx={{ pl: '16px', pr: '12px' }}>
<VerticallyCenteredFlex sx={{ py: '14px' }} gap={'10px'}>
{startIcon && startIcon}
<Box px={'2px'}>
{labelComponent ? (
labelComponent
) : variant === 'captioned' ? (
<CaptionedText
color={color}
mainText={label}
subText={subText}
subIcon={subIcon}
/>
) : variant === 'mini' ? (
<Typography variant="mini" color="text.muted">
{label}
</Typography>
) : (
<Typography fontWeight={fontWeight}>
{label}
</Typography>
)}
</Box>
</VerticallyCenteredFlex>
<VerticallyCenteredFlex gap={'4px'}>
{endIcon && endIcon}
{variant === 'toggle' && (
<PublicShareSwitch
checked={checked}
onClick={handleIconClick}
/>
)}
{variant === 'path' && (
<ChangeDirectoryOption
changeExportDirectory={handleIconClick}
/>
)}
</VerticallyCenteredFlex>
</SpaceBetweenFlex>
</MenuItem>
);
}

View file

@ -0,0 +1,16 @@
import { Divider } from '@mui/material';
interface Iprops {
hasIcon?: boolean;
}
export default function MenuItemDivider({ hasIcon = false }: Iprops) {
return (
<Divider
sx={{
'&&&': {
my: 0,
ml: hasIcon ? '48px' : '16px',
},
}}
/>
);
}

View file

@ -0,0 +1,20 @@
import { styled } from '@mui/material';
export const MenuItemGroup = styled('div')(
({ theme }) => `
& > .MuiMenuItem-root{
border-radius: 8px;
background-color: transparent;
}
& > .MuiMenuItem-root:not(:last-of-type) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
& > .MuiMenuItem-root:not(:first-of-type) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
background-color: ${theme.colors.fill.faint};
border-radius: 8px;
`
);

View file

@ -0,0 +1,27 @@
import { Typography } from '@mui/material';
import { VerticallyCenteredFlex } from '@ente/shared/components/Container';
interface Iprops {
title: string;
icon?: JSX.Element;
}
export default function MenuSectionTitle({ title, icon }: Iprops) {
return (
<VerticallyCenteredFlex
px="8px"
py={'6px'}
gap={'8px'}
sx={{
'& > svg': {
fontSize: '17px',
color: (theme) => theme.colors.stroke.muted,
},
}}>
{icon && icon}
<Typography variant="small" color="text.muted">
{title}
</Typography>
</VerticallyCenteredFlex>
);
}

View file

@ -0,0 +1,56 @@
import Close from '@mui/icons-material/Close';
import ArrowBack from '@mui/icons-material/ArrowBack';
import { Box, IconButton, Typography } from '@mui/material';
import { FlexWrapper } from '@ente/shared/components/Container';
interface Iprops {
title: string;
caption?: string;
onClose: () => void;
backIsClose?: boolean;
onRootClose?: () => void;
actionButton?: JSX.Element;
}
export default function Titlebar({
title,
caption,
onClose,
backIsClose,
actionButton,
onRootClose,
}: Iprops): JSX.Element {
return (
<>
<FlexWrapper
height={48}
alignItems={'center'}
justifyContent="space-between">
<IconButton
onClick={onClose}
color={backIsClose ? 'secondary' : 'primary'}>
{backIsClose ? <Close /> : <ArrowBack />}
</IconButton>
<Box display={'flex'} gap="4px">
{actionButton && actionButton}
{!backIsClose && (
<IconButton onClick={onRootClose} color={'secondary'}>
<Close />
</IconButton>
)}
</Box>
</FlexWrapper>
<Box py={0.5} px={2}>
<Typography variant="h3" fontWeight={'bold'}>
{title}
</Typography>
<Typography
variant="small"
color="text.muted"
sx={{ wordBreak: 'break-all', minHeight: '17px' }}>
{caption}
</Typography>
</Box>
</>
);
}