Moved v3 code from NginxProxyManager/nginx-proxy-manager-3 to NginxProxyManager/nginx-proxy-manager

This commit is contained in:
Jamie Curnow
2022-05-12 08:47:31 +10:00
parent 4db34f5894
commit 2110ecc382
830 changed files with 38168 additions and 36635 deletions

View File

@ -0,0 +1,23 @@
import { ReactNode } from "react";
import { Box, Heading, Text } from "@chakra-ui/react";
interface EmptyListProps {
title: string;
summary: string;
createButton?: ReactNode;
}
function EmptyList({ title, summary, createButton }: EmptyListProps) {
return (
<Box textAlign="center" py={10} px={6}>
<Heading as="h4" size="md" mt={6} mb={2}>
{title}
</Heading>
<Text color="gray.500">{summary}</Text>
{createButton}
</Box>
);
}
export { EmptyList };

View File

@ -0,0 +1,32 @@
import { Box } from "@chakra-ui/layout";
import { hasFlag } from "country-flag-icons";
// @ts-ignore Creating a typing for a subfolder is not easily possible
import Flags from "country-flag-icons/react/3x2";
interface FlagProps {
/**
* Additional Class
*/
className?: string;
/**
* Two letter country code of flag
*/
countryCode: string;
}
function Flag({ className, countryCode }: FlagProps) {
countryCode = countryCode.toUpperCase();
if (hasFlag(countryCode)) {
// @ts-ignore have to do this because of above
const FlagElement = Flags[countryCode] as any;
return (
<Box as={FlagElement} title={countryCode} className={className} w={6} />
);
} else {
console.error(`No flag for country ${countryCode} found!`);
return <Box w={6} h={4} />;
}
}
export { Flag };

View File

@ -0,0 +1 @@
export * from "./Flag";

View File

@ -0,0 +1,64 @@
import {
Box,
Container,
Link,
Stack,
Text,
Tooltip,
useColorModeValue,
} from "@chakra-ui/react";
import { intl } from "locale";
function Footer() {
return (
<Box
bg={useColorModeValue("gray.50", "gray.900")}
color={useColorModeValue("gray.700", "gray.200")}>
<Container
as={Stack}
maxW="6xl"
py={4}
direction={{ base: "column", md: "row" }}
spacing={4}
justify={{ base: "center", md: "space-between" }}
align={{ base: "center", md: "center" }}>
<Text>
{intl.formatMessage(
{ id: "footer.copyright" },
{ year: new Date().getFullYear() },
)}
</Text>
<Stack direction="row" spacing={6}>
<Link
href="https://nginxproxymanager.com?utm_source=npm"
isExternal
rel="noopener noreferrer">
{intl.formatMessage({ id: "footer.userguide" })}
</Link>
<Link
href="https://github.com/NginxProxyManager/nginx-proxy-manager/releases?utm_source=npm"
isExternal
rel="noopener noreferrer">
{intl.formatMessage({ id: "footer.changelog" })}
</Link>
<Link
href="https://github.com/NginxProxyManager/nginx-proxy-manager?utm_source=npm"
isExternal
rel="noopener noreferrer">
{intl.formatMessage({ id: "footer.github" })}
</Link>
<Tooltip label={process.env.REACT_APP_COMMIT}>
<Link
href="https://github.com/NginxProxyManager/nginx-proxy-manager/releases?utm_source=npm"
isExternal
rel="noopener noreferrer">
v{process.env.REACT_APP_VERSION}
</Link>
</Tooltip>
</Stack>
</Container>
</Box>
);
}
export { Footer };

View File

@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import {
Button,
Drawer,
DrawerContent,
DrawerOverlay,
DrawerBody,
useDisclosure,
} from "@chakra-ui/react";
import { getLocale } from "locale";
import { FiHelpCircle } from "react-icons/fi";
import ReactMarkdown from "react-markdown";
import { getHelpFile } from "../../locale/src/HelpDoc";
interface HelpDrawerProps {
/**
* Section to show
*/
section: string;
}
function HelpDrawer({ section }: HelpDrawerProps) {
const { isOpen, onOpen, onClose } = useDisclosure();
const [markdownText, setMarkdownText] = useState("");
const lang = getLocale(true);
useEffect(() => {
try {
const docFile = getHelpFile(lang, section) as any;
fetch(docFile)
.then((response) => response.text())
.then(setMarkdownText);
} catch (ex: any) {
setMarkdownText(`**ERROR:** ${ex.message}`);
}
}, [lang, section]);
return (
<>
<Button size="sm" onClick={onOpen}>
<FiHelpCircle />
</Button>
<Drawer onClose={onClose} isOpen={isOpen} size="xl">
<DrawerOverlay />
<DrawerContent>
<DrawerBody className="helpdoc-body">
<ReactMarkdown>{markdownText}</ReactMarkdown>
</DrawerBody>
</DrawerContent>
</Drawer>
</>
);
}
export { HelpDrawer };

View File

@ -0,0 +1 @@
export * from "./HelpDrawer";

View File

@ -0,0 +1,19 @@
import { ReactNode } from "react";
import cn from "classnames";
interface LoaderProps {
/**
* Child elements within
*/
children?: ReactNode;
/**
* Additional Class
*/
className?: string;
}
function Loader({ children, className }: LoaderProps) {
return <div className={cn({ loader: true }, className)}>{children}</div>;
}
export { Loader };

View File

@ -0,0 +1 @@
export * from "./Loader";

View File

@ -0,0 +1,12 @@
import { Box } from "@chakra-ui/react";
import { Loader } from "components";
function Loading() {
return (
<Box textAlign="center">
<Loader />
</Box>
);
}
export { Loading };

View File

