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

11
frontend/src/App.test.tsx Normal file
View File

@ -0,0 +1,11 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import App from "./App";
it("renders without crashing", () => {
const div = document.createElement("div");
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

31
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,31 @@
import { ChakraProvider } from "@chakra-ui/react";
import { AuthProvider, LocaleProvider } from "context";
import { intl } from "locale";
import { RawIntlProvider } from "react-intl";
import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import Router from "./Router";
import lightTheme from "./theme/customTheme";
// Create a client
const queryClient = new QueryClient();
function App() {
return (
<RawIntlProvider value={intl}>
<LocaleProvider>
<QueryClientProvider client={queryClient}>
<ChakraProvider theme={lightTheme}>
<AuthProvider>
<Router />
</AuthProvider>
</ChakraProvider>
<ReactQueryDevtools />
</QueryClientProvider>
</LocaleProvider>
</RawIntlProvider>
);
}
export default App;

81
frontend/src/Router.tsx Normal file
View File

@ -0,0 +1,81 @@
import { lazy, Suspense } from "react";
import { SiteWrapper, SpinnerPage, Unhealthy } from "components";
import { useAuthState, useLocaleState } from "context";
import { useHealth } from "hooks";
import { BrowserRouter, Routes, Route } from "react-router-dom";
const AccessLists = lazy(() => import("pages/AccessLists"));
const AuditLog = lazy(() => import("pages/AuditLog"));
const Certificates = lazy(() => import("pages/Certificates"));
const CertificateAuthorities = lazy(
() => import("pages/CertificateAuthorities"),
);
const Dashboard = lazy(() => import("pages/Dashboard"));
const DNSProviders = lazy(() => import("pages/DNSProviders"));
const Hosts = lazy(() => import("pages/Hosts"));
const HostTemplates = lazy(() => import("pages/HostTemplates"));
const Login = lazy(() => import("pages/Login"));
const GeneralSettings = lazy(() => import("pages/Settings"));
const Setup = lazy(() => import("pages/Setup"));
const Users = lazy(() => import("pages/Users"));
function Router() {
const health = useHealth();
const { authenticated } = useAuthState();
const { locale } = useLocaleState();
const Spinner = <SpinnerPage />;
if (health.isLoading) {
return Spinner;
}
if (health.isError || !health.data?.healthy) {
return <Unhealthy />;
}
if (health.data?.healthy && !health.data?.setup) {
return (
<Suspense fallback={Spinner}>
<Setup />
</Suspense>
);
}
if (!authenticated) {
return (
<Suspense fallback={Spinner}>
<Login />
</Suspense>
);
}
return (
<BrowserRouter>
<SiteWrapper key={`locale-${locale}`}>
<Suspense fallback={Spinner}>
<Routes>
<Route path="/hosts" element={<Hosts />} />
<Route path="/ssl/certificates" element={<Certificates />} />
<Route
path="/ssl/authorities"
element={<CertificateAuthorities />}
/>
<Route path="/ssl/dns-providers" element={<DNSProviders />} />
<Route path="/audit-log" element={<AuditLog />} />
<Route path="/access-lists" element={<AccessLists />} />
<Route path="/users" element={<Users />} />
<Route
path="/settings/host-templates"
element={<HostTemplates />}
/>
<Route path="/settings/general" element={<GeneralSettings />} />
<Route path="/" element={<Dashboard />} />
</Routes>
</Suspense>
</SiteWrapper>
</BrowserRouter>
);
}
export default Router;

View File

@ -0,0 +1,95 @@
import { camelizeKeys, decamelizeKeys } from "humps";
import AuthStore from "modules/AuthStore";
import * as queryString from "query-string";
const contentTypeHeader = "Content-Type";
interface BuildUrlArgs {
url: string;
params?: queryString.StringifiableRecord;
}
function buildUrl({ url, params }: BuildUrlArgs) {
const endpoint = url.replace(/^\/|\/$/g, "");
const apiParams = params ? `?${queryString.stringify(params)}` : "";
const apiUrl = `/api/${endpoint}${apiParams}`;
return apiUrl;
}
function buildAuthHeader(): Record<string, string> | undefined {
if (AuthStore.token) {
return { Authorization: `Bearer ${AuthStore.token.token}` };
}
return {};
}
function buildBody(data?: Record<string, any>) {
if (data) {
return JSON.stringify(decamelizeKeys(data));
}
}
async function processResponse(response: Response) {
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error.message);
}
return camelizeKeys(payload) as any;
}
interface GetArgs {
url: string;
params?: queryString.StringifiableRecord;
}
export async function get(
{ url, params }: GetArgs,
abortController?: AbortController,
) {
const apiUrl = buildUrl({ url, params });
const method = "GET";
const signal = abortController?.signal;
const headers = buildAuthHeader();
const response = await fetch(apiUrl, { method, headers, signal });
return processResponse(response);
}
interface PostArgs {
url: string;
data?: any;
}
export async function post(
{ url, data }: PostArgs,
abortController?: AbortController,
) {
const apiUrl = buildUrl({ url });
const method = "POST";
const headers = {
...buildAuthHeader(),
[contentTypeHeader]: "application/json",
};
const signal = abortController?.signal;
const body = buildBody(data);
const response = await fetch(apiUrl, { method, headers, body, signal });
return processResponse(response);
}
interface PutArgs {
url: string;
data?: any;
}
export async function put(
{ url, data }: PutArgs,
abortController?: AbortController,
) {
const apiUrl = buildUrl({ url });
const method = "PUT";
const headers = {
...buildAuthHeader(),
[contentTypeHeader]: "application/json",
};
const signal = abortController?.signal;
const body = buildBody(data);
const response = await fetch(apiUrl, { method, headers, body, signal });
return processResponse(response);
}

View File

@ -0,0 +1,16 @@
import * as api from "./base";
import { CertificateAuthority } from "./models";
export async function createCertificateAuthority(
data: CertificateAuthority,
abortController?: AbortController,
): Promise<CertificateAuthority> {
const { result } = await api.post(
{
url: "/certificate-authorities",
data,
},
abortController,
);
return result;
}

View File

@ -0,0 +1,16 @@
import * as api from "./base";
import { DNSProvider } from "./models";
export async function createDNSProvider(
data: DNSProvider,
abortController?: AbortController,
): Promise<DNSProvider> {
const { result } = await api.post(
{
url: "/dns-providers",
data,
},
abortController,
);
return result;
}

View File

@ -0,0 +1,30 @@
import * as api from "./base";
import { User } from "./models";
export interface AuthOptions {
type: string;
secret: string;
}
export interface NewUser {
name: string;
nickname: string;
email: string;
isDisabled: boolean;
auth: AuthOptions;
capabilities: string[];
}
export async function createUser(
data: NewUser,
abortController?: AbortController,
): Promise<User> {
const { result } = await api.post(
{
url: "/users",
data,
},
abortController,
);
return result;
}

View File

@ -0,0 +1,19 @@
import * as api from "./base";
import { CertificateAuthoritiesResponse } from "./responseTypes";
export async function getCertificateAuthorities(
offset = 0,
limit = 10,
sort?: string,
filters?: { [key: string]: string },
abortController?: AbortController,
): Promise<CertificateAuthoritiesResponse> {
const { result } = await api.get(
{
url: "certificate-authorities",
params: { limit, offset, sort, ...filters },
},
abortController,
);
return result;
}

View File

@ -0,0 +1,13 @@
import * as api from "./base";
import { CertificateAuthority } from "./models";
export async function getCertificateAuthority(
id: number,
params = {},
): Promise<CertificateAuthority> {
const { result } = await api.get({
url: `/certificate-authorities/${id}`,
params,
});
return result;
}

View File

@ -0,0 +1,19 @@
import * as api from "./base";
import { CertificatesResponse } from "./responseTypes";
export async function getCertificates(
offset = 0,
limit = 10,
sort?: string,
filters?: { [key: string]: string },
abortController?: AbortController,
): Promise<CertificatesResponse> {
const { result } = await api.get(
{
url: "certificates",
params: { limit, offset, sort, ...filters },
},
abortController,
);
return result;
}

View File

@ -0,0 +1,13 @@
import * as api from "./base";
import { DNSProvider } from "./models";
export async function getDNSProvider(
id: number,
params = {},
): Promise<DNSProvider> {
const { result } = await api.get({
url: `/dns-providers/${id}`,
params,
});
return result;
}

View File

@ -0,0 +1,19 @@
import * as api from "./base";
import { DNSProvidersResponse } from "./responseTypes";
export async function getDNSProviders(
offset = 0,
limit = 10,
sort?: string,
filters?: { [key: string]: string },
abortController?: AbortController,
): Promise<DNSProvidersResponse> {
const { result } = await api.get(
{
url: "dns-providers",
params: { limit, offset, sort, ...filters },
},
abortController,
);
return result;
}

View File

@ -0,0 +1,14 @@
import * as api from "./base";
import { DNSProvidersAcmesh } from "./models";
export async function getDNSProvidersAcmesh(
abortController?: AbortController,
): Promise<DNSProvidersAcmesh[]> {
const { result } = await api.get(
{
url: "dns-providers/acmesh",
},
abortController,
);
return result;
}

View File

@ -0,0 +1,14 @@
import * as api from "./base";
import { HealthResponse } from "./responseTypes";
export async function getHealth(
abortController?: AbortController,
): Promise<HealthResponse> {
const { result } = await api.get(
{
url: "",
},
abortController,
);
return result;
}

View File

@ -0,0 +1,19 @@
import * as api from "./base";
import { HostTemplatesResponse } from "./responseTypes";
export async function getHostTemplates(
offset = 0,
limit = 10,
sort?: string,
filters?: { [key: string]: string },
abortController?: AbortController,
): Promise<HostTemplatesResponse> {
const { result } = await api.get(
{
url: "host-templates",
params: { limit, offset, sort, ...filters },
},
abortController,
);
return result;
}

View File

@ -0,0 +1,19 @@
import * as api from "./base";
import { HostsResponse } from "./responseTypes";
export async function getHosts(
offset = 0,
limit = 10,
sort?: string,
filters?: { [key: string]: string },
abortController?: AbortController,
): Promise<HostsResponse> {
const { result } = await api.get(
{
url: "hosts",
params: { limit, offset, sort, expand: "user,certificate", ...filters },
},
abortController,
);
return result;
}

View File

@ -0,0 +1,19 @@
import * as api from "./base";
import { SettingsResponse } from "./responseTypes";
export async function getSettings(
offset = 0,
limit = 10,
sort?: string,
filters?: { [key: string]: string },
abortController?: AbortController,
): Promise<SettingsResponse> {
const { result } = await api.get(
{
url: "settings",
params: { limit, offset, sort, ...filters },
},
abortController,
);
return result;
}

View File

@ -0,0 +1,24 @@
import * as api from "./base";
import { TokenResponse } from "./responseTypes";
interface Options {
payload: {
type: string;
identity: string;
secret: string;
};
}
export async function getToken(
{ payload }: Options,
abortController?: AbortController,
): Promise<TokenResponse> {
const { result } = await api.post(
{
url: "/tokens",
data: payload,
},
abortController,
);
return result;
}

View File

@ -0,0 +1,14 @@
import * as api from "./base";
import { User } from "./models";
export async function getUser(
id: number | string = "me",
params = {},
): Promise<User> {
const userId = id ? id : "me";
const { result } = await api.get({
url: `/users/${userId}`,
params,
});
return result;
}

View File

@ -0,0 +1,19 @@
import * as api from "./base";
import { UsersResponse } from "./responseTypes";
export async function getUsers(
offset = 0,
limit = 10,
sort?: string,
filters?: { [key: string]: string },
abortController?: AbortController,
): Promise<UsersResponse> {
const { result } = await api.get(
{
url: "users",
params: { limit, offset, sort, expand: "capabilities", ...filters },
},
abortController,
);
return result;
}

View File

@ -0,0 +1,34 @@
import { decamelize } from "humps";
/**
* This will convert a react-table sort object into
* a string that the backend api likes:
* name.asc,id.desc
*/
export function tableSortToAPI(sortBy: any): string | undefined {
if (sortBy?.length > 0) {
const strs: string[] = [];
sortBy.map((item: any) => {
strs.push(decamelize(item.id) + "." + (item.desc ? "desc" : "asc"));
return undefined;
});
return strs.join(",");
}
return;
}
/**
* This will convert a react-table filters object into
* a string that the backend api likes:
* name:contains=jam
*/
export function tableFiltersToAPI(filters: any): { [key: string]: string } {
const items: { [key: string]: string } = {};
if (filters?.length > 0) {
filters.map((item: any) => {
items[`${decamelize(item.id)}:${item.value.modifier}`] = item.value.value;
return undefined;
});
}
return items;
}

View File

@ -0,0 +1,24 @@
export * from "./createCertificateAuthority";
export * from "./createDNSProvider";
export * from "./createUser";
export * from "./getCertificateAuthorities";
export * from "./getCertificateAuthority";
export * from "./getCertificates";
export * from "./getDNSProvider";
export * from "./getDNSProviders";
export * from "./getDNSProvidersAcmesh";
export * from "./getHealth";
export * from "./getHosts";
export * from "./getHostTemplates";
export * from "./getSettings";
export * from "./getToken";
export * from "./getUser";
export * from "./getUsers";
export * from "./helpers";
export * from "./models";
export * from "./refreshToken";
export * from "./responseTypes";
export * from "./setAuth";
export * from "./setCertificateAuthority";
export * from "./setDNSProvider";
export * from "./setUser";

View File

@ -0,0 +1,123 @@
export interface Sort {
field: string;
direction: "ASC" | "DESC";
}
export interface UserAuth {
id: number;
userId: number;
type: string;
createdOn: number;
updatedOn: number;
}
export interface User {
id: number;
name: string;
nickname: string;
email: string;
createdOn: number;
updatedOn: number;
gravatarUrl: string;
isDisabled: boolean;
notifications: Notification[];
capabilities?: string[];
auth?: UserAuth;
}
export interface Notification {
title: string;
seen: boolean;
}
export interface Setting {
id: number;
createdOn: number;
modifiedOn: number;
name: string;
value: any;
}
// TODO: copy pasta not right
export interface Certificate {
id: number;
createdOn: number;
modifiedOn: number;
name: string;
acmeshServer: string;
caBundle: string;
maxDomains: number;
isWildcardSupported: boolean;
isSetup: boolean;
}
export interface CertificateAuthority {
id: number;
createdOn: number;
modifiedOn: number;
name: string;
acmeshServer: string;
caBundle: string;
maxDomains: number;
isWildcardSupported: boolean;
isReadonly: boolean;
}
export interface DNSProvider {
id: number;
createdOn: number;
modifiedOn: number;
userId: number;
name: string;
acmeshName: string;
dnsSleep: number;
meta: any;
}
export interface DNSProvidersAcmeshField {
name: string;
type: string;
metaKey: string;
isRequired: boolean;
isSecret: boolean;
}
export interface DNSProvidersAcmesh {
name: string;
acmeshName: string;
fields: DNSProvidersAcmeshField[];
}
export interface Host {
id: number;
createdOn: number;
modifiedOn: number;
userId: number;
type: string;
hostTemplateId: number;
listenInterface: number;
domainNames: string[];
upstreamId: number;
certificateId: number;
accessListId: number;
sslForced: boolean;
cachingEnabled: boolean;
blockExploits: boolean;
allowWebsocketUpgrade: boolean;
http2Support: boolean;
hstsEnabled: boolean;
hstsSubdomains: boolean;
paths: string;
upstreamOptions: string;
advancedConfig: string;
isDisabled: boolean;
}
export interface HostTemplate {
id: number;
createdOn: number;
modifiedOn: number;
userId: number;
hostType: string;
template: string;
}

View File

@ -0,0 +1,14 @@
import * as api from "./base";
import { TokenResponse } from "./responseTypes";
export async function refreshToken(
abortController?: AbortController,
): Promise<TokenResponse> {
const { result } = await api.get(
{
url: "/tokens",
},
abortController,
);
return result;
}

View File

@ -0,0 +1,58 @@
import {
Certificate,
CertificateAuthority,
DNSProvider,
Host,
HostTemplate,
Setting,
Sort,
User,
} from "./models";
export interface BaseResponse {
total: number;
offset: number;
limit: number;
sort: Sort[];
}
export interface HealthResponse {
commit: string;
errorReporting: boolean;
healthy: boolean;
setup: boolean;
version: string;
}
export interface TokenResponse {
expires: number;
token: string;
}
export interface SettingsResponse extends BaseResponse {
items: Setting[];
}
export interface CertificatesResponse extends BaseResponse {
items: Certificate[];
}
export interface CertificateAuthoritiesResponse extends BaseResponse {
items: CertificateAuthority[];
}
export interface UsersResponse extends BaseResponse {
items: User[];
}
export interface DNSProvidersResponse extends BaseResponse {
items: DNSProvider[];
}
export interface HostsResponse extends BaseResponse {
items: Host[];
}
export interface HostTemplatesResponse extends BaseResponse {
items: HostTemplate[];
}

View File

@ -0,0 +1,18 @@
import * as api from "./base";
import { UserAuth } from "./models";
export async function setAuth(
id: number | string = "me",
data: any,
): Promise<UserAuth> {
const userId = id ? id : "me";
if (data.id) {
delete data.id;
}
const { result } = await api.post({
url: `/users/${userId}/auth`,
data,
});
return result;
}

View File

@ -0,0 +1,17 @@
import * as api from "./base";
import { CertificateAuthority } from "./models";
export async function setCertificateAuthority(
id: number,
data: any,
): Promise<CertificateAuthority> {
if (data.id) {
delete data.id;
}
const { result } = await api.put({
url: `/certificate-authorities/${id}`,
data,
});
return result;
}

View File

@ -0,0 +1,17 @@
import * as api from "./base";
import { DNSProvider } from "./models";
export async function setDNSProvider(
id: number,
data: any,
): Promise<DNSProvider> {
if (data.id) {
delete data.id;
}
const { result } = await api.put({
url: `/dns-providers/${id}`,
data,
});
return result;
}

View File

@ -0,0 +1,18 @@
import * as api from "./base";
import { User } from "./models";
export async function setUser(
id: number | string = "me",
data: any,
): Promise<User> {
const userId = id ? id : "me";
if (data.id) {
delete data.id;
}
const { result } = await api.put({
url: `/users/${userId}`,
data,
});
return result;
}

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";

View File

@ -0,0 +1,79 @@
import { ReactNode, createContext, useContext, useState } from "react";
import { getToken, refreshToken, TokenResponse } from "api/npm";
import AuthStore from "modules/AuthStore";
import { useQueryClient } from "react-query";
import { useIntervalWhen } from "rooks";
// Context
export interface AuthContextType {
authenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
token?: string;
}
const initalValue = null;
const AuthContext = createContext<AuthContextType | null>(initalValue);
// Provider
interface Props {
children?: ReactNode;
tokenRefreshInterval?: number;
}
function AuthProvider({
children,
tokenRefreshInterval = 5 * 60 * 1000,
}: Props) {
const queryClient = useQueryClient();
const [authenticated, setAuthenticated] = useState(
AuthStore.hasActiveToken(),
);
const handleTokenUpdate = (response: TokenResponse) => {
AuthStore.set(response);
setAuthenticated(true);
};
const login = async (identity: string, secret: string) => {
const type = "password";
const response = await getToken({ payload: { type, identity, secret } });
handleTokenUpdate(response);
};
const logout = () => {
AuthStore.clear();
setAuthenticated(false);
queryClient.invalidateQueries("user");
};
const refresh = async () => {
const response = await refreshToken();
handleTokenUpdate(response);
};
useIntervalWhen(
() => {
if (authenticated) {
refresh();
}
},
tokenRefreshInterval,
true,
);
const value = { authenticated, login, logout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
function useAuthState() {
const context = useContext(AuthContext);
if (!context) {
throw new Error(`useAuthState must be used within a AuthProvider`);
}
return context;
}
export { AuthProvider, useAuthState };
export default AuthContext;

View File

@ -0,0 +1,41 @@
import { createContext, ReactNode, useContext, useState } from "react";
import { getLocale } from "locale";
// Context
export interface LocaleContextType {
setLocale: (locale: string) => void;
locale?: string;
}
const initalValue = null;
const LocaleContext = createContext<LocaleContextType | null>(initalValue);
// Provider
interface Props {
children?: ReactNode;
}
function LocaleProvider({ children }: Props) {
const [locale, setLocaleValue] = useState(getLocale());
const setLocale = async (locale: string) => {
setLocaleValue(locale);
};
const value = { locale, setLocale };
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
function useLocaleState() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error(`useLocaleState must be used within a LocaleProvider`);
}
return context;
}
export { LocaleProvider, useLocaleState };
export default LocaleContext;

View File

@ -0,0 +1,2 @@
export * from "./AuthContext";
export * from "./LocaleContext";

1
frontend/src/declarations.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "*.md";

View File

@ -0,0 +1,12 @@
export * from "./useCertificateAuthorities";
export * from "./useCertificateAuthority";
export * from "./useCertificates";
export * from "./useDNSProvider";
export * from "./useDNSProviders";
export * from "./useDNSProvidersAcmesh";
export * from "./useHealth";
export * from "./useHosts";
export * from "./useHostTemplates";
export * from "./useSettings";
export * from "./useUser";
export * from "./useUsers";

View File

@ -0,0 +1,41 @@
import {
CertificateAuthoritiesResponse,
getCertificateAuthorities,
tableSortToAPI,
tableFiltersToAPI,
} from "api/npm";
import { useQuery } from "react-query";
const fetchCertificateAuthorities = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
) => {
return getCertificateAuthorities(
offset,
limit,
tableSortToAPI(sortBy),
tableFiltersToAPI(filters),
);
};
const useCertificateAuthorities = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
options = {},
) => {
return useQuery<CertificateAuthoritiesResponse, Error>(
["certificate-authorities", { offset, limit, sortBy, filters }],
() => fetchCertificateAuthorities(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { fetchCertificateAuthorities, useCertificateAuthorities };

View File

@ -0,0 +1,62 @@
import {
createCertificateAuthority,
getCertificateAuthority,
setCertificateAuthority,
CertificateAuthority,
} from "api/npm";
import { useMutation, useQuery, useQueryClient } from "react-query";
const fetchCertificateAuthority = (id: any) => {
return getCertificateAuthority(id);
};
const useCertificateAuthority = (id: number, options = {}) => {
return useQuery<CertificateAuthority, Error>(
["certificate-authority", id],
() => fetchCertificateAuthority(id),
{
staleTime: 60 * 1000, // 1 minute
...options,
},
);
};
const useSetCertificateAuthority = () => {
const queryClient = useQueryClient();
return useMutation(
(values: CertificateAuthority) => {
return values.id
? setCertificateAuthority(values.id, values)
: createCertificateAuthority(values);
},
{
onMutate: (values) => {
const previousObject = queryClient.getQueryData([
"certificate-authority",
values.id,
]);
queryClient.setQueryData(
["certificate-authority", values.id],
(old: any) => ({
...old,
...values,
}),
);
return () =>
queryClient.setQueryData(
["certificate-authority", values.id],
previousObject,
);
},
onError: (error, values, rollback: any) => rollback(),
onSuccess: async ({ id }: CertificateAuthority) => {
queryClient.invalidateQueries(["certificate-authority", id]);
queryClient.invalidateQueries("certificate-authorities");
},
},
);
};
export { useCertificateAuthority, useSetCertificateAuthority };

View File

@ -0,0 +1,41 @@
import {
getCertificates,
CertificatesResponse,
tableSortToAPI,
tableFiltersToAPI,
} from "api/npm";
import { useQuery } from "react-query";
const fetchCertificates = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
) => {
return getCertificates(
offset,
limit,
tableSortToAPI(sortBy),
tableFiltersToAPI(filters),
);
};
const useCertificates = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
options = {},
) => {
return useQuery<CertificatesResponse, Error>(
["hosts", { offset, limit, sortBy, filters }],
() => fetchCertificates(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { fetchCertificates, useCertificates };

View File

@ -0,0 +1,56 @@
import {
createDNSProvider,
getDNSProvider,
setDNSProvider,
DNSProvider,
} from "api/npm";
import { useMutation, useQuery, useQueryClient } from "react-query";
const fetchDNSProvider = (id: any) => {
return getDNSProvider(id);
};
const useDNSProvider = (id: number, options = {}) => {
return useQuery<DNSProvider, Error>(
["dns-provider", id],
() => fetchDNSProvider(id),
{
staleTime: 60 * 1000, // 1 minute
...options,
},
);
};
const useSetDNSProvider = () => {
const queryClient = useQueryClient();
return useMutation(
(values: DNSProvider) => {
return values.id
? setDNSProvider(values.id, values)
: createDNSProvider(values);
},
{
onMutate: (values) => {
const previousObject = queryClient.getQueryData([
"dns-provider",
values.id,
]);
queryClient.setQueryData(["dns-provider", values.id], (old: any) => ({
...old,
...values,
}));
return () =>
queryClient.setQueryData(["dns-provider", values.id], previousObject);
},
onError: (error, values, rollback: any) => rollback(),
onSuccess: async ({ id }: DNSProvider) => {
queryClient.invalidateQueries(["dns-provider", id]);
queryClient.invalidateQueries("dns-providers");
},
},
);
};
export { useDNSProvider, useSetDNSProvider };

View File

@ -0,0 +1,41 @@
import {
getDNSProviders,
DNSProvidersResponse,
tableSortToAPI,
tableFiltersToAPI,
} from "api/npm";
import { useQuery } from "react-query";
const fetchDNSProviders = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
) => {
return getDNSProviders(
offset,
limit,
tableSortToAPI(sortBy),
tableFiltersToAPI(filters),
);
};
const useDNSProviders = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
options = {},
) => {
return useQuery<DNSProvidersResponse, Error>(
["dns-providers", { offset, limit, sortBy, filters }],
() => fetchDNSProviders(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { useDNSProviders };

View File

@ -0,0 +1,16 @@
import { DNSProvidersAcmesh, getDNSProvidersAcmesh } from "api/npm";
import { useQuery } from "react-query";
const useDNSProvidersAcmesh = (options = {}) => {
return useQuery<DNSProvidersAcmesh[], Error>(
["dns-providers-acmesh"],
() => getDNSProvidersAcmesh(),
{
keepPreviousData: true,
staleTime: 60 * 60 * 1000, // 1 hour
...options,
},
);
};
export { useDNSProvidersAcmesh };

View File

@ -0,0 +1,16 @@
import { getHealth, HealthResponse } from "api/npm";
import { useQuery } from "react-query";
const fetchHealth = () => getHealth();
const useHealth = (options = {}) => {
return useQuery<HealthResponse, Error>("health", fetchHealth, {
refetchOnWindowFocus: false,
retry: 5,
refetchInterval: 15 * 1000, // 15 seconds
staleTime: 14 * 1000, // 14 seconds
...options,
});
};
export { fetchHealth, useHealth };

View File

@ -0,0 +1,41 @@
import {
getHostTemplates,
HostTemplatesResponse,
tableSortToAPI,
tableFiltersToAPI,
} from "api/npm";
import { useQuery } from "react-query";
const fetchHostTemplates = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
) => {
return getHostTemplates(
offset,
limit,
tableSortToAPI(sortBy),
tableFiltersToAPI(filters),
);
};
const useHostTemplates = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
options = {},
) => {
return useQuery<HostTemplatesResponse, Error>(
["hosts", { offset, limit, sortBy, filters }],
() => fetchHostTemplates(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { fetchHostTemplates, useHostTemplates };

View File

@ -0,0 +1,36 @@
import {
getHosts,
HostsResponse,
tableSortToAPI,
tableFiltersToAPI,
} from "api/npm";
import { useQuery } from "react-query";
const fetchHosts = (offset = 0, limit = 10, sortBy?: any, filters?: any) => {
return getHosts(
offset,
limit,
tableSortToAPI(sortBy),
tableFiltersToAPI(filters),
);
};
const useHosts = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
options = {},
) => {
return useQuery<HostsResponse, Error>(
["hosts", { offset, limit, sortBy, filters }],
() => fetchHosts(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { fetchHosts, useHosts };

View File

@ -0,0 +1,36 @@
import {
getSettings,
SettingsResponse,
tableSortToAPI,
tableFiltersToAPI,
} from "api/npm";
import { useQuery } from "react-query";
const fetchSettings = (offset = 0, limit = 10, sortBy?: any, filters?: any) => {
return getSettings(
offset,
limit,
tableSortToAPI(sortBy),
tableFiltersToAPI(filters),
);
};
const useSettings = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
options = {},
) => {
return useQuery<SettingsResponse, Error>(
["settings", { offset, limit, sortBy, filters }],
() => fetchSettings(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { fetchSettings, useSettings };

View File

@ -0,0 +1,37 @@
import { getUser, setUser, User } from "api/npm";
import { useMutation, useQuery, useQueryClient } from "react-query";
const fetchUser = (id: any) => {
return getUser(id, { expand: "capabilities" });
};
const useUser = (id: string | number, options = {}) => {
return useQuery<User, Error>(["user", id], () => fetchUser(id), {
staleTime: 60 * 1000, // 1 minute
...options,
});
};
const useSetUser = () => {
const queryClient = useQueryClient();
return useMutation((values: User) => setUser(values.id, values), {
onMutate: (values) => {
const previousObject = queryClient.getQueryData(["user", values.id]);
queryClient.setQueryData(["user", values.id], (old: any) => ({
...old,
...values,
}));
return () =>
queryClient.setQueryData(["user", values.id], previousObject);
},
onError: (error, values, rollback: any) => rollback(),
onSuccess: async ({ id }: User) => {
queryClient.invalidateQueries(["user", id]);
queryClient.invalidateQueries("users");
},
});
};
export { useUser, useSetUser };

View File

@ -0,0 +1,36 @@
import {
getUsers,
UsersResponse,
tableSortToAPI,
tableFiltersToAPI,
} from "api/npm";
import { useQuery } from "react-query";
const fetchUsers = (offset = 0, limit = 10, sortBy?: any, filters?: any) => {
return getUsers(
offset,
limit,
tableSortToAPI(sortBy),
tableFiltersToAPI(filters),
);
};
const useUsers = (
offset = 0,
limit = 10,
sortBy?: any,
filters?: any,
options = {},
) => {
return useQuery<UsersResponse, Error>(
["users", { offset, limit, sortBy, filters }],
() => fetchUsers(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { fetchUsers, useUsers };

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

59
frontend/src/index.scss Normal file
View File

@ -0,0 +1,59 @@
@import "styles/fonts.scss";
html,
body,
#root {
margin: 0;
padding: 0;
height: 100%;
}
body {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fb;
color: rgb(73, 80, 87);
}
.text-right {
text-align: right;
}
table th.w-80,
table td.w-80 {
width: 80px;
}
/* helpdoc */
.helpdoc-body {
p {
margin: 12px 0;
}
ul, ol {
margin: 0 0 0 20px;
}
h1 {
padding-inline-start: var(--chakra-space-6);
padding-inline-end: var(--chakra-space-6);
padding-top: var(--chakra-space-4);
padding-bottom: var(--chakra-space-4);
padding-left: 0;
border-bottom: 1px solid #aaa;
margin: 0;
font-size: var(--chakra-fontSizes-xl);
font-weight: var(--chakra-fontWeights-semibold);
}
h2 {
padding-inline-start: var(--chakra-space-4);
padding-inline-end: var(--chakra-space-4);
padding-top: var(--chakra-space-4);
padding-left: 0;
margin: 0;
font-size: var(--chakra-fontSizes-lg);
font-weight: var(--chakra-fontWeights-semibold);
}
}

28
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,28 @@
import React from "react";
import { ColorModeScript } from "@chakra-ui/react";
import ReactDOM from "react-dom";
import App from "./App";
import "./index.scss";
import customTheme from "./theme/customTheme";
declare global {
interface Function {
Item: React.FC<any>;
Link: React.FC<any>;
Header: React.FC<any>;
Main: React.FC<any>;
Options: React.FC<any>;
SubTitle: React.FC<any>;
Title: React.FC<any>;
}
}
ReactDOM.render(
<>
<ColorModeScript initialColorMode={customTheme.config.initialColorMode} />
<App />
</>,
document.getElementById("root"),
);

View File

@ -0,0 +1,75 @@
import { createIntl, createIntlCache } from "react-intl";
import langDe from "./lang/de.json";
import langEn from "./lang/en.json";
import langFa from "./lang/fa.json";
import langList from "./lang/lang-list.json";
// first item of each array should be the language code,
// not the country code
// Remember when adding to this list, also update check-locales.js script
const localeOptions = [
["en", "en-US"],
["de", "de-DE"],
["fa", "fa-IR"],
];
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
locale = locale || "en";
switch (locale.slice(0, 2)) {
case "de":
return Object.assign({}, langList, langEn, langDe);
case "fa":
return Object.assign({}, langList, langEn, langFa);
default:
return Object.assign({}, langList, langEn);
}
};
const getFlagCodeForLocale = (locale?: string) => {
switch (locale) {
case "de-DE":
case "de":
return "DE";
case "fa-IR":
case "fa":
return "IR";
default:
return "US";
}
};
const getLocale = (short = false) => {
let loc = window.localStorage.getItem("locale");
if (!loc) {
loc = document.documentElement.lang;
}
if (short) {
return loc.slice(0, 2);
}
return loc;
};
const cache = createIntlCache();
const initialMessages = loadMessages(getLocale());
let intl = createIntl(
{ locale: getLocale(), messages: initialMessages },
cache,
);
const changeLocale = (locale: string): void => {
const messages = loadMessages(locale);
intl = createIntl({ locale, messages }, cache);
window.localStorage.setItem("locale", locale);
document.documentElement.lang = locale;
};
export {
localeOptions,
getFlagCodeForLocale,
getLocale,
createIntl,
changeLocale,
intl,
};

View File

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

View File

@ -0,0 +1,3 @@
# Hilfe zu Zertifizierungsstellen
TODO

View File

@ -0,0 +1 @@
export * as CertificateAuthorities from "./CertificateAuthorities.md";

View File

@ -0,0 +1,26 @@
# Certificate Authorities Help
## What is a Certificate Authority?
A **certificate authority (CA)**, also sometimes referred to as a
**certification authority**, is a company or organization that acts to validate
the identities of entities (such as websites, email addresses, companies, or
individual persons) and bind them to cryptographic keys through the issuance of
electronic documents known as digital certificates.
## Which CA should I use?
Not all CA's are created equal and you would be fine to use the default,
ZeroSSL.
When using another CA it's worth considering the wildcard support and number of
hosts-per-certificate that it supports.
## Can I use my own custom CA?
Yes, you can run your own CA software. You would only do this if you have a
greater understanding of the SSL ecosystem.
When requesting SSL Certificates through your custom CA and while they will be
successful, browsers will not automatically trust your CA and visiting hosts
using certificates issued by that CA will show errors.

View File

@ -0,0 +1 @@
export * as CertificateAuthorities from "./CertificateAuthorities.md";

View File

@ -0,0 +1,3 @@
# کمک مقامات صدور گواهی
TODO

View File

@ -0,0 +1 @@
export * as CertificateAuthorities from "./CertificateAuthorities.md";

View File

@ -0,0 +1,17 @@
import * as de from "./de/index";
import * as en from "./en/index";
import * as fa from "./fa/index";
const items: any = { de, en, fa };
export const getHelpFile = (lang: string, section: string): string => {
if (
typeof items[lang] !== "undefined" &&
typeof items[lang][section] !== "undefined"
) {
return items[lang][section].default;
}
throw new Error(`Cannot load help doc for ${lang}-${section}`);
};
export default items;

View File

@ -0,0 +1,371 @@
{
"access-lists.title": {
"defaultMessage": "Zugriffslisten"
},
"action.edit": {
"defaultMessage": "Bearbeiten"
},
"action.set-password": {
"defaultMessage": "Passwort festlegen"
},
"audit-log.title": {
"defaultMessage": "Audit-Protokoll"
},
"brand.name": {
"defaultMessage": "Nginx Proxy Manager"
},
"capability-count": {
"defaultMessage": "{count} Artikel"
},
"capability.full-admin": {
"defaultMessage": "Vollständiger Administrator"
},
"capability.hosts.view": {
"defaultMessage": "Gastgeber anzeigen"
},
"capability.system": {
"defaultMessage": "System"
},
"capability.users.manage": {
"defaultMessage": "Benutzer verwalten"
},
"certificate-authorities.title": {
"defaultMessage": "Zertifizierungsstellen"
},
"certificate-authority.acmesh-server": {
"defaultMessage": "ACME-Server"
},
"certificate-authority.ca-bundle": {
"defaultMessage": "CA-Zertifikatpaket"
},
"certificate-authority.create": {
"defaultMessage": "Zertifizierungsstelle erstellen"
},
"certificate-authority.edit": {
"defaultMessage": "Zertifizierungsstelle bearbeiten"
},
"certificate-authority.has-wildcard-support": {
"defaultMessage": "Hat Wildcard-Unterstützung"
},
"certificate-authority.max-domains": {
"defaultMessage": "Maximale Domains pro Zertifikat"
},
"certificate-authority.name": {
"defaultMessage": "Name"
},
"certificates.title": {
"defaultMessage": "Zertifikate"
},
"change-password": {
"defaultMessage": "Passwort ändern"
},
"column.acmesh-name": {
"defaultMessage": "Acme.sh-Plugin"
},
"column.description": {
"defaultMessage": "Beschreibung"
},
"column.dns-sleep": {
"defaultMessage": "DNS-Schlaf"
},
"column.domain-names": {
"defaultMessage": "Domänen"
},
"column.host-type": {
"defaultMessage": "Hosttyp"
},
"column.id": {
"defaultMessage": "ICH WÜRDE"
},
"column.max-domains": {
"defaultMessage": "Domänen pro Zertifikat"
},
"column.name": {
"defaultMessage": "Name"
},
"column.status": {
"defaultMessage": "Status"
},
"column.type": {
"defaultMessage": "Art"
},
"column.validation-type": {
"defaultMessage": "Validierung"
},
"column.wildcard-support": {
"defaultMessage": "Wildcard-Unterstützung"
},
"create-certificate": {
"defaultMessage": "Zertifikat erstellen"
},
"create-certificate-title": {
"defaultMessage": "Es gibt keine Zertifikate"
},
"create-dns-provider": {
"defaultMessage": "Erstellen Sie einen DNS-Anbieter"
},
"create-dns-provider-title": {
"defaultMessage": "Es gibt keine DNS-Anbieter"
},
"create-hint": {
"defaultMessage": "Warum erstellen Sie nicht eine?"
},
"create-host": {
"defaultMessage": "Host erstellen"
},
"create-host-template": {
"defaultMessage": "Hostvorlage erstellen"
},
"create-host-title": {
"defaultMessage": "Es gibt keine Proxy-Hosts"
},
"dashboard.title": {
"defaultMessage": "Armaturenbrett"
},
"disabled": {
"defaultMessage": "Deaktiviert"
},
"dns-provider.acmesh-name": {
"defaultMessage": "Acme.sh-Name"
},
"dns-provider.create": {
"defaultMessage": "Erstellen Sie einen DNS-Anbieter"
},
"dns-providers.title": {
"defaultMessage": "DNS-Anbieter"
},
"error": {
"defaultMessage": "Fehler"
},
"error.ca-bundle-does-not-exist": {
"defaultMessage": "Datei existiert nicht auf dem Server"
},
"error.cannot-save-system-users": {
"defaultMessage": "Systembenutzer können nicht geändert werden"
},
"error.current-password-invalid": {
"defaultMessage": "Aktuelles Passwort ist ungültig"
},
"error.database-unavailable": {
"defaultMessage": "Datenbank ist nicht verfügbar"
},
"error.email-already-exists": {
"defaultMessage": "Es existiert bereits ein Benutzer mit dieser E-Mail-Adresse"
},
"error.invalid-login-credentials": {
"defaultMessage": "Ungültige Login-Details"
},
"error.request-failed-validation": {
"defaultMessage": "Back-End-Validierung fehlgeschlagen"
},
"error.user-disabled": {
"defaultMessage": "Konto ist deaktiviert"
},
"filter.apply": {
"defaultMessage": "Anwenden"
},
"filter.clear": {
"defaultMessage": "Klar"
},
"filter.contains": {
"defaultMessage": "Enthält"
},
"filter.ends": {
"defaultMessage": "Endet mit"
},
"filter.exactly": {
"defaultMessage": "Exakt"
},
"filter.placeholder": {
"defaultMessage": "Suchbegriff eingeben"
},
"filter.starts": {
"defaultMessage": "Beginnt mit"
},
"footer.changelog": {
"defaultMessage": "Änderungen"
},
"footer.copyright": {
"defaultMessage": "Copyright © {year} jc21.com"
},
"footer.github": {
"defaultMessage": "Github"
},
"footer.userguide": {
"defaultMessage": "Handbuch"
},
"form.cancel": {
"defaultMessage": "Stornieren"
},
"form.invalid-email": {
"defaultMessage": "Ungültige E-Mail-Adresse"
},
"form.max-int": {
"defaultMessage": "Das Maximum ist {count}"
},
"form.max-length": {
"defaultMessage": "Maximum length is {count, plural, one {# character} other {# characters}}"
},
"form.min-int": {
"defaultMessage": "Das Minimum ist {count}"
},
"form.min-length": {
"defaultMessage": "Minimum length is {count, plural, one {# character} other {# characters}}"
},
"form.required": {
"defaultMessage": "Dies ist erforderlich"
},
"form.save": {
"defaultMessage": "Speichern"
},
"full-access": {
"defaultMessage": "Voller Zugriff"
},
"full-access.description": {
"defaultMessage": "Zugriff auf alle Funktionen"
},
"general-settings.title": {
"defaultMessage": "Allgemeine Einstellungen"
},
"host-templates.title": {
"defaultMessage": "Host-Vorlagen"
},
"hosts.title": {
"defaultMessage": "Gastgeber"
},
"http-https": {
"defaultMessage": "HTTP/HTTPS"
},
"http-only": {
"defaultMessage": "Nur HTTP"
},
"https-only": {
"defaultMessage": "Nur HTTPS"
},
"lets-go": {
"defaultMessage": "Lass uns gehen"
},
"login.login": {
"defaultMessage": "Einloggen"
},
"navigation.close": {
"defaultMessage": "Navigation schließen"
},
"navigation.open": {
"defaultMessage": "Navigation öffnen"
},
"no-access": {
"defaultMessage": "Kein Zugang"
},
"password.confirm": {
"defaultMessage": "Bestätige neues Passwort"
},
"password.current": {
"defaultMessage": "Aktuelles Passwort"
},
"password.new": {
"defaultMessage": "Neues Kennwort"
},
"permissions.title": {
"defaultMessage": "Berechtigungen"
},
"profile.logout": {
"defaultMessage": "Ausloggen"
},
"profile.title": {
"defaultMessage": "Profileinstellungen"
},
"ready": {
"defaultMessage": "Bereit"
},
"restricted-access": {
"defaultMessage": "Eingeschränkter Zugang"
},
"restricted-access.description": {
"defaultMessage": "Passen Sie die Berechtigungen für diesen Benutzer an"
},
"seconds": {
"defaultMessage": "{seconds} Sekunden"
},
"set-password": {
"defaultMessage": "Passwort festlegen"
},
"settings.title": {
"defaultMessage": "Einstellungen"
},
"setup-required": {
"defaultMessage": "Setup Required"
},
"setup.create": {
"defaultMessage": "Registrieren"
},
"setup.title": {
"defaultMessage": "Erstelle deinen ersten Account"
},
"ssl.title": {
"defaultMessage": "SSL"
},
"tables.clear-all-filters": {
"defaultMessage": "{count, plural, one {Filter löschen} other {Löschen Sie # Filter}}"
},
"tables.no-items": {
"defaultMessage": "Es sind keine Artikel vorhandenThere are no items"
},
"tables.no-items-with-filters": {
"defaultMessage": "Es gibt keine Elemente, die {count, plural, one {diesem Filter} other {diesen Filtern}} entsprechen"
},
"tables.pagination-counts": {
"defaultMessage": "Showing {start} to {end} of {total, plural, =0 {no items} one {# item} other {# items}}"
},
"tables.pagination-next": {
"defaultMessage": "Nächste Seite"
},
"tables.pagination-previous": {
"defaultMessage": "Vorherige Seite"
},
"tables.pagination-select": {
"defaultMessage": "Wählen Sie eine Seite aus"
},
"theme.to-dark": {
"defaultMessage": "Wechseln Sie zum dunklen Design"
},
"theme.to-light": {
"defaultMessage": "Wechseln Sie zum Lichtdesign"
},
"unhealthy.body": {
"defaultMessage": "Wir werden weiterhin den Zustand überprüfen und hoffen, bald wieder einsatzbereit zu sein!"
},
"unhealthy.title": {
"defaultMessage": "Nginx Proxy Manager ist fehlerhaft"
},
"user.capabilities": {
"defaultMessage": "Fähigkeiten"
},
"user.create": {
"defaultMessage": "Benutzer erstellen"
},
"user.disabled": {
"defaultMessage": "Benutzer ist deaktiviert"
},
"user.edit": {
"defaultMessage": "Benutzer bearbeiten"
},
"user.email": {
"defaultMessage": "E-Mail"
},
"user.name": {
"defaultMessage": "Name"
},
"user.nickname": {
"defaultMessage": "Benutzername"
},
"user.password": {
"defaultMessage": "Passwort"
},
"users.title": {
"defaultMessage": "Benutzer"
},
"view-only": {
"defaultMessage": "Nur anschauen"
}
}

View File

@ -0,0 +1,506 @@
{
"access-lists.title": {
"defaultMessage": "Access Lists"
},
"acmesh.dns_ad": {
"defaultMessage": "Alwaysdata"
},
"acmesh.dns_ali": {
"defaultMessage": "Aliyun"
},
"acmesh.dns_aws": {
"defaultMessage": "AWS Route53"
},
"acmesh.dns_cf": {
"defaultMessage": "Cloudflare"
},
"acmesh.dns_cloudns": {
"defaultMessage": "ClouDNS.net"
},
"acmesh.dns_cx": {
"defaultMessage": "CloudXNS"
},
"acmesh.dns_cyon": {
"defaultMessage": "Cyon.ch"
},
"acmesh.dns_dgon": {
"defaultMessage": "DigitalOcean"
},
"acmesh.dns_dnsimple": {
"defaultMessage": "DNSimple"
},
"acmesh.dns_dp": {
"defaultMessage": "DNSPod.cn"
},
"acmesh.dns_duckdns": {
"defaultMessage": "DuckDNS"
},
"acmesh.dns_dyn": {
"defaultMessage": "Dyn"
},
"acmesh.dns_dynu": {
"defaultMessage": "Dynu"
},
"acmesh.dns_freedns": {
"defaultMessage": "FreeDNS"
},
"acmesh.dns_gandi_livedns": {
"defaultMessage": "Gandi LiveDNS"
},
"acmesh.dns_gd": {
"defaultMessage": "GoDaddy"
},
"acmesh.dns_he": {
"defaultMessage": "Hurricane Electric"
},
"acmesh.dns_infoblox": {
"defaultMessage": "Infoblox"
},
"acmesh.dns_ispconfig": {
"defaultMessage": "ISPConfig"
},
"acmesh.dns_linode_v4": {
"defaultMessage": "Linode"
},
"acmesh.dns_lua": {
"defaultMessage": "LuaDNS"
},
"acmesh.dns_me": {
"defaultMessage": "DNSMadeEasy"
},
"acmesh.dns_namecom": {
"defaultMessage": "Name.com"
},
"acmesh.dns_nsone": {
"defaultMessage": "NS1.com"
},
"acmesh.dns_pdns": {
"defaultMessage": "PowerDNS"
},
"acmesh.dns_unoeuro": {
"defaultMessage": "UnoEuro"
},
"acmesh.dns_vscale": {
"defaultMessage": "VSCALE"
},
"acmesh.dns_yandex": {
"defaultMessage": "pdd.yandex.ru"
},
"action.edit": {
"defaultMessage": "Edit"
},
"action.set-password": {
"defaultMessage": "Set Password"
},
"audit-log.title": {
"defaultMessage": "Audit Log"
},
"brand.name": {
"defaultMessage": "Nginx Proxy Manager"
},
"capability-count": {
"defaultMessage": "{count} items"
},
"capability.access-lists.manage": {
"defaultMessage": "Manage Access Lists"
},
"capability.access-lists.view": {
"defaultMessage": "View Access Lists"
},
"capability.audit-log.view": {
"defaultMessage": "View Audit Log"
},
"capability.certificate-authorities.manage": {
"defaultMessage": "Manage Certificate Authorities"
},
"capability.certificate-authorities.view": {
"defaultMessage": "View Certificate Authorities"
},
"capability.certificates.manage": {
"defaultMessage": "Manage Certificates"
},
"capability.certificates.view": {
"defaultMessage": "View Certificates"
},
"capability.dns-providers.manage": {
"defaultMessage": "Manage DNS Providers"
},
"capability.dns-providers.view": {
"defaultMessage": "View DNS Providers"
},
"capability.full-admin": {
"defaultMessage": "Full Admin"
},
"capability.host-templates.manage": {
"defaultMessage": "Manage Host Templates"
},
"capability.host-templates.view": {
"defaultMessage": "View Host Templates"
},
"capability.hosts.manage": {
"defaultMessage": "Manage Hosts"
},
"capability.hosts.view": {
"defaultMessage": "View Hosts"
},
"capability.settings.manage": {
"defaultMessage": "Manage Settings"
},
"capability.system": {
"defaultMessage": "System"
},
"capability.users.manage": {
"defaultMessage": "Manage Users"
},
"certificate-authorities.title": {
"defaultMessage": "Certificate Authorities"
},
"certificate-authority.acmesh-server": {
"defaultMessage": "ACME Server"
},
"certificate-authority.ca-bundle": {
"defaultMessage": "CA Certificate Bundle"
},
"certificate-authority.create": {
"defaultMessage": "Create Certificate Authority"
},
"certificate-authority.edit": {
"defaultMessage": "Edit Certificate Authority"
},
"certificate-authority.has-wildcard-support": {
"defaultMessage": "Has Wildcard Support"
},
"certificate-authority.max-domains": {
"defaultMessage": "Maximum Domains per Certificate"
},
"certificate-authority.name": {
"defaultMessage": "Name"
},
"certificates.title": {
"defaultMessage": "Certificates"
},
"change-password": {
"defaultMessage": "Change Password"
},
"column.acmesh-name": {
"defaultMessage": "Acme.sh Plugin"
},
"column.description": {
"defaultMessage": "Description"
},
"column.dns-sleep": {
"defaultMessage": "DNS Sleep"
},
"column.domain-names": {
"defaultMessage": "Domains"
},
"column.host-type": {
"defaultMessage": "Host Type"
},
"column.id": {
"defaultMessage": "ID"
},
"column.max-domains": {
"defaultMessage": "Domains per Cert"
},
"column.name": {
"defaultMessage": "Name"
},
"column.status": {
"defaultMessage": "Status"
},
"column.type": {
"defaultMessage": "Type"
},
"column.validation-type": {
"defaultMessage": "Validation"
},
"column.wildcard-support": {
"defaultMessage": "Wildcard Support"
},
"create-certificate": {
"defaultMessage": "Create Certificate"
},
"create-certificate-title": {
"defaultMessage": "There are no Certificates"
},
"create-dns-provider": {
"defaultMessage": "Create DNS Provider"
},
"create-dns-provider-title": {
"defaultMessage": "There are no DNS Providers"
},
"create-hint": {
"defaultMessage": "Why don't you create one?"
},
"create-host": {
"defaultMessage": "Create Host"
},
"create-host-template": {
"defaultMessage": "Create Host Template"
},
"create-host-title": {
"defaultMessage": "There are no Proxy Hosts"
},
"dashboard.title": {
"defaultMessage": "Dashboard"
},
"disabled": {
"defaultMessage": "Disabled"
},
"dns-provider.acmesh-name": {
"defaultMessage": "Acme.sh Name"
},
"dns-provider.create": {
"defaultMessage": "Create DNS Provider"
},
"dns-providers.title": {
"defaultMessage": "DNS Providers"
},
"error": {
"defaultMessage": "Error"
},
"error.ca-bundle-does-not-exist": {
"defaultMessage": "File doesn't exist on the server"
},
"error.cannot-save-system-users": {
"defaultMessage": "You cannot modify system users"
},
"error.current-password-invalid": {
"defaultMessage": "Current password is invalid"
},
"error.database-unavailable": {
"defaultMessage": "Database is unavailable"
},
"error.email-already-exists": {
"defaultMessage": "A user already exists with this email address"
},
"error.invalid-login-credentials": {
"defaultMessage": "Invalid login credentials"
},
"error.request-failed-validation": {
"defaultMessage": "Failed backend validation"
},
"error.user-disabled": {
"defaultMessage": "Account is disabled"
},
"filter.apply": {
"defaultMessage": "Apply"
},
"filter.clear": {
"defaultMessage": "Clear"
},
"filter.contains": {
"defaultMessage": "Contains"
},
"filter.ends": {
"defaultMessage": "Ends with"
},
"filter.exactly": {
"defaultMessage": "Exactly"
},
"filter.placeholder": {
"defaultMessage": "Enter search query"
},
"filter.starts": {
"defaultMessage": "Begins with"
},
"footer.changelog": {
"defaultMessage": "Change Log"
},
"footer.copyright": {
"defaultMessage": "Copyright © {year} jc21.com"
},
"footer.github": {
"defaultMessage": "Github"
},
"footer.userguide": {
"defaultMessage": "User Guide"
},
"form.cancel": {
"defaultMessage": "Cancel"
},
"form.invalid-email": {
"defaultMessage": "Invalid email address"
},
"form.max-int": {
"defaultMessage": "Maximum is {count}"
},
"form.max-length": {
"defaultMessage": "Maximum length is {count, plural, one {# character} other {# characters}}"
},
"form.min-int": {
"defaultMessage": "Minimum is {count}"
},
"form.min-length": {
"defaultMessage": "Minimum length is {count, plural, one {# character} other {# characters}}"
},
"form.required": {
"defaultMessage": "This is required"
},
"form.save": {
"defaultMessage": "Save"
},
"full-access": {
"defaultMessage": "Full Access"
},
"full-access.description": {
"defaultMessage": "Access to all functionality"
},
"general-settings.title": {
"defaultMessage": "General Settings"
},
"host-templates.title": {
"defaultMessage": "Host Templates"
},
"host-type.dead": {
"defaultMessage": "404 Host"
},
"host-type.proxy": {
"defaultMessage": "Proxy Host"
},
"host-type.redirect": {
"defaultMessage": "Redirection"
},
"host-type.stream": {
"defaultMessage": "Stream"
},
"hosts.title": {
"defaultMessage": "Hosts"
},
"http-https": {
"defaultMessage": "HTTP/HTTPS"
},
"http-only": {
"defaultMessage": "HTTP Only"
},
"https-only": {
"defaultMessage": "HTTPS Only"
},
"lets-go": {
"defaultMessage": "Let's go"
},
"login.login": {
"defaultMessage": "Sign in"
},
"navigation.close": {
"defaultMessage": "Close navigation"
},
"navigation.open": {
"defaultMessage": "Open navigation"
},
"no-access": {
"defaultMessage": "No Access"
},
"password.confirm": {
"defaultMessage": "Confirm New Password"
},
"password.current": {
"defaultMessage": "Current Password"
},
"password.new": {
"defaultMessage": "New Password"
},
"permissions.title": {
"defaultMessage": "Permissions"
},
"profile.logout": {
"defaultMessage": "Logout"
},
"profile.title": {
"defaultMessage": "Profile"
},
"ready": {
"defaultMessage": "Ready"
},
"restricted-access": {
"defaultMessage": "Restricted Access"
},
"restricted-access.description": {
"defaultMessage": "Fine tune permissions for this user"
},
"seconds": {
"defaultMessage": "{seconds} seconds"
},
"set-password": {
"defaultMessage": "Set Password"
},
"settings.title": {
"defaultMessage": "Settings"
},
"setup-required": {
"defaultMessage": "Setup Required"
},
"setup.create": {
"defaultMessage": "Sign up"
},
"setup.title": {
"defaultMessage": "Create your first Account"
},
"ssl.title": {
"defaultMessage": "SSL"
},
"tables.clear-all-filters": {
"defaultMessage": "Clear {count, plural, one {filter} other {# filters}}"
},
"tables.no-items": {
"defaultMessage": "There are no items"
},
"tables.no-items-with-filters": {
"defaultMessage": "There are no items matching {count, plural, one {this filter} other {these filters}}"
},
"tables.pagination-counts": {
"defaultMessage": "Showing {start} to {end} of {total, plural, =0 {no items} one {# item} other {# items}}"
},
"tables.pagination-next": {
"defaultMessage": "Next page"
},
"tables.pagination-previous": {
"defaultMessage": "Previous page"
},
"tables.pagination-select": {
"defaultMessage": "Select a page"
},
"theme.to-dark": {
"defaultMessage": "Switch to dark theme"
},
"theme.to-light": {
"defaultMessage": "Switch to light theme"
},
"unhealthy.body": {
"defaultMessage": "We'll continue to check the health and hope to be back up and running soon!"
},
"unhealthy.title": {
"defaultMessage": "Nginx Proxy Manager is unhealthy"
},
"user.capabilities": {
"defaultMessage": "Capabilities"
},
"user.create": {
"defaultMessage": "Create User"
},
"user.disabled": {
"defaultMessage": "User is Disabled"
},
"user.edit": {
"defaultMessage": "Edit User"
},
"user.email": {
"defaultMessage": "Email"
},
"user.name": {
"defaultMessage": "Name"
},
"user.nickname": {
"defaultMessage": "Nickname"
},
"user.password": {
"defaultMessage": "Password"
},
"users.title": {
"defaultMessage": "Users"
},
"view-only": {
"defaultMessage": "View Only"
}
}

View File

@ -0,0 +1,371 @@
{
"access-lists.title": {
"defaultMessage": "دسترسی به لیست ها"
},
"action.edit": {
"defaultMessage": "ویرایش کنید"
},
"action.set-password": {
"defaultMessage": "قراردادن رمز عبور"
},
"audit-log.title": {
"defaultMessage": "گزارش حسابرسی"
},
"brand.name": {
"defaultMessage": "Nginx Proxy Manager"
},
"capability-count": {
"defaultMessage": "{count} مورد"
},
"capability.full-admin": {
"defaultMessage": "ادمین کامل"
},
"capability.hosts.view": {
"defaultMessage": "مشاهده میزبان ها"
},
"capability.system": {
"defaultMessage": "سیستم"
},
"capability.users.manage": {
"defaultMessage": "مدیریت کاربران"
},
"certificate-authorities.title": {
"defaultMessage": "مقامات صدور گواهینامه"
},
"certificate-authority.acmesh-server": {
"defaultMessage": "سرور ACME"
},
"certificate-authority.ca-bundle": {
"defaultMessage": "بسته گواهی CA"
},
"certificate-authority.create": {
"defaultMessage": "ایجاد مرجع صدور گواهینامه"
},
"certificate-authority.edit": {
"defaultMessage": "ویرایش مرجع صدور گواهی"
},
"certificate-authority.has-wildcard-support": {
"defaultMessage": "دارای پشتیبانی Wildcard"
},
"certificate-authority.max-domains": {
"defaultMessage": "حداکثر دامنه در هر گواهی"
},
"certificate-authority.name": {
"defaultMessage": "نام"
},
"certificates.title": {
"defaultMessage": "گواهینامه ها"
},
"change-password": {
"defaultMessage": "رمز عبور را تغییر دهید"
},
"column.acmesh-name": {
"defaultMessage": "پلاگین Acme.sh"
},
"column.description": {
"defaultMessage": "شرح"
},
"column.dns-sleep": {
"defaultMessage": "DNS صبر کنید"
},
"column.domain-names": {
"defaultMessage": "دامنه ها"
},
"column.host-type": {
"defaultMessage": "نوع میزبان"
},
"column.id": {
"defaultMessage": "شناسه"
},
"column.max-domains": {
"defaultMessage": "دامنه در هر گواهی"
},
"column.name": {
"defaultMessage": "نام"
},
"column.status": {
"defaultMessage": "وضعیت"
},
"column.type": {
"defaultMessage": "تایپ کنید"
},
"column.validation-type": {
"defaultMessage": "اعتبار سنجی"
},
"column.wildcard-support": {
"defaultMessage": "پشتیبانی Wildcard"
},
"create-certificate": {
"defaultMessage": "ایجاد گواهی"
},
"create-certificate-title": {
"defaultMessage": "هیچ گواهی وجود ندارد"
},
"create-dns-provider": {
"defaultMessage": "ارائه دهنده DNS ایجاد کنید"
},
"create-dns-provider-title": {
"defaultMessage": "هیچ ارائه دهنده DNS وجود ندارد"
},
"create-hint": {
"defaultMessage": "چرا یکی را ایجاد نمی کنید؟"
},
"create-host": {
"defaultMessage": "هاست ایجاد کنید"
},
"create-host-template": {
"defaultMessage": "قالب هاست ایجاد کنید"
},
"create-host-title": {
"defaultMessage": "هیچ هاست پروکسی وجود ندارد"
},
"dashboard.title": {
"defaultMessage": "داشبورد"
},
"disabled": {
"defaultMessage": "معلول"
},
"dns-provider.acmesh-name": {
"defaultMessage": "نام Acme.sh"
},
"dns-provider.create": {
"defaultMessage": "ارائه دهنده DNS ایجاد کنید"
},
"dns-providers.title": {
"defaultMessage": "ارائه دهندگان DNS"
},
"error": {
"defaultMessage": "خطا"
},
"error.ca-bundle-does-not-exist": {
"defaultMessage": "فایل در سرور وجود ندارد"
},
"error.cannot-save-system-users": {
"defaultMessage": "شما نمی توانید کاربران سیستم را تغییر دهید"
},
"error.current-password-invalid": {
"defaultMessage": "رمز عبور فعلی نامعتبر است"
},
"error.database-unavailable": {
"defaultMessage": "پایگاه داده در دسترس نیست"
},
"error.email-already-exists": {
"defaultMessage": "کاربری از قبل با این آدرس ایمیل وجود دارد"
},
"error.invalid-login-credentials": {
"defaultMessage": "اعتبار ورود نامعتبر است"
},
"error.request-failed-validation": {
"defaultMessage": "اعتبار سنجی پشتیبان ناموفق بود"
},
"error.user-disabled": {
"defaultMessage": "اکانت غیرفعال است"
},
"filter.apply": {
"defaultMessage": "درخواست دادن"
},
"filter.clear": {
"defaultMessage": "پاک کردن"
},
"filter.contains": {
"defaultMessage": "حاوی"
},
"filter.ends": {
"defaultMessage": "به پایان می رسد با"
},
"filter.exactly": {
"defaultMessage": "دقیقا"
},
"filter.placeholder": {
"defaultMessage": "عبارت جستجو را وارد کنید"
},
"filter.starts": {
"defaultMessage": "شروع با"
},
"footer.changelog": {
"defaultMessage": "ورود به سیستم را تغییر دهید"
},
"footer.copyright": {
"defaultMessage": "حق چاپ © حق چاپ © {year} jc21.com"
},
"footer.github": {
"defaultMessage": "Github"
},
"footer.userguide": {
"defaultMessage": "راهنمای کاربر"
},
"form.cancel": {
"defaultMessage": "لغو کنید"
},
"form.invalid-email": {
"defaultMessage": "آدرس ایمیل نامعتبر"
},
"form.max-int": {
"defaultMessage": "حداکثر {count} است"
},
"form.max-length": {
"defaultMessage": "حداکثر طول {count, plural, one {# character} other {# characters}} کاراکتر است"
},
"form.min-int": {
"defaultMessage": "حداقل {count} است"
},
"form.min-length": {
"defaultMessage": "حداقل طول {count, plural, one {# character} other {# characters}} کاراکتر است"
},
"form.required": {
"defaultMessage": "این مورد نیاز است"
},
"form.save": {
"defaultMessage": "صرفه جویی"
},
"full-access": {
"defaultMessage": "دسترسی کامل"
},
"full-access.description": {
"defaultMessage": "دسترسی به تمام قابلیت ها"
},
"general-settings.title": {
"defaultMessage": "تنظیمات عمومی"
},
"host-templates.title": {
"defaultMessage": "قالب های میزبان"
},
"hosts.title": {
"defaultMessage": "میزبان"
},
"http-https": {
"defaultMessage": "HTTP/HTTPS"
},
"http-only": {
"defaultMessage": "فقط HTTP"
},
"https-only": {
"defaultMessage": "فقط HTTPS"
},
"lets-go": {
"defaultMessage": "بیا بریم"
},
"login.login": {
"defaultMessage": "ورود"
},
"navigation.close": {
"defaultMessage": "بستن ناوبری"
},
"navigation.open": {
"defaultMessage": "ناوبری را باز کنید"
},
"no-access": {
"defaultMessage": "هیچ دسترسی"
},
"password.confirm": {
"defaultMessage": "رمز عبور جدید را تأیید کنید"
},
"password.current": {
"defaultMessage": "رمز عبور فعلی"
},
"password.new": {
"defaultMessage": "رمز عبور جدید"
},
"permissions.title": {
"defaultMessage": "مجوزها"
},
"profile.logout": {
"defaultMessage": "خروج"
},
"profile.title": {
"defaultMessage": "تنظیمات نمایه"
},
"ready": {
"defaultMessage": "آماده"
},
"restricted-access": {
"defaultMessage": "دسترسی محدود"
},
"restricted-access.description": {
"defaultMessage": "مجوزهای تنظیم دقیق برای این کاربر"
},
"seconds": {
"defaultMessage": "{seconds} ثانیه"
},
"set-password": {
"defaultMessage": "قراردادن رمز عبور"
},
"settings.title": {
"defaultMessage": "تنظیمات"
},
"setup-required": {
"defaultMessage": "راه اندازی مورد نیاز است"
},
"setup.create": {
"defaultMessage": "ثبت نام"
},
"setup.title": {
"defaultMessage": "اولین حساب خود را ایجاد کنید"
},
"ssl.title": {
"defaultMessage": "SSL"
},
"tables.clear-all-filters": {
"defaultMessage": "{count, plural, one {فیلتر را پاک کنید} other {# فیلتر را پاک کنید}}"
},
"tables.no-items": {
"defaultMessage": "هیچ آیتمی وجود ندارد"
},
"tables.no-items-with-filters": {
"defaultMessage": "{count, plural, one {هیچ موردی مطابق با این فیلتر وجود ندارد} other {هیچ موردی مطابق با این فیلترها وجود ندارد}}"
},
"tables.pagination-counts": {
"defaultMessage": "نمایش {start} تا {end} مورد از {total} مورد"
},
"tables.pagination-next": {
"defaultMessage": "صفحه بعد"
},
"tables.pagination-previous": {
"defaultMessage": "صفحه قبلی"
},
"tables.pagination-select": {
"defaultMessage": "یک صفحه را انتخاب کنید"
},
"theme.to-dark": {
"defaultMessage": "به طرح زمینه تیره بروید"
},
"theme.to-light": {
"defaultMessage": "به طرح زمینه روشن تغییر دهید"
},
"unhealthy.body": {
"defaultMessage": "ما همچنان به بررسی وضعیت سلامتی خود ادامه خواهیم داد و امیدواریم به زودی دوباره راه اندازی شده و کار کنیم!"
},
"unhealthy.title": {
"defaultMessage": "Nginx Proxy Manager ناسالم است"
},
"user.capabilities": {
"defaultMessage": "توانایی ها"
},
"user.create": {
"defaultMessage": "کاربر ایجاد کنید"
},
"user.disabled": {
"defaultMessage": "کاربر غیرفعال است"
},
"user.edit": {
"defaultMessage": "ویرایش کاربر"
},
"user.email": {
"defaultMessage": "پست الکترونیک"
},
"user.name": {
"defaultMessage": "نام"
},
"user.nickname": {
"defaultMessage": "کنیه"
},
"user.password": {
"defaultMessage": "کلمه عبور"
},
"users.title": {
"defaultMessage": "کاربران"
},
"view-only": {
"defaultMessage": "مشاهده فقط"
}
}

Some files were not shown because too many files have changed in this diff Show More