diff --git a/src/App.tsx b/src/App.tsx index e8df44e..cdbd6a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import {Link as RouterLink, Route, Routes} from 'react-router-dom'; -import {styled} from '@mui/material/styles'; +import {createTheme, styled, ThemeProvider} from '@mui/material/styles'; import Link from '@mui/material/Link'; import MuiDrawer from '@mui/material/Drawer'; import Box from '@mui/material/Box'; @@ -37,6 +37,19 @@ import ReadTag from './pages/tags/Read'; import ReadUser from './pages/users/Read'; import {useCurrentUser} from './authentication'; import ReadRequest from './pages/requests/Read'; +import { + alpha, + CssBaseline, + PaletteMode, + Stack, + ToggleButton, + ToggleButtonGroup, + Tooltip, + useMediaQuery, + useTheme, +} from '@mui/material'; +import {DarkMode, LightMode, Monitor} from '@mui/icons-material'; +import {lightGreen, red, yellow} from '@mui/material/colors'; const drawerWidth: number = 240; @@ -88,14 +101,72 @@ const Drawer = styled(MuiDrawer, { }, })); -function Dashboard() { +function ThemeToggle({setThemeMode, condensed}: {setThemeMode: (theme: PaletteMode) => void; condensed: boolean}) { + const [storedTheme, setStoredTheme] = React.useState( + localStorage.getItem('user-set-color-scheme') as 'light' | 'dark' | null, + ); + const currentTheme = useTheme(); + const systemTheme = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light'; + + const handleThemeOverride = (theme: PaletteMode) => { + setThemeMode(theme); + localStorage.setItem('user-set-color-scheme', theme); + setStoredTheme(theme); + }; + + const handleSystemDefault = () => { + setThemeMode(systemTheme); + localStorage.removeItem('user-set-color-scheme'); + setStoredTheme(null); + }; + + return ( + + {(currentTheme.palette.mode != 'light' || !condensed) && ( + + handleThemeOverride('light')} + aria-label="Light mode"> + + + + )} + {!condensed && ( + + + + + + )} + {(currentTheme.palette.mode != 'dark' || !condensed) && ( + + handleThemeOverride('dark')} + aria-label="Dark mode"> + + + + )} + + ); +} + +function Dashboard({setThemeMode}: {setThemeMode: (theme: PaletteMode) => void}) { const [open, setOpen] = React.useState(true); const toggleDrawer = () => { setOpen(!open); }; return ( - + - + ACCESS @@ -149,12 +220,15 @@ function Dashboard() { + + + - theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900], + theme.palette.mode === 'light' ? theme.palette.grey[200] : theme.palette.grey[800], flexGrow: 1, height: '100vh', overflow: 'auto', @@ -189,6 +263,81 @@ function Dashboard() { } export default function App() { + const storedTheme = localStorage.getItem('user-set-color-scheme') as 'light' | 'dark' | null; + const systemTheme = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light'; + const initialMode = storedTheme ?? systemTheme; + const [mode, setMode] = React.useState(initialMode); + + // See https://discord.com/branding + let theme = React.useMemo(() => { + const base = createTheme({ + palette: { + mode, + primary: { + main: '#5865F2', + light: '#A5B2FF', + }, + secondary: { + main: '#EB459E', + }, + error: { + main: '#ED4245', + }, + warning: { + main: '#FEE75C', + }, + success: { + main: '#57F287', + }, + text: { + accent: mode === 'light' ? '#5865F2' : '#A5B2FF', + }, + }, + components: { + MuiChip: { + styleOverrides: { + colorPrimary: ({ownerState, theme}) => ({ + ...(ownerState.variant === 'outlined' && + ownerState.color === 'primary' && { + color: theme.palette.text.accent, + borderColor: theme.palette.text.accent, + }), + }), + deleteIcon: ({ownerState, theme}) => ({ + ...(ownerState.variant === 'outlined' && + ownerState.color === 'primary' && { + color: theme.palette.text.accent, + }), + }), + }, + }, + }, + }); + return createTheme(base, { + palette: { + highlight: { + success: base.palette.augmentColor({ + color: {main: mode === 'light' ? lightGreen[100] : alpha(lightGreen[500], 0.3)}, + name: 'success', + }), + warning: base.palette.augmentColor({ + color: {main: mode === 'light' ? yellow[100] : alpha(yellow[500], 0.3)}, + name: 'warning', + }), + danger: base.palette.augmentColor({ + color: {main: mode === 'light' ? red[100] : alpha(red[500], 0.3)}, + name: 'danger', + }), + }, + }, + }); + }, [mode]); + useCurrentUser(); - return ; + return ( + + + + + ); } diff --git a/src/components/AvatarButton.tsx b/src/components/AvatarButton.tsx new file mode 100644 index 0000000..cba0b74 --- /dev/null +++ b/src/components/AvatarButton.tsx @@ -0,0 +1,33 @@ +import {Avatar, ButtonBase, Typography} from '@mui/material'; +import {ReactNode} from 'react'; + +interface AvatarButtonProps { + icon: ReactNode; + text?: string; + strikethrough?: boolean; + onClick?: () => void; +} + +export default function AvatarButton({icon, text, strikethrough, onClick}: AvatarButtonProps) { + return ( + + {icon} + {text && ( + + {text} + + )} + + ); +} diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 2f60a8b..c199dce 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -1,7 +1,7 @@ import Breadcrumbs from '@mui/material/Breadcrumbs'; import Typography from '@mui/material/Typography'; -import Link, {LinkProps} from '@mui/material/Link'; +import Link from '@mui/material/Link'; import {Link as RouterLink, useLocation} from 'react-router-dom'; export default function Crumbs() { diff --git a/src/components/BulkRenewalDataGrid.tsx b/src/components/BulkRenewalDataGrid.tsx new file mode 100644 index 0000000..94c11be --- /dev/null +++ b/src/components/BulkRenewalDataGrid.tsx @@ -0,0 +1,44 @@ +import {darken, lighten, PaletteColor, styled} from '@mui/material'; +import {DataGrid} from '@mui/x-data-grid'; + +const getHoverBackgroundColor = (color: PaletteColor, mode: string) => (mode === 'dark' ? color.dark : color.light); + +const getSelectedBackgroundColor = (color: PaletteColor, mode: string) => + mode === 'dark' ? darken(color.dark, 0.5) : lighten(color.light, 0.5); + +const getSelectedHoverBackgroundColor = (color: PaletteColor, mode: string) => + mode === 'dark' ? darken(color.dark, 0.4) : lighten(color.light, 0.4); + +const BulkRenewalDataGrid = styled(DataGrid)( + ({ + theme: { + palette: {highlight, mode}, + }, + }) => ({ + '& .super-app-theme--Expired': { + backgroundColor: highlight.danger.main, + '&:hover': { + backgroundColor: getHoverBackgroundColor(highlight.danger, mode), + }, + '&.Mui-selected': { + backgroundColor: getSelectedBackgroundColor(highlight.danger, mode), + '&:hover': { + backgroundColor: getSelectedHoverBackgroundColor(highlight.danger, mode), + }, + }, + }, + '& .super-app-theme--Soon': { + backgroundColor: highlight.warning.main, + '&:hover': { + backgroundColor: getHoverBackgroundColor(highlight.warning, mode), + }, + '&.Mui-selected': { + backgroundColor: getSelectedBackgroundColor(highlight.warning, mode), + '&:hover': { + backgroundColor: getSelectedHoverBackgroundColor(highlight.warning, mode), + }, + }, + }, + }), +); +export default BulkRenewalDataGrid; diff --git a/src/components/DateRange.tsx b/src/components/DateRange.tsx index 852627c..c0a76f5 100644 --- a/src/components/DateRange.tsx +++ b/src/components/DateRange.tsx @@ -10,12 +10,10 @@ import EventIcon from '@mui/icons-material/Event'; import {Dayjs} from 'dayjs'; import {DatePicker, DatePickerProps} from '@mui/x-date-pickers/DatePicker'; -import {PickersDay, PickersDayProps, pickersDayClasses} from '@mui/x-date-pickers/PickersDay'; +import {PickersDay, PickersDayProps} from '@mui/x-date-pickers/PickersDay'; import {UseDateFieldProps} from '@mui/x-date-pickers/DateField'; import {BaseSingleInputFieldProps, DateValidationError, FieldSection} from '@mui/x-date-pickers/models'; -import {grey} from '@mui/material/colors'; - function HighlightDay(props: PickersDayProps & {startDate?: Dayjs; endDate?: Dayjs; rangeSelected: boolean}) { const {startDate, endDate, rangeSelected, ...rest} = props; @@ -35,22 +33,26 @@ function HighlightDay(props: PickersDayProps & {startDate?: Dayjs; endDat let selectedClass = ''; let style = {}; + let dayStyle = {}; if (rangeSelected && props.day.isSame(endDate!, 'day')) { selectedClass = 'Mui-selected'; } if (isSelected) { - style = {backgroundColor: 'primary_extra_light.main'}; + style = {backgroundColor: theme.palette.primary.light}; + dayStyle = {color: theme.palette.common.black}; } else if (start) { - style = {background: `linear-gradient(90deg, white 50%, ${theme.palette.primary_extra_light.main} 50%)`}; + style = { + background: `linear-gradient(90deg, transparent 50%, ${theme.palette.primary.light} 50%)`, + }; } else if (end) { - style = {background: `linear-gradient(90deg, ${theme.palette.primary_extra_light.main} 50%, white 50%)`}; + style = {background: `linear-gradient(90deg, ${theme.palette.primary.light} 50%, transparent 50%)`}; } return ( - + ); } @@ -78,6 +80,7 @@ function ButtonField(props: ButtonFieldProps) { inputProps: {'aria-label': ariaLabel} = {}, } = props; + const theme = useTheme(); let displayString = ''; if (rangeSelected) { @@ -88,38 +91,38 @@ function ButtonField(props: ButtonFieldProps) { return ( - - - + Ending Date Range diff --git a/src/components/EmptyListEntry.tsx b/src/components/EmptyListEntry.tsx new file mode 100644 index 0000000..c00f2c3 --- /dev/null +++ b/src/components/EmptyListEntry.tsx @@ -0,0 +1,18 @@ +import {TableRow, TableCell, Typography, TableCellProps} from '@mui/material'; + +interface EmptyListEntryProps { + cellProps?: TableCellProps; + customText?: string; +} + +export const EmptyListEntry: React.FC = ({cellProps, customText}) => { + return ( + + + + {customText || 'None'} + + + + ); +}; diff --git a/src/components/NumberInput.tsx b/src/components/NumberInput.tsx index b66decc..30132fa 100644 --- a/src/components/NumberInput.tsx +++ b/src/components/NumberInput.tsx @@ -5,8 +5,7 @@ import { NumberInputProps as NumInputProps, numberInputClasses, } from '@mui/base/Unstable_NumberInput'; -import {styled} from '@mui/system'; -import {grey} from '@mui/material/colors'; +import {styled} from '@mui/material/styles'; const NumInput = React.forwardRef(function CustomNumberInput( props: NumInputProps, @@ -66,7 +65,7 @@ const InputAdornment = styled('div')( align-items: center; justify-content: center; grid-row: 1/3; - color: ${theme.palette.mode === 'dark' ? grey[500] : grey[700]}; + color: ${theme.palette.text.secondary}; `, ); @@ -74,11 +73,8 @@ const InputRoot = styled('div')( ({theme}) => ` font-family: 'IBM Plex Sans', sans-serif; font-weight: 400; - border-radius: 8px; - color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; - background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; - border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; - box-shadow: 0px 2px 4px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0, 0.5)' : 'rgba(0,0,0, 0.05)'}; + border-radius: 4px; + border: 1px solid ${theme.palette.action.disabled}; display: grid; grid-template-columns: auto 1fr auto 19px; grid-template-rows: 1fr 1fr; @@ -86,12 +82,12 @@ const InputRoot = styled('div')( padding: 4px; &.${numberInputClasses.focused} { - border-color: ${theme.palette.primary.main}; - box-shadow: 0 0 0 3px ${theme.palette.primary_extra_light.main}; + border: 1px solid transparent; + outline: 2px solid ${theme.palette.primary.main}; } - &:hover { - border-color: ${theme.palette.primary.main}; + &:hover:not(.${numberInputClasses.focused}) { + border-color: ${theme.palette.action.active}; } // firefox @@ -108,7 +104,7 @@ const InputElement = styled('input')( font-weight: 400; line-height: 1.5; grid-row: 1/3; - color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + color: ${theme.palette.text.primary}; background: inherit; border: none; border-radius: inherit; @@ -131,58 +127,30 @@ const Button = styled('button')( font-size: 0.875rem; line-height: 1; box-sizing: border-box; - background: ${theme.palette.mode === 'dark' ? grey[900] : 'white'}; - border: 0; - color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + border: 1px solid ${theme.palette.action.disabled}; transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 120ms; + color: ${theme.palette.text.primary}; + background-color: transparent; + grid-column: 4/5; &:hover { - background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; - border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + background: ${theme.palette.action.hover}; cursor: pointer; } &.${numberInputClasses.incrementButton} { - grid-column: 4/5; grid-row: 1/2; border-top-left-radius: 4px; border-top-right-radius: 4px; - border: 1px solid; - border-bottom: 0; - border-color: ${grey[200]}; - background: ${theme.palette.mode === 'dark' ? grey[900] : undefined}; - color: ${theme.palette.mode === 'dark' ? grey[200] : grey[600]}; - - &:hover { - cursor: pointer; - color: #FFF; - background: ${theme.palette.primary.main}; - border-color: ${theme.palette.primary.main}; - } + border-bottom: 0px; } &.${numberInputClasses.decrementButton} { - grid-column: 4/5; grid-row: 2/3; border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; - border: 1px solid; - border-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; - background: ${theme.palette.mode === 'dark' ? grey[900] : undefined}; - color: ${theme.palette.mode === 'dark' ? grey[200] : grey[600]}; - - &:hover { - cursor: pointer; - color: #FFF; - background: ${theme.palette.primary.main}; - border-color: ${theme.palette.primary.main}; - } - } - - & .arrow { - transform: translateY(-1px); } & .arrow { diff --git a/src/components/TableTopBar.tsx b/src/components/TableTopBar.tsx new file mode 100644 index 0000000..1532201 --- /dev/null +++ b/src/components/TableTopBar.tsx @@ -0,0 +1,75 @@ +import {Launch} from '@mui/icons-material'; +import {Autocomplete, AutocompleteProps, Box, Grid, IconButton, Stack, TextField, Typography} from '@mui/material'; + +import * as React from 'react'; +import {useNavigate} from 'react-router-dom'; + +export function renderUserOption(props: React.HTMLAttributes, option: any) { + const [displayName, email] = option.split(';'); + return ( +
  • + + + {displayName} + + {email} + + + +
  • + ); +} + +export function TableTopBarAutocomplete({ + defaultValue, + filterOptions = (x) => x, + ...restProps +}: Omit, 'renderInput'>) { + return ( + } + {...restProps} + /> + ); +} + +interface TableTopBarProps { + title: string; + link?: string; + children?: React.ReactNode; +} + +export default function TableTopBar({title, link, children}: TableTopBarProps) { + const navigate = useNavigate(); + return ( + + + + {title} + + {link != null && ( + navigate(link)}> + + + )} + + + {children} + + + ); +} diff --git a/src/helpers.tsx b/src/helpers.tsx index fd9ab1a..b99eda6 100644 --- a/src/helpers.tsx +++ b/src/helpers.tsx @@ -1,4 +1,4 @@ -import {PolymorphicGroup, OktaUser, Tag, OktaGroupTagMap} from './api/apiSchemas'; +import {PolymorphicGroup, OktaUser, Tag, OktaGroupTagMap, OktaUserGroupMember} from './api/apiSchemas'; export const perPage: Array = [5, 10, 20, 50, {label: 'All', value: -1}]; @@ -120,3 +120,28 @@ export function ownerCantAddSelf(tags: Tag[] | undefined, owner: boolean) { export function ownerCantAddSelfGroups(groups: PolymorphicGroup[], owner: boolean) { return ownerCantAddSelf(getActiveTagsFromGroups(groups), owner); } + +export function sortGroupMembers( + [aUserId, aUsers]: [string, Array], + [bUserId, bUsers]: [string, Array], +): number { + let aEmail = aUsers[0].active_user?.email ?? ''; + let bEmail = bUsers[0].active_user?.email ?? ''; + return aEmail.localeCompare(bEmail); +} + +export function sortGroupMemberRecords(users: Record): OktaUser[] { + const usersArray = Object.values(users); // Convert the object to an array + usersArray.sort((a, b) => { + const nameA = `${a.first_name} ${a.last_name}`; + const nameB = `${b.first_name} ${b.last_name}`; + return nameA.localeCompare(nameB); + }); + return usersArray; +} + +export function groupMemberships( + memberships: Array | undefined, +): Map> { + return groupBy(memberships ?? [], 'active_user.id'); +} diff --git a/src/index.tsx b/src/index.tsx index 070fb55..5ec4ccc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import {createRoot} from 'react-dom/client'; import {BrowserRouter} from 'react-router-dom'; -import {ThemeProvider, createTheme} from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; import {AdapterDayjs} from '@mui/x-date-pickers/AdapterDayjs'; import {LocalizationProvider} from '@mui/x-date-pickers'; import * as Sentry from '@sentry/react'; @@ -17,46 +15,6 @@ const queryClient = new QueryClient({ }, }); -declare module '@mui/material/styles' { - interface Palette { - primary_extra_light: Palette['primary']; - } - - interface PaletteOptions { - primary_extra_light?: PaletteOptions['primary']; - } -} - -declare module '@mui/material/Button' { - interface ButtonPropsColorOverrides { - primary_extra_light: true; - } -} - -// See https://discord.com/branding -let theme = createTheme({ - palette: { - primary: { - main: '#5865F2', - }, - secondary: { - main: '#EB459E', - }, - error: { - main: '#ED4245', - }, - warning: { - main: '#FEE75C', - }, - success: { - main: '#57F287', - }, - primary_extra_light: { - main: '#A5B2FF', - }, - }, -}); - if (['production', 'staging'].includes(process.env.NODE_ENV)) { // Use a placeholder DSN as we'll be using the tunnel to proxy all Sentry React errors Sentry.init({ @@ -74,14 +32,9 @@ createRoot(document.getElementById('root')!).render( } showDialog> - - - - - - - - + + + diff --git a/src/mui.d.ts b/src/mui.d.ts new file mode 100644 index 0000000..376f39e --- /dev/null +++ b/src/mui.d.ts @@ -0,0 +1,19 @@ +import '@mui/material/styles'; + +declare module '@mui/material/styles' { + interface Palette { + highlight: { + [variant: string]: Palette['primary']; + }; + } + + interface PaletteOptions { + highlight?: { + [variant: string]: PaletteOptions['primary']; + }; + } + + interface TypeText { + accent: string; + } +} diff --git a/src/pages/ComingSoon.tsx b/src/pages/ComingSoon.tsx index 8a17c3d..304bd1b 100644 --- a/src/pages/ComingSoon.tsx +++ b/src/pages/ComingSoon.tsx @@ -26,7 +26,7 @@ export default function ComingSoon() { - + Coming Soon! diff --git a/src/pages/Error.tsx b/src/pages/Error.tsx index 767baef..b359077 100644 --- a/src/pages/Error.tsx +++ b/src/pages/Error.tsx @@ -24,7 +24,7 @@ export default function NotFound() { - + An Unrecoverable Error Occurred diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 5caca6a..276b60c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -18,8 +18,6 @@ import PeopleLeadIcon from '@mui/icons-material/ContentPaste'; import FAQIcon from '@mui/icons-material/TipsAndUpdates'; import UserIcon from '@mui/icons-material/AccountBox'; -import {grey} from '@mui/material/colors'; - const sections: Record = { // section shorthand --> [guide title, button title, icon] general: ['Welcome to Access!', 'Overview', ], @@ -119,18 +117,24 @@ interface AccordionMakerProps { } function AccordionMaker(props: AccordionMakerProps) { + const [expanded, setExpanded] = React.useState(false); + + const handleChange = (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => { + setExpanded(newExpanded ? panel : false); + }; + return ( <> - + {sections[props.which][0]} {Object.entries(guide[props.which]).map(([key, value]: [string, string]) => ( - + } aria-controls={'panel-content' + key} id={'panel-header' + key} - sx={{fontWeight: 500, color: grey[900]}}> + sx={{fontWeight: 500}}> {key} @@ -168,14 +172,14 @@ export default function Home() { - + Access User Guides {Object.entries(sections).map(([key, [title, buttonTitle, icon]]) => ( - +