@ -0,0 +1,62 @@
import {
Button,
Box,
Menu,
MenuButton,
MenuList,
MenuItem,
} from "@chakra-ui/react";
import { Flag } from "components";
import { useLocaleState } from "context";
import {
changeLocale,
getFlagCodeForLocale,
intl,
localeOptions,
} from "locale";
interface LocalPickerProps {
/**
* On change handler
*/
onChange?: any;
/**
* Class
*/
className?: string;
}
function LocalePicker({ onChange, className }: LocalPickerProps) {
const { locale, setLocale } = useLocaleState();
const changeTo = (lang: string) => {
changeLocale(lang);
setLocale(lang);
onChange && onChange(locale);
};
return (
<Box className={className}>
<Menu>
<MenuButton as={Button}>
<Flag countryCode={getFlagCodeForLocale(locale)} />
</MenuButton>
<MenuList>
{localeOptions.map((item) => {
return (
<MenuItem
icon={<Flag countryCode={getFlagCodeForLocale(item[0])} />}
onClick={() => changeTo(item[0])}
// rel={item[1]}
key={`locale-${item[0]}`}>
<span>{intl.formatMessage({ id: `locale-${item[1]}` })}</span>
</MenuItem>
);
})}
</MenuList>
</Menu>
</Box>
);
}
export { LocalePicker };

View File

@ -0,0 +1,24 @@
import { useDisclosure } from "@chakra-ui/react";
import { NavigationHeader, NavigationMenu } from "components";
function Navigation() {
const {
isOpen: mobileNavIsOpen,
onToggle: mobileNavToggle,
onClose: mobileNavClose,
} = useDisclosure();
return (
<>
<NavigationHeader
toggleMobileNav={mobileNavToggle}
mobileNavIsOpen={mobileNavIsOpen}
/>
<NavigationMenu
mobileNavIsOpen={mobileNavIsOpen}
closeMobileNav={mobileNavClose}
/>
</>
);
}
export { Navigation };

View File

@ -0,0 +1,113 @@
import {
Avatar,
Box,
Button,
chakra,
Container,
Flex,
HStack,
Icon,
IconButton,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Text,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
import { ThemeSwitcher } from "components";
import { useAuthState } from "context";
import { useUser } from "hooks";
import { intl } from "locale";
import { ChangePasswordModal, ProfileModal } from "modals";
import { FiLock, FiLogOut, FiMenu, FiUser, FiX } from "react-icons/fi";
interface NavigationHeaderProps {
mobileNavIsOpen: boolean;
toggleMobileNav: () => void;
}
function NavigationHeader({
mobileNavIsOpen,
toggleMobileNav,
}: NavigationHeaderProps) {
const passwordDisclosure = useDisclosure();
const profileDisclosure = useDisclosure();
const { data: user } = useUser("me");
const { logout } = useAuthState();
return (
<Box
h={16}
borderBottom="1px solid"
borderColor={useColorModeValue("gray.200", "gray.700")}>
<Container h="full" maxW="container.xl">
<Flex h="full" alignItems="center" justifyContent="space-between">
<IconButton
display={{ base: "block", md: "none" }}
position="relative"
bg="transparent"
aria-label={
mobileNavIsOpen
? intl.formatMessage({ id: "navigation.close" })
: intl.formatMessage({ id: "navigation.open" })
}
onClick={toggleMobileNav}
icon={<Icon as={mobileNavIsOpen ? FiX : FiMenu} />}
/>
<HStack height="full" paddingY={3} spacing={4}>
<chakra.img src="/images/logo-no-text.svg" alt="" height="full" />
<Text
display={{ base: "none", md: "block" }}
fontSize="2xl"
fontWeight="bold">
{intl.formatMessage({ id: "brand.name" })}
</Text>
</HStack>
<HStack>
<ThemeSwitcher background="transparent" />
<Box pl={2}>
<Menu>
<MenuButton
as={Button}
rounded="full"
variant="link"
cursor="pointer"
minW={0}>
<Avatar size="sm" src={user?.gravatarUrl} />
</MenuButton>
<MenuList>
<MenuItem
icon={<Icon as={FiUser} />}
onClick={profileDisclosure.onOpen}>
{intl.formatMessage({ id: "profile.title" })}
</MenuItem>
<MenuItem
icon={<Icon as={FiLock} />}
onClick={passwordDisclosure.onOpen}>
{intl.formatMessage({ id: "change-password" })}
</MenuItem>
<MenuDivider />
<MenuItem onClick={logout} icon={<Icon as={FiLogOut} />}>
{intl.formatMessage({ id: "profile.logout" })}
</MenuItem>
</MenuList>
</Menu>
</Box>
</HStack>
</Flex>
</Container>
<ProfileModal
isOpen={profileDisclosure.isOpen}
onClose={profileDisclosure.onClose}
/>
<ChangePasswordModal
isOpen={passwordDisclosure.isOpen}
onClose={passwordDisclosure.onClose}
/>
</Box>
);
}
export { NavigationHeader };

View File

@ -0,0 +1,331 @@
import { FC, useCallback, useMemo, ReactNode } from "react";
import {
Box,
Collapse,
Flex,
forwardRef,
HStack,
Icon,
Link,
Menu,
MenuButton,
MenuItem,
MenuList,
Text,
Stack,
useColorModeValue,
useDisclosure,
Container,
useBreakpointValue,
} from "@chakra-ui/react";
import { intl } from "locale";
import {
FiHome,
FiSettings,
FiUser,
FiBook,
FiLock,
FiShield,
FiMonitor,
FiChevronDown,
} from "react-icons/fi";
import { Link as RouterLink, useLocation } from "react-router-dom";
interface NavItem {
/** Displayed label */
label: string;
/** Icon shown before the label */
icon: ReactNode;
/** Link where to navigate to */
to?: string;
subItems?: { label: string; to: string }[];
}
const navItems: NavItem[] = [
{
label: intl.formatMessage({ id: "dashboard.title" }),
icon: <Icon as={FiHome} />,
to: "/",
},
{
label: intl.formatMessage({ id: "hosts.title" }),
icon: <Icon as={FiMonitor} />,
to: "/hosts",
},
{
label: intl.formatMessage({ id: "access-lists.title" }),
icon: <Icon as={FiLock} />,
to: "/access-lists",
},
{
label: intl.formatMessage({ id: "ssl.title" }),
icon: <Icon as={FiShield} />,
subItems: [
{
label: intl.formatMessage({ id: "certificates.title" }),
to: "/ssl/certificates",
},
{
label: intl.formatMessage({ id: "certificate-authorities.title" }),
to: "/ssl/authorities",
},
{
label: intl.formatMessage({ id: "dns-providers.title" }),
to: "/ssl/dns-providers",
},
],
},
{
label: intl.formatMessage({ id: "audit-log.title" }),
icon: <Icon as={FiBook} />,
to: "/audit-log",
},
{
label: intl.formatMessage({ id: "users.title" }),
icon: <Icon as={FiUser} />,
to: "/users",
},
{
label: intl.formatMessage({ id: "settings.title" }),
icon: <Icon as={FiSettings} />,
subItems: [
{
label: intl.formatMessage({ id: "general-settings.title" }),
to: "/settings/general",
},
{
label: intl.formatMessage({ id: "host-templates.title" }),
to: "/settings/host-templates",
},
],
},
];
interface NavigationMenuProps {
/** Navigation is currently hidden on mobile */
mobileNavIsOpen: boolean;
closeMobileNav: () => void;
}
function NavigationMenu({
mobileNavIsOpen,
closeMobileNav,
}: NavigationMenuProps) {
const isMobile = useBreakpointValue({ base: true, md: false });
return (
<>
{isMobile ? (
<Collapse in={mobileNavIsOpen}>
<MobileNavigation closeMobileNav={closeMobileNav} />
</Collapse>
) : (
<DesktopNavigation />
)}
</>
);
}
/** Single tab element for desktop navigation */
type NavTabProps = Omit<NavItem, "subItems"> & { active?: boolean };
const NavTab = forwardRef<NavTabProps, "a">(
({ label, icon, to, active, ...props }, ref) => {
const linkColor = useColorModeValue("gray.500", "gray.200");
const linkHoverColor = useColorModeValue("gray.900", "white");
return (
<Link
as={RouterLink}
ref={ref}
height={12}
to={to ?? "#"}
display="flex"
alignItems="center"
borderBottom="1px solid"
borderBottomColor={active ? linkHoverColor : "transparent"}
color={active ? linkHoverColor : linkColor}
_hover={{
textDecoration: "none",
color: linkHoverColor,
borderBottomColor: linkHoverColor,
}}
{...props}>
{icon}
<Text as="span" marginLeft={2}>
{label}
</Text>
</Link>
);
},
);
const DesktopNavigation: FC = () => {
const path = useLocation().pathname;
const activeNavItemIndex = useMemo(
() =>
navItems.findIndex((item) => {
// Find the nav item whose location / sub items location is the beginning of the currently active path
if (item.to) {
// console.debug(item.to, path);
if (item.to === "/") {
return path === item.to;
}
return path.startsWith(item.to !== "" ? item.to : "/dashboard");
} else if (item.subItems) {
return item.subItems.some((subItem) => path.startsWith(subItem.to));
}
return false;
}),
[path],
);
return (
<Box
display={{ base: "none", md: "block" }}
overflowY="visible"
overflowX="auto"
whiteSpace="nowrap"
borderBottom="1px solid"
borderColor={useColorModeValue("gray.200", "gray.700")}>
<Container h="full" maxW="container.xl">
<HStack spacing={8}>
{navItems.map((navItem, index) => {
const { subItems, ...propsWithoutSubItems } = navItem;
const additionalProps: Partial<NavTabProps> = {};
if (index === activeNavItemIndex) {
additionalProps["active"] = true;
}
if (subItems) {
return (
<Menu key={`mainnav${index}`}>
<MenuButton
as={NavTab}
{...propsWithoutSubItems}
{...additionalProps}
/>
{subItems && (
<MenuList>
{subItems.map((item, subIndex) => (
<MenuItem
as={RouterLink}
to={item.to}
key={`mainnav${index}-${subIndex}`}>
{item.label}
</MenuItem>
))}
</MenuList>
)}
</Menu>
);
} else {
return (
<NavTab
key={`mainnav${index}`}
{...propsWithoutSubItems}
{...additionalProps}
/>
);
}
})}
</HStack>
</Container>
</Box>
);
};
const MobileNavigation: FC<Pick<NavigationMenuProps, "closeMobileNav">> = ({
closeMobileNav,
}) => {
return (
<Stack
p={4}
display={{ md: "none" }}
borderBottom="1px solid"
borderColor={useColorModeValue("gray.200", "gray.700")}>
{navItems.map((navItem, index) => (
<MobileNavItem
key={`mainmobilenav${index}`}
index={index}
closeMobileNav={closeMobileNav}
{...navItem}
/>
))}
</Stack>
);
};
const MobileNavItem: FC<
NavItem & {
index: number;
closeMobileNav: NavigationMenuProps["closeMobileNav"];
}
> = ({ closeMobileNav, ...props }) => {
const { isOpen, onToggle } = useDisclosure();
const onClickHandler = useCallback(() => {
if (props.subItems) {
// Toggle accordeon
onToggle();
} else {
// Close menu on navigate
closeMobileNav();
}
}, [closeMobileNav, onToggle, props.subItems]);
return (
<Stack spacing={4} onClick={onClickHandler}>
<Box>
<Flex
py={2}
as={RouterLink}
to={props.to ?? "#"}
justify="space-between"
align="center"
_hover={{
textDecoration: "none",
}}>
<Box display="flex" alignItems="center">
{props.icon}
<Text as="span" marginLeft={2}>
{props.label}
</Text>
</Box>
{props.subItems && (
<Icon
as={FiChevronDown}
transition="all .25s ease-in-out"
transform={isOpen ? "rotate(180deg)" : ""}
w={6}
h={6}
/>
)}
</Flex>
<Collapse
in={isOpen}
animateOpacity
style={{ marginTop: "0 !important" }}>
<Stack
mt={1}
pl={4}
borderLeft={1}
borderStyle="solid"
borderColor={useColorModeValue("gray.200", "gray.700")}
align="start">
{props.subItems &&
props.subItems.map((subItem, subIndex) => (
<Link
as={RouterLink}
key={`mainmobilenav${props.index}-${subIndex}`}
py={2}
onClick={closeMobileNav}
to={subItem.to}>
{subItem.label}
</Link>
))}
</Stack>
</Collapse>
</Box>
</Stack>
);
};
export { NavigationMenu };

View File

@ -0,0 +1,3 @@
export * from "./Navigation";
export * from "./NavigationHeader";
export * from "./NavigationMenu";

View File

@ -0,0 +1,36 @@
import { MouseEventHandler } from "react";
import { Heading, Stack, Text, useColorModeValue } from "@chakra-ui/react";
import { intl } from "locale";
interface AdminPermissionSelectorProps {
selected?: boolean;
onClick: MouseEventHandler<HTMLElement>;
}
function AdminPermissionSelector({
selected,
onClick,
}: AdminPermissionSelectorProps) {
return (
<Stack
onClick={onClick}
style={{ cursor: "pointer", opacity: selected ? 1 : 0.4 }}
borderWidth="1px"
borderRadius="lg"
w={{ sm: "100%" }}
mb={2}
p={4}
bg={useColorModeValue("white", "gray.900")}
boxShadow={selected ? "2xl" : "base"}>
<Heading fontSize="2xl" fontFamily="body">
{intl.formatMessage({ id: "full-access" })}
</Heading>
<Text color={useColorModeValue("gray.700", "gray.400")}>
{intl.formatMessage({ id: "full-access.description" })}
</Text>
</Stack>
);
}
export { AdminPermissionSelector };

View File

@ -0,0 +1,285 @@
import { ChangeEvent, MouseEventHandler } from "react";
import {
Flex,
Heading,
Select,
Stack,
Text,
useColorModeValue,
} from "@chakra-ui/react";
import { intl } from "locale";
interface PermissionSelectorProps {
capabilities: string[];
selected?: boolean;
onClick: MouseEventHandler<HTMLElement>;
onChange: (i: string[]) => any;
}
function PermissionSelector({
capabilities,
selected,
onClick,
onChange,
}: PermissionSelectorProps) {
const textColor = useColorModeValue("gray.700", "gray.400");
const onSelectChange = ({ target }: ChangeEvent<HTMLSelectElement>) => {
// remove all items starting with target.name
const i: string[] = [];
const re = new RegExp(`^${target.name}\\.`, "g");
capabilities.forEach((capability) => {
if (!capability.match(re)) {
i.push(capability);
}
});
// add a new item, if value is something, and doesn't already exist
if (target.value) {
const c = `${target.name}.${target.value}`;
if (i.indexOf(c) === -1) {
i.push(c);
}
}
onChange(i);
};
const getDefaultValue = (c: string): string => {
if (capabilities.indexOf(`${c}.manage`) !== -1) {
return "manage";
}
if (capabilities.indexOf(`${c}.view`) !== -1) {
return "view";
}
return "";
};
return (
<Stack
onClick={onClick}
style={{ cursor: "pointer", opacity: selected ? 1 : 0.4 }}
borderWidth="1px"
borderRadius="lg"
w={{ sm: "100%" }}
p={4}
bg={useColorModeValue("white", "gray.900")}
boxShadow={selected ? "2xl" : "base"}>
<Heading fontSize="2xl" fontFamily="body">
{intl.formatMessage({ id: "restricted-access" })}
</Heading>
{selected ? (
<Stack spacing={3}>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>
{intl.formatMessage({ id: "access-lists.title" })}
</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("access-lists")}
onChange={onSelectChange}
name="access-lists"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
<option value="view">
{intl.formatMessage({ id: "view-only" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>
{intl.formatMessage({ id: "audit-log.title" })}
</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("audit-log")}
onChange={onSelectChange}
name="audit-log"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="view">
{intl.formatMessage({ id: "view-only" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>
{intl.formatMessage({ id: "certificates.title" })}
</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("certificates")}
onChange={onSelectChange}
name="certificates"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
<option value="view">
{intl.formatMessage({ id: "view-only" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>
{intl.formatMessage({ id: "certificate-authorities.title" })}
</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("certificate-authorities")}
onChange={onSelectChange}
name="certificate-authorities"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
<option value="view">
{intl.formatMessage({ id: "view-only" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>
{intl.formatMessage({ id: "dns-providers.title" })}
</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("dns-providers")}
onChange={onSelectChange}
name="dns-providers"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
<option value="view">
{intl.formatMessage({ id: "view-only" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>{intl.formatMessage({ id: "hosts.title" })}</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("hosts")}
onChange={onSelectChange}
name="hosts"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
<option value="view">
{intl.formatMessage({ id: "view-only" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>
{intl.formatMessage({ id: "host-templates.title" })}
</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("host-templates")}
onChange={onSelectChange}
name="host-templates"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
<option value="view">
{intl.formatMessage({ id: "view-only" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>{intl.formatMessage({ id: "settings.title" })}</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("settings")}
onChange={onSelectChange}
name="settings"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
</Select>
</Flex>
</Stack>
<Stack direction={{ base: "column", md: "row" }}>
<Flex flex={1}>{intl.formatMessage({ id: "users.title" })}</Flex>
<Flex flex={1}>
<Select
defaultValue={getDefaultValue("users")}
onChange={onSelectChange}
name="users"
size="sm"
variant="filled"
disabled={!selected}>
<option value="">
{intl.formatMessage({ id: "no-access" })}
</option>
<option value="manage">
{intl.formatMessage({ id: "full-access" })}
</option>
</Select>
</Flex>
</Stack>
</Stack>
) : (
<Text color={textColor}>
{intl.formatMessage({ id: "restricted-access.description" })}
</Text>
)}
</Stack>
);
}
export { PermissionSelector };

View File

@ -0,0 +1,2 @@
export * from "./AdminPermissionSelector";
export * from "./PermissionSelector";

View File

@ -0,0 +1,20 @@
import { Button, ButtonProps } from "@chakra-ui/react";
function PrettyButton(props: ButtonProps) {
return (
<Button
type="submit"
fontFamily="heading"
bgGradient="linear(to-r, red.400,pink.400)"
color="white"
_hover={{
bgGradient: "linear(to-r, red.400,pink.400)",
boxShadow: "xl",
}}
{...props}>
{props.children}
</Button>
);
}
export { PrettyButton };

View File

@ -0,0 +1,32 @@
import { ReactNode } from "react";
import { Box, Container } from "@chakra-ui/react";
import { Footer, Navigation } from "components";
interface Props {
children?: ReactNode;
}
function SiteWrapper({ children }: Props) {
return (
<Box display="flex" flexDir="column" height="100vh">
<Box flexShrink={0}>
<Navigation />
</Box>
<Box flex="1 0 auto" overflow="auto">
<Container
as="main"
maxW="container.xl"
overflowY="auto"
py={4}
h="full">
{children}
</Container>
</Box>
<Box flexShrink={0}>
<Footer />
</Box>
</Box>
);
}
export { SiteWrapper };

View File

@ -0,0 +1,9 @@
import { Flex, Spinner } from "@chakra-ui/react";
export function SpinnerPage() {
return (
<Flex alignItems="center" justifyContent="center" h="full">
<Spinner />
</Flex>
);
}

View File

@ -0,0 +1,225 @@
import { Avatar, Badge, Text, Tooltip } from "@chakra-ui/react";
import { RowAction, RowActionsMenu } from "components";
import { intl } from "locale";
import getNiceDNSProvider from "modules/Acmesh";
function ActionsFormatter(rowActions: RowAction[]) {
const formatCell = (instance: any) => {
return <RowActionsMenu data={instance.row.original} actions={rowActions} />;
};
return formatCell;
}
function BooleanFormatter() {
const formatCell = ({ value }: any) => {
return (
<Badge color={value ? "cyan.500" : "red.400"}>
{value ? "true" : "false"}
</Badge>
);
};
return formatCell;
}
function CapabilitiesFormatter() {
const formatCell = ({ row, value }: any) => {
const style = {} as any;
if (row?.original?.isDisabled) {
style.textDecoration = "line-through";
}
if (row?.original?.isSystem) {
return (
<Badge color="orange.400" style={style}>
{intl.formatMessage({ id: "capability.system" })}
</Badge>
);
}
if (value?.indexOf("full-admin") !== -1) {
return (
<Badge color="teal.300" style={style}>
{intl.formatMessage({ id: "capability.full-admin" })}
</Badge>
);
}
if (value?.length) {
const strs: string[] = [];
value.map((c: string) => {
strs.push(intl.formatMessage({ id: `capability.${c}` }));
return null;
});
return (
<Tooltip label={strs.join(", \n")}>
<Badge color="cyan.500" style={style}>
{intl.formatMessage(
{ id: "capability-count" },
{ count: value.length },
)}
</Badge>
</Tooltip>
);
}
return null;
};
return formatCell;
}
function CertificateStatusFormatter() {
const formatCell = ({ value }: any) => {
return (
<Badge color={value ? "cyan.500" : "red.400"}>
{value
? intl.formatMessage({ id: "ready" })
: intl.formatMessage({ id: "setup-required" })}
</Badge>
);
};
return formatCell;
}
function DisabledFormatter() {
const formatCell = ({ value, row }: any) => {
if (row?.original?.isDisabled) {
return (
<Text color="red.500">
<Tooltip label={intl.formatMessage({ id: "user.disabled" })}>
{value}
</Tooltip>
</Text>
);
}
return value;
};
return formatCell;
}
function DNSProviderFormatter() {
const formatCell = ({ value }: any) => {
return getNiceDNSProvider(value);
};
return formatCell;
}
function DomainsFormatter() {
const formatCell = ({ value }: any) => {
if (value?.length > 0) {
return (
<>
{value.map((dom: string, idx: number) => {
return (
<Badge key={`domain-${idx}`} color="yellow.400">
{dom}
</Badge>
);
})}
</>
);
}
return <Badge color="red.400">No domains!</Badge>;
};
return formatCell;
}
function GravatarFormatter() {
const formatCell = ({ value }: any) => {
return <Avatar size="sm" src={value} />;
};
return formatCell;
}
function HostStatusFormatter() {
const formatCell = ({ row }: any) => {
if (row.original.isDisabled) {
return (
<Badge color="red.400">{intl.formatMessage({ id: "disabled" })}</Badge>
);
}
if (row.original.certificateId) {
if (row.original.certificate.status === "provided") {
return (
<Badge color="green.400">
{row.original.sslForced
? intl.formatMessage({ id: "https-only" })
: intl.formatMessage({ id: "http-https" })}
</Badge>
);
}
if (row.original.certificate.status === "error") {
return (
<Tooltip label={row.original.certificate.errorMessage}>
<Badge color="red.400">{intl.formatMessage({ id: "error" })}</Badge>
</Tooltip>
);
}
return (
<Badge color="cyan.400">
{intl.formatMessage({
id: `certificate.${row.original.certificate.status}`,
})}
</Badge>
);
}
return (
<Badge color="orange.400">
{intl.formatMessage({ id: "http-only" })}
</Badge>
);
};
return formatCell;
}
function HostTypeFormatter() {
const formatCell = ({ value }: any) => {
return intl.formatMessage({ id: `host-type.${value}` });
};
return formatCell;
}
function IDFormatter() {
const formatCell = ({ value }: any) => {
return <span className="text-muted">{value}</span>;
};
return formatCell;
}
function SecondsFormatter() {
const formatCell = ({ value }: any) => {
return intl.formatMessage({ id: "seconds" }, { seconds: value });
};
return formatCell;
}
export {
ActionsFormatter,
BooleanFormatter,
CapabilitiesFormatter,
CertificateStatusFormatter,
DisabledFormatter,
DNSProviderFormatter,
DomainsFormatter,
GravatarFormatter,
HostStatusFormatter,
HostTypeFormatter,
IDFormatter,
SecondsFormatter,
};

View File

@ -0,0 +1,70 @@
import { ReactNode } from "react";
import {
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
} from "@chakra-ui/react";
import { FiMoreVertical } from "react-icons/fi";
// A row action is a single menu item for the actions column
export interface RowAction {
title: string;
onClick: (e: any, data: any) => any;
show?: (data: any) => any;
disabled?: (data: any) => any;
icon?: any;
}
interface RowActionsProps {
/**
* Row Data
*/
data: any;
/**
* Actions
*/
actions: RowAction[];
}
function RowActionsMenu({ data, actions }: RowActionsProps) {
const elms: ReactNode[] = [];
actions.map((action) => {
if (!action.show || action.show(data)) {
const disabled = action.disabled && action.disabled(data);
elms.push(
<MenuItem
key={`action-${action.title}`}
icon={action.icon}
isDisabled={disabled}
onClick={(e: any) => {
action.onClick(e, data);
}}>
{action.title}
</MenuItem>,
);
}
return null;
});
if (!elms.length) {
return null;
}
return (
<div style={{ textAlign: "right" }}>
<Menu>
<MenuButton
as={IconButton}
aria-label="Actions"
icon={<FiMoreVertical />}
variant="outline"
/>
<MenuList>{elms}</MenuList>
</Menu>
</div>
);
}
export { RowActionsMenu };

View File

@ -0,0 +1,57 @@
export interface TablePagination {
limit: number;
offset: number;
total: number;
}
export interface TableSortBy {
id: string;
desc: boolean;
}
export interface TableFilter {
id: string;
value: any;
}
const tableEvents = {
FILTERS_CHANGED: "FILTERS_CHANGED",
PAGE_CHANGED: "PAGE_CHANGED",
PAGE_SIZE_CHANGED: "PAGE_SIZE_CHANGED",
TOTAL_COUNT_CHANGED: "TOTAL_COUNT_CHANGED",
SORT_CHANGED: "SORT_CHANGED",
};
const tableEventReducer = (state: any, { type, payload }: any) => {
switch (type) {
case tableEvents.PAGE_CHANGED:
return {
...state,
offset: payload * state.limit,
};
case tableEvents.PAGE_SIZE_CHANGED:
return {
...state,
limit: payload,
};
case tableEvents.TOTAL_COUNT_CHANGED:
return {
...state,
total: payload,
};
case tableEvents.SORT_CHANGED:
return {
...state,
sortBy: payload,
};
case tableEvents.FILTERS_CHANGED:
return {
...state,
filters: payload,
};
default:
throw new Error(`Unhandled action type: ${type}`);
}
};
export { tableEvents, tableEventReducer };

View File

@ -0,0 +1,246 @@
import { ReactNode } from "react";
import {
ButtonGroup,
Center,
Flex,
HStack,
IconButton,
Link,
Select,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
VStack,
} from "@chakra-ui/react";
import { TablePagination } from "components";
import { intl } from "locale";
import {
FiChevronsLeft,
FiChevronLeft,
FiChevronsRight,
FiChevronRight,
FiChevronDown,
FiChevronUp,
FiX,
} from "react-icons/fi";
export interface TableLayoutProps {
pagination: TablePagination;
getTableProps: any;
getTableBodyProps: any;
headerGroups: any;
rows: any;
prepareRow: any;
gotoPage: any;
canPreviousPage: any;
previousPage: any;
canNextPage: any;
setPageSize: any;
nextPage: any;
pageCount: any;
pageOptions: any;
visibleColumns: any;
setAllFilters: any;
state: any;
}
function TableLayout({
pagination,
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
gotoPage,
canPreviousPage,
previousPage,
canNextPage,
setPageSize,
nextPage,
pageCount,
pageOptions,
visibleColumns,
setAllFilters,
state,
}: TableLayoutProps) {
const currentPage = state.pageIndex + 1;
const getPageList = () => {
const list = [];
for (let x = 0; x < pageOptions.length; x++) {
list.push(
<option
key={`table-pagination-page-${x}`}
value={x + 1}
selected={currentPage === x + 1}>
{x + 1}
</option>,
);
}
return list;
};
const renderEmpty = (): ReactNode => {
return (
<Tr>
<Td colSpan={visibleColumns.length}>
<Center>
{state?.filters?.length
? intl.formatMessage(
{ id: "tables.no-items-with-filters" },
{ count: state.filters.length },
)
: intl.formatMessage({ id: "tables.no-items" })}
</Center>
</Td>
</Tr>
);
};
return (
<>
<Table {...getTableProps()}>
<Thead>
{headerGroups.map((headerGroup: any) => (
<Tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column: any) => (
<Th
userSelect="none"
className={column.className}
isNumeric={column.isNumeric}>
<Flex alignItems="center">
<HStack mx={6} justifyContent="space-between">
<Text
{...column.getHeaderProps(
column.sortable && column.getSortByToggleProps(),
)}>
{column.render("Header")}
</Text>
{column.sortable && column.isSorted ? (
column.isSortedDesc ? (
<FiChevronDown />
) : (
<FiChevronUp />
)
) : null}
{column.Filter ? column.render("Filter") : null}
</HStack>
</Flex>
</Th>
))}
</Tr>
))}
</Thead>
<Tbody {...getTableBodyProps()}>
{rows.length
? rows.map((row: any) => {
prepareRow(row);
return (
<Tr {...row.getRowProps()}>
{row.cells.map((cell: any) => (
<Td
{...cell.getCellProps([
{
className: cell.column.className,
},
])}>
{cell.render("Cell")}
</Td>
))}
</Tr>
);
})
: renderEmpty()}
</Tbody>
</Table>
<HStack mx={6} my={4} justifyContent="space-between">
<VStack align="left">
<Text color="gray.500">
{rows.length
? intl.formatMessage(
{ id: "tables.pagination-counts" },
{
start: pagination.offset + 1,
end: Math.min(
pagination.total,
pagination.offset + pagination.limit,
),
total: pagination.total,
},
)
: null}
</Text>
{state?.filters?.length ? (
<Link onClick={() => setAllFilters([])}>
<HStack>
<FiX display="inline-block" />
<Text>
{intl.formatMessage(
{ id: "tables.clear-all-filters" },
{ count: state.filters.length },
)}
</Text>
</HStack>
</Link>
) : null}
</VStack>
<nav>
<ButtonGroup size="sm" isAttached>
<IconButton
aria-label={intl.formatMessage({
id: "tables.pagination-previous",
})}
size="sm"
icon={<FiChevronsLeft />}
isDisabled={!canPreviousPage}
onClick={() => gotoPage(0)}
/>
<IconButton
aria-label={intl.formatMessage({
id: "tables.pagination-previous",
})}
size="sm"
icon={<FiChevronLeft />}
isDisabled={!canPreviousPage}
onClick={() => previousPage()}
/>
<Select
size="sm"
variant="filled"
borderRadius={0}
defaultValue={currentPage}
disabled={!canPreviousPage && !canNextPage}
aria-label={intl.formatMessage({
id: "tables.pagination-select",
})}>
{getPageList()}
</Select>
<IconButton
aria-label={intl.formatMessage({
id: "tables.pagination-next",
})}
size="sm"
icon={<FiChevronRight />}
isDisabled={!canNextPage}
onClick={() => nextPage()}
/>
<IconButton
aria-label={intl.formatMessage({
id: "tables.pagination-next",
})}
size="sm"
icon={<FiChevronsRight />}
isDisabled={!canNextPage}
onClick={() => gotoPage(pageCount - 1)}
/>
</ButtonGroup>
</nav>
</HStack>
</>
);
}
export { TableLayout };

View File

@ -0,0 +1,142 @@
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
IconButton,
FormControl,
FormErrorMessage,
Input,
Stack,
ButtonGroup,
Button,
useDisclosure,
Select,
} from "@chakra-ui/react";
import { PrettyButton } from "components";
import { Formik, Form, Field } from "formik";
import { intl } from "locale";
import { validateString } from "modules/Validations";
import FocusLock from "react-focus-lock";
import { FiFilter } from "react-icons/fi";
function TextFilter({ column: { filterValue, setFilter } }: any) {
const { onOpen, onClose, isOpen } = useDisclosure();
const onSubmit = (values: any, { setSubmitting }: any) => {
setFilter(values);
setSubmitting(false);
onClose();
};
const clearFilter = () => {
setFilter(undefined);
onClose();
};
const isFiltered = (): boolean => {
return !(typeof filterValue === "undefined" || filterValue === "");
};
return (
<Popover
isOpen={isOpen}
onOpen={onOpen}
onClose={onClose}
placement="right">
<PopoverTrigger>
<IconButton
variant="unstyled"
size="sm"
color={isFiltered() ? "orange.400" : ""}
icon={<FiFilter />}
aria-label="Filter"
/>
</PopoverTrigger>
<PopoverContent p={5}>
<FocusLock returnFocus persistentFocus={false}>
<PopoverArrow />
<Formik
initialValues={
{
modifier: filterValue?.modifier || "contains",
value: filterValue?.value,
} as any
}
onSubmit={onSubmit}>
{({ isSubmitting }) => (
<Form>
<Stack spacing={4}>
<Field name="modifier">
{({ field, form }: any) => (
<FormControl
isRequired
isInvalid={
form.errors.modifier && form.touched.modifier
}>
<Select
{...field}
size="sm"
id="modifier"
defaultValue="contains">
<option value="contains">
{intl.formatMessage({ id: "filter.contains" })}
</option>
<option value="equals">
{intl.formatMessage({ id: "filter.exactly" })}
</option>
<option value="starts">
{intl.formatMessage({ id: "filter.starts" })}
</option>
<option value="ends">
{intl.formatMessage({ id: "filter.ends" })}
</option>
</Select>
<FormErrorMessage>{form.errors.name}</FormErrorMessage>
</FormControl>
)}
</Field>
<Field name="value" validate={validateString(1, 50)}>
{({ field, form }: any) => (
<FormControl
isRequired
isInvalid={form.errors.value && form.touched.value}>
<Input
{...field}
size="sm"
placeholder={intl.formatMessage({
id: "filter.placeholder",
})}
autoComplete="off"
/>
<FormErrorMessage>{form.errors.value}</FormErrorMessage>
</FormControl>
)}
</Field>
<ButtonGroup d="flex" justifyContent="flex-end">
<Button
size="sm"
variant="outline"
onClick={clearFilter}
isLoading={isSubmitting}>
{intl.formatMessage({
id: "filter.clear",
})}
</Button>
<PrettyButton size="sm" isLoading={isSubmitting}>
{intl.formatMessage({
id: "filter.apply",
})}
</PrettyButton>
</ButtonGroup>
</Stack>
</Form>
)}
</Formik>
</FocusLock>
</PopoverContent>
</Popover>
);
}
export { TextFilter };

View File

@ -0,0 +1,5 @@
export * from "./Formatters";
export * from "./RowActionsMenu";
export * from "./TableHelpers";
export * from "./TableLayout";
export * from "./TextFilter";

View File

@ -0,0 +1,130 @@
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-table#configuration-using-declaration-merging
import {
UseColumnOrderInstanceProps,
UseColumnOrderState,
UseExpandedHooks,
UseExpandedInstanceProps,
UseExpandedOptions,
UseExpandedRowProps,
UseExpandedState,
UseFiltersColumnOptions,
UseFiltersColumnProps,
UseFiltersInstanceProps,
UseFiltersOptions,
UseFiltersState,
UseGlobalFiltersColumnOptions,
UseGlobalFiltersInstanceProps,
UseGlobalFiltersOptions,
UseGlobalFiltersState,
UseGroupByCellProps,
UseGroupByColumnOptions,
UseGroupByColumnProps,
UseGroupByHooks,
UseGroupByInstanceProps,
UseGroupByOptions,
UseGroupByRowProps,
UseGroupByState,
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseResizeColumnsColumnOptions,
UseResizeColumnsColumnProps,
UseResizeColumnsOptions,
UseResizeColumnsState,
UseRowSelectHooks,
UseRowSelectInstanceProps,
UseRowSelectOptions,
UseRowSelectRowProps,
UseRowSelectState,
UseRowStateCellProps,
UseRowStateInstanceProps,
UseRowStateOptions,
UseRowStateRowProps,
UseRowStateState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByHooks,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
} from "react-table";
declare module "react-table" {
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
export interface TableOptions<
D extends Record<string, unknown>,
> extends UseExpandedOptions<D>,
UseFiltersOptions<D>,
UseGlobalFiltersOptions<D>,
UseGroupByOptions<D>,
UsePaginationOptions<D>,
UseResizeColumnsOptions<D>,
UseRowSelectOptions<D>,
UseRowStateOptions<D>,
UseSortByOptions<D>,
// note that having Record here allows you to add anything to the options, this matches the spirit of the
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
// feature set, this is a safe default.
Record<string, any> {}
export interface Hooks<
D extends Record<string, unknown> = Record<string, unknown>,
> extends UseExpandedHooks<D>,
UseGroupByHooks<D>,
UseRowSelectHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<
D extends Record<string, unknown> = Record<string, unknown>,
> extends UseColumnOrderInstanceProps<D>,
UseExpandedInstanceProps<D>,
UseFiltersInstanceProps<D>,
UseGlobalFiltersInstanceProps<D>,
UseGroupByInstanceProps<D>,
UsePaginationInstanceProps<D>,
UseRowSelectInstanceProps<D>,
UseRowStateInstanceProps<D>,
UseSortByInstanceProps<D> {}
export interface TableState<
D extends Record<string, unknown> = Record<string, unknown>,
> extends UseColumnOrderState<D>,
UseExpandedState<D>,
UseFiltersState<D>,
UseGlobalFiltersState<D>,
UseGroupByState<D>,
UsePaginationState<D>,
UseResizeColumnsState<D>,
UseRowSelectState<D>,
UseRowStateState<D>,
UseSortByState<D> {}
export interface ColumnInterface<
D extends Record<string, unknown> = Record<string, unknown>,
> extends UseFiltersColumnOptions<D>,
UseGlobalFiltersColumnOptions<D>,
UseGroupByColumnOptions<D>,
UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {}
export interface ColumnInstance<
D extends Record<string, unknown> = Record<string, unknown>,
> extends UseFiltersColumnProps<D>,
UseGroupByColumnProps<D>,
UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {}
export interface Cell<
D extends Record<string, unknown> = Record<string, unknown>,
// V = any,
> extends UseGroupByCellProps<D>,
UseRowStateCellProps<D> {}
export interface Row<
D extends Record<string, unknown> = Record<string, unknown>,
> extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D>,
UseRowStateRowProps<D> {}
}

View File

@ -0,0 +1,34 @@
import {
Icon,
IconButton,
IconButtonProps,
useColorMode,
} from "@chakra-ui/react";
import { intl } from "locale";
import { FiSun, FiMoon } from "react-icons/fi";
interface ThemeSwitcherProps {
background?: "normal" | "transparent";
}
function ThemeSwitcher({ background }: ThemeSwitcherProps) {
const { colorMode, toggleColorMode } = useColorMode();
const additionalProps: Partial<IconButtonProps> = {};
if (background === "transparent") {
additionalProps["backgroundColor"] = "transparent";
}
return (
<IconButton
onClick={toggleColorMode}
{...additionalProps}
aria-label={
colorMode === "light"
? intl.formatMessage({ id: "theme.to-dark" })
: intl.formatMessage({ id: "theme.to-light" })
}
icon={<Icon as={colorMode === "light" ? FiMoon : FiSun} />}
/>
);
}
export { ThemeSwitcher };

View File

@ -0,0 +1,38 @@
import { Box, Flex, Heading, Text, Stack } from "@chakra-ui/react";
import { LocalePicker, ThemeSwitcher } from "components";
import { intl } from "locale";
import { FaTimes } from "react-icons/fa";
function Unhealthy() {
return (
<>
<Stack h={10} m={4} justify="end" direction="row">
<ThemeSwitcher />
<LocalePicker className="text-right" />
</Stack>
<Box textAlign="center" py={10} px={6}>
<Box display="inline-block">
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
bg="red.500"
rounded="50px"
w="55px"
h="55px"
textAlign="center">
<FaTimes size="30px" color="white" />
</Flex>
</Box>
<Heading as="h2" size="xl" mt={6} mb={2}>
{intl.formatMessage({ id: "unhealthy.title" })}
</Heading>
<Text color="gray.500">
{intl.formatMessage({ id: "unhealthy.body" })}
</Text>
</Box>
</>
);
}
export { Unhealthy };

View File

@ -0,0 +1,15 @@
export * from "./EmptyList";
export * from "./Flag";
export * from "./Footer";
export * from "./HelpDrawer";
export * from "./Loader";
export * from "./Loading";
export * from "./LocalePicker";
export * from "./Navigation";
export * from "./Permissions";
export * from "./PrettyButton";
export * from "./SiteWrapper";
export * from "./SpinnerPage";
export * from "./Table";
export * from "./ThemeSwitcher";
export * from "./Unhealthy";