Version 3 starter

This commit is contained in:
Jamie Curnow
2021-06-14 19:29:35 +10:00
parent 60fc57431a
commit 6205434140
642 changed files with 25817 additions and 32319 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);
});

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

@@ -0,0 +1,16 @@
import React from "react";
import Router from "components/Router";
import { AuthProvider, HealthProvider } from "context";
function App() {
return (
<HealthProvider>
<AuthProvider>
<Router />
</AuthProvider>
</HealthProvider>
);
}
export default App;

View File

@@ -0,0 +1,89 @@
import { camelizeKeys, decamelizeKeys } from "humps";
import AuthStore from "modules/AuthStore";
import * as queryString from "query-string";
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(), "Content-Type": "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(), "Content-Type": "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,32 @@
import * as api from "./base";
import { UserResponse } from "./responseTypes";
interface AuthOptions {
type: string;
secret: string;
}
interface Options {
payload: {
name: string;
nickname: string;
email: string;
roles: string[];
isDisabled: boolean;
auth: AuthOptions;
};
}
export async function createUser(
{ payload }: Options,
abortController?: AbortController,
): Promise<UserResponse> {
const { result } = await api.post(
{
url: "/users",
data: payload,
},
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,12 @@
import * as api from "./base";
import { UserResponse } from "./responseTypes";
export async function getUser(
id: number | string = "me",
): Promise<UserResponse> {
const userId = id ? id : "me";
const { result } = await api.get({
url: `/users/${userId}`,
});
return result;
}

View File

@@ -0,0 +1,6 @@
export * from "./createUser";
export * from "./getToken";
export * from "./getUser";
export * from "./refreshToken";
export * from "./requestHealth";
export * from "./responseTypes";

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,15 @@
import * as api from "./base";
import { HealthResponse } from "./responseTypes";
// Request function.
export async function requestHealth(
abortController?: AbortController,
): Promise<HealthResponse> {
const { result } = await api.get(
{
url: "",
},
abortController,
);
return result;
}

View File

@@ -0,0 +1,33 @@
export interface HealthResponse {
commit: string;
errorReporting: boolean;
healthy: boolean;
setup: boolean;
version: string;
}
export interface UserAuthResponse {
id: number;
userId: number;
type: string;
createdOn: number;
updatedOn: number;
}
export interface TokenResponse {
expires: number;
token: string;
}
export interface UserResponse {
id: number;
name: string;
nickname: string;
email: string;
createdOn: number;
updatedOn: number;
roles: string[];
gravatarUrl: string;
isDisabled: boolean;
auth?: UserAuthResponse;
}

View File

@@ -0,0 +1,56 @@
import React from "react";
import { useHealthState } from "context";
import styled from "styled-components";
import { Site } from "tabler-react";
const FixedFooterWrapper = styled.div`
position: fixed;
bottom: 0;
width: 100%;
`;
interface Props {
fixed?: boolean;
}
function Footer({ fixed }: Props) {
const { health } = useHealthState();
const footerNav = (
<div>
<a
href="https://nginxproxymanager.com?utm_source=npm"
target="_blank"
rel="noreferrer">
User Guide
</a>{" "}
{String.fromCharCode(183)}{" "}
<a
href="https://github.com/jc21/nginx-proxy-manager/releases?utm_source=npm"
target="_blank"
rel="noreferrer">
Changelog
</a>{" "}
{String.fromCharCode(183)}{" "}
<a
href="https://github.com/jc21/nginx-proxy-manager?utm_source=npm"
target="_blank"
rel="noreferrer">
Github
</a>
</div>
);
const note =
"v" + health.version + " " + String.fromCharCode(183) + " " + health.commit;
return fixed ? (
<FixedFooterWrapper>
<Site.Footer copyright={note} nav={footerNav} />
</FixedFooterWrapper>
) : (
<Site.Footer copyright={note} nav={footerNav} />
);
}
export { Footer };

View File

@@ -0,0 +1,18 @@
import React from "react";
import styled from "styled-components";
import { Loader } from "tabler-react";
const Root = styled.div`
text-align: center;
`;
function Loading() {
return (
<Root>
<Loader />
</Root>
);
}
export { Loading };

View File

@@ -0,0 +1,57 @@
import React, { lazy, Suspense } from "react";
import { Loading, SiteWrapper, SinglePage } from "components";
import { useAuthState, useHealthState, UserProvider } from "context";
import { BrowserRouter, Switch, Route } from "react-router-dom";
const Setup = lazy(() => import("pages/Setup"));
const Dashboard = lazy(() => import("pages/Dashboard"));
const Login = lazy(() => import("pages/Login"));
function Router() {
const { health } = useHealthState();
const { authenticated } = useAuthState();
const Spinner = (
<SinglePage>
<Loading />
</SinglePage>
);
if (health.loading) {
return Spinner;
}
if (health.healthy && !health.setup) {
return (
<Suspense fallback={Spinner}>
<Setup />
</Suspense>
);
}
if (!authenticated) {
return (
<Suspense fallback={Spinner}>
<Login />
</Suspense>
);
}
return (
<UserProvider>
<BrowserRouter>
<SiteWrapper>
<Suspense fallback={Spinner}>
<Switch>
<Route path="/">
<Dashboard />
</Route>
</Switch>
</Suspense>
</SiteWrapper>
</BrowserRouter>
</UserProvider>
);
}
export default Router;

View File

@@ -0,0 +1,32 @@
import React, { ReactNode } from "react";
import { Footer } from "components";
import styled from "styled-components";
const Root = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100%;
`;
const Wrapper = styled.div`
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
`;
interface Props {
children?: ReactNode;
}
function SinglePage({ children }: Props) {
return (
<Root>
<Wrapper>{children}</Wrapper>
<Footer />
</Root>
);
}
export { SinglePage };

View File

@@ -0,0 +1,193 @@
import React, { ReactNode } from "react";
import { Footer } from "components";
import { useAuthState, useUserState } from "context";
import styled from "styled-components";
import { Site, Container, Button, Grid, List } from "tabler-react";
const StyledContainer = styled(Container)`
padding-bottom: 30px;
`;
interface Props {
children?: ReactNode;
}
function SiteWrapper({ children }: Props) {
const user = useUserState();
const { logout } = useAuthState();
const accountDropdownProps = {
avatarURL: user.gravatarUrl,
name: user.nickname,
description: user.roles.includes("admin")
? "Administrator"
: "Standard User",
options: [
{ icon: "user", value: "Profile" },
{ icon: "settings", value: "Settings" },
{ isDivider: true },
{
icon: "help-circle",
value: "Need help?",
href: "https://nginxproxymanager.com",
target: "_blank",
},
{ icon: "log-out", value: "Log out", onClick: logout },
],
};
const navBarItems = [
{
value: "Home",
to: "/",
icon: "home",
//LinkComponent: withRouter(NavLink),
useExact: true,
},
{
value: "Interface",
icon: "box",
subItems: [
{
value: "Cards Design",
to: "/cards",
//LinkComponent: withRouter(NavLink),
},
//{ value: "Charts", to: "/charts", LinkComponent: withRouter(NavLink) },
{
value: "Pricing Cards",
to: "/pricing-cards",
//LinkComponent: withRouter(NavLink),
},
],
},
{
value: "Components",
icon: "calendar",
/*
subItems: [
{ value: "Maps", to: "/maps", LinkComponent: withRouter(NavLink) },
{ value: "Icons", to: "/icons", LinkComponent: withRouter(NavLink) },
{ value: "Store", to: "/store", LinkComponent: withRouter(NavLink) },
{ value: "Blog", to: "/blog", LinkComponent: withRouter(NavLink) },
],
*/
},
{
value: "Pages",
icon: "file",
subItems: [
{
value: "Profile",
to: "/profile",
//LinkComponent: withRouter(NavLink),
},
//{ value: "Login", to: "/login", LinkComponent: withRouter(NavLink) },
{
value: "Register",
to: "/register",
//LinkComponent: withRouter(NavLink),
},
{
value: "Forgot password",
to: "/forgot-password",
//LinkComponent: withRouter(NavLink),
},
{
value: "Empty page",
to: "/empty-page",
//LinkComponent: withRouter(NavLink),
},
//{ value: "RTL", to: "/rtl", LinkComponent: withRouter(NavLink) },
],
},
{
value: "Forms",
to: "/form-elements",
icon: "check-square",
//LinkComponent: withRouter(NavLink),
},
{
value: "Gallery",
to: "/gallery",
icon: "image",
//LinkComponent: withRouter(NavLink),
},
{
icon: "file-text",
value: "Documentation",
to:
process.env.NODE_ENV === "production"
? "https://tabler.github.io/tabler-react/documentation"
: "/documentation",
},
];
return (
<Site.Wrapper
headerProps={{
href: "/",
alt: "Nginx Proxy Manager",
imageURL: "/images/logo-bold-horizontal-grey.svg",
accountDropdown: accountDropdownProps,
}}
navProps={{ itemsObjects: navBarItems }}
//routerContextComponentType={withRouter(RouterContextProvider)}
footerProps={{
links: [
<a href="#asd">First Link</a>,
<a href="#fg">Second Link</a>,
<a href="#dsg">Third Link</a>,
<a href="#egf">Fourth Link</a>,
<a href="#dsf">Five Link</a>,
<a href="#sdfg">Sixth Link</a>,
<a href="#sdf">Seventh Link</a>,
<a href="#sdf">Eigth Link</a>,
],
note: "Premium and Open Source dashboard template with responsive and high quality UI. For Free!",
copyright: (
<React.Fragment>
Copyright © 2019
<a href="."> Tabler-react</a>. Theme by
<a
href="https://codecalm.net"
target="_blank"
rel="noopener noreferrer">
{" "}
codecalm.net
</a>{" "}
All rights reserved.
</React.Fragment>
),
nav: (
<React.Fragment>
<Grid.Col auto={true}>
<List className="list-inline list-inline-dots mb-0">
<List.Item className="list-inline-item">
<a href="./docs/index.html">Documentation</a>
</List.Item>
<List.Item className="list-inline-item">
<a href="./faq.html">FAQ</a>
</List.Item>
</List>
</Grid.Col>
<Grid.Col auto={true}>
<Button
href="https://github.com/tabler/tabler-react"
size="sm"
outline
color="primary"
RootComponent="a">
Source code
</Button>
</Grid.Col>
</React.Fragment>
),
}}>
<StyledContainer>{children}</StyledContainer>
<Footer fixed />
</Site.Wrapper>
);
}
export { SiteWrapper };

View File

@@ -0,0 +1,45 @@
import React from "react";
import styled from "styled-components";
import { Alert } from "tabler-react";
const Root = styled.div`
padding: 20vh 10vw 0 10vw;
&& .ant-alert-warning {
background-color: #2a2a2a;
border: 2px solid #2ab1a4;
color: #eee;
}
&& .ant-alert-message {
color: #fff;
font-size: 6vh;
}
&& .ant-alert-description {
font-size: 4vh;
line-height: 5vh;
}
&& .ant-alert-with-description {
padding-left: 23vh;
}
&& .ant-alert-with-description .ant-alert-icon {
font-size: 15vh;
}
`;
function Unhealthy() {
return (
<Root>
<Alert type="warning" icon="alert-triangle">
Nginx Proxy Manager is <strong>unhealthy</strong>. We'll continue to
check the health and hope to be back up and running soon!
</Alert>
</Root>
);
}
export { Unhealthy };

View File

@@ -0,0 +1,6 @@
export * from "./Footer";
export * from "./Loading";
export * from "./Router";
export * from "./SinglePage";
export * from "./SiteWrapper";
export * from "./Unhealthy";

View File

@@ -0,0 +1,56 @@
import React from "react";
import { useHealthState } from "context";
import styled from "styled-components";
import { Site } from "tabler-react";
const FixedFooterWrapper = styled.div`
position: fixed;
bottom: 0;
width: 100%;
`;
interface Props {
fixed?: boolean;
}
function Footer({ fixed }: Props) {
const { health } = useHealthState();
const footerNav = (
<div>
<a
href="https://nginxproxymanager.com?utm_source=npm"
target="_blank"
rel="noreferrer">
User Guide
</a>{" "}
{String.fromCharCode(183)}{" "}
<a
href="https://github.com/jc21/nginx-proxy-manager/releases?utm_source=npm"
target="_blank"
rel="noreferrer">
Changelog
</a>{" "}
{String.fromCharCode(183)}{" "}
<a
href="https://github.com/jc21/nginx-proxy-manager?utm_source=npm"
target="_blank"
rel="noreferrer">
Github
</a>
</div>
);
const note =
"v" + health.version + " " + String.fromCharCode(183) + " " + health.commit;
return fixed ? (
<FixedFooterWrapper>
<Site.Footer copyright={note} nav={footerNav} />
</FixedFooterWrapper>
) : (
<Site.Footer copyright={note} nav={footerNav} />
);
}
export { Footer };

View File

@@ -0,0 +1,130 @@
import React from "react";
import { useAuthState, useUserState } from "context";
import { Site } from "tabler-react";
function Header() {
const user = useUserState();
const { logout } = useAuthState();
const accountDropdownProps = {
avatarURL: user.gravatarUrl,
name: user.nickname,
description: user.roles.includes("admin")
? "Administrator"
: "Standard User",
options: [
{ icon: "user", value: "Profile" },
{ icon: "settings", value: "Settings" },
{ isDivider: true },
{
icon: "help-circle",
value: "Need help?",
href: "https://nginxproxymanager.com",
target: "_blank",
},
{ icon: "log-out", value: "Log out", onClick: logout },
],
};
const navBarItems = [
{
value: "Home",
to: "/",
icon: "home",
//LinkComponent: withRouter(NavLink),
useExact: true,
},
{
value: "Interface",
icon: "box",
subItems: [
{
value: "Cards Design",
to: "/cards",
//LinkComponent: withRouter(NavLink),
},
//{ value: "Charts", to: "/charts", LinkComponent: withRouter(NavLink) },
{
value: "Pricing Cards",
to: "/pricing-cards",
//LinkComponent: withRouter(NavLink),
},
],
},
{
value: "Components",
icon: "calendar",
/*
subItems: [
{ value: "Maps", to: "/maps", LinkComponent: withRouter(NavLink) },
{ value: "Icons", to: "/icons", LinkComponent: withRouter(NavLink) },
{ value: "Store", to: "/store", LinkComponent: withRouter(NavLink) },
{ value: "Blog", to: "/blog", LinkComponent: withRouter(NavLink) },
],
*/
},
{
value: "Pages",
icon: "file",
subItems: [
{
value: "Profile",
to: "/profile",
//LinkComponent: withRouter(NavLink),
},
//{ value: "Login", to: "/login", LinkComponent: withRouter(NavLink) },
{
value: "Register",
to: "/register",
//LinkComponent: withRouter(NavLink),
},
{
value: "Forgot password",
to: "/forgot-password",
//LinkComponent: withRouter(NavLink),
},
{
value: "Empty page",
to: "/empty-page",
//LinkComponent: withRouter(NavLink),
},
//{ value: "RTL", to: "/rtl", LinkComponent: withRouter(NavLink) },
],
},
{
value: "Forms",
to: "/form-elements",
icon: "check-square",
//LinkComponent: withRouter(NavLink),
},
{
value: "Gallery",
to: "/gallery",
icon: "image",
//LinkComponent: withRouter(NavLink),
},
{
icon: "file-text",
value: "Documentation",
to:
process.env.NODE_ENV === "production"
? "https://tabler.github.io/tabler-react/documentation"
: "/documentation",
},
];
return (
<>
<Site.Header
href="/"
alt="Nginx Proxy Manager"
imageURL="/images/logo-bold-horizontal-grey.svg"
accountDropdown={accountDropdownProps}
/>
<Site.Nav itemsObjects={navBarItems} />
</>
);
}
export { Header };

View File

@@ -0,0 +1,2 @@
export * from "./Footer";
export * from "./Header";

View File

@@ -0,0 +1,76 @@
import React, { ReactNode, useState } from "react";
import { getToken, refreshToken, TokenResponse } from "api/npm";
import AuthStore from "modules/AuthStore";
import { useInterval } from "rooks";
// Context
export interface AuthContextType {
authenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
token?: string;
}
const initalValue = null;
const AuthContext = React.createContext<AuthContextType | null>(initalValue);
// Provider
interface Props {
children?: ReactNode;
tokenRefreshInterval?: number;
}
function AuthProvider({
children,
tokenRefreshInterval = 5 * 60 * 1000,
}: Props) {
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);
};
const refresh = async () => {
const response = await refreshToken();
handleTokenUpdate(response);
};
useInterval(
() => {
if (authenticated) {
refresh();
}
},
tokenRefreshInterval,
true,
);
const value = { authenticated, login, logout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
function useAuthState() {
const context = React.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,82 @@
import React, { ReactNode, useState, useEffect, useCallback } from "react";
import { HealthResponse, requestHealth } from "api/npm";
import { Unhealthy } from "components";
import { useInterval } from "rooks";
interface HealthResponseLoaded extends HealthResponse {
loading: boolean;
}
export interface HealthContextType {
health: HealthResponseLoaded;
refreshHealth: () => void;
}
const initalValue = null;
const HealthContext =
React.createContext<HealthContextType | null>(initalValue);
interface Props {
children: ReactNode;
}
function HealthProvider({ children }: Props) {
const [health, setHealth] = useState({
loading: true,
commit: "",
healthy: false,
setup: false,
errorReporting: true,
version: "",
});
const handleHealthUpdate = (response: HealthResponse) => {
setHealth({ ...response, loading: false });
};
const refreshHealth = async () => {
const response = await requestHealth();
handleHealthUpdate(response);
};
const asyncFetch = useCallback(async () => {
try {
const response = await requestHealth();
handleHealthUpdate(response);
if (response.healthy) {
if (!health.loading && health.commit !== response.commit) {
// New backend version detected, let's reload the entire window
window.location.assign(`?hash=${response.commit}`);
}
}
} catch ({ message }) {
console.error("Health failed:", message);
}
}, [health.commit, health.loading]);
useEffect(() => {
asyncFetch();
}, [asyncFetch]);
useInterval(asyncFetch, 30 * 1000, true);
if (!health.loading && !health.healthy) {
return <Unhealthy />;
}
const value = { health, refreshHealth };
return (
<HealthContext.Provider value={value}>{children}</HealthContext.Provider>
);
}
function useHealthState() {
const context = React.useContext(HealthContext);
if (!context) {
throw new Error(`useHealthState must be used within a HealthProvider`);
}
return context;
}
export { HealthProvider, useHealthState };
export default HealthContext;

View File

@@ -0,0 +1,54 @@
import React, { useState, useEffect } from "react";
import { getUser, UserResponse } from "api/npm";
import { useAuthState } from "context";
// Context
const initalValue = null;
const UserContext = React.createContext<UserResponse | null>(initalValue);
// Provider
interface Props {
children?: JSX.Element;
}
function UserProvider({ children }: Props) {
const [userData, setUserData] = useState<UserResponse>({
id: 0,
name: "",
nickname: "",
email: "",
createdOn: 0,
updatedOn: 0,
roles: [],
gravatarUrl: "",
isDisabled: false,
});
const { authenticated } = useAuthState();
const fetchUserData = async () => {
const response = await getUser();
setUserData({ ...userData, ...response });
};
useEffect(() => {
if (authenticated) {
fetchUserData();
}
/* eslint-disable-next-line */
}, [authenticated]);
return (
<UserContext.Provider value={userData}>{children}</UserContext.Provider>
);
}
function useUserState() {
const context = React.useContext(UserContext);
if (!context) {
throw new Error(`useUserState must be used within a UserProvider`);
}
return context;
}
export { UserProvider, useUserState };
export default UserContext;

View File

@@ -0,0 +1,3 @@
export * from "./AuthContext";
export * from "./HealthContext";
export * from "./UserContext";

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

@@ -0,0 +1,62 @@
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);
}
.btn {
text-transform: none;
}
.btn-loading {
color: transparent !important;
pointer-events: none;
position: relative;
&:after {
content: "";
-webkit-animation: loader 500ms infinite linear;
animation: loader 500ms infinite linear;
border: 2px solid #fff;
border-radius: 50%;
border-right-color: transparent !important;
border-top-color: transparent !important;
display: block;
height: 1.4em;
width: 1.4em;
position: absolute;
left: calc(50% - (1.4em / 2));
top: calc(50% - (1.4em / 2));
-webkit-transform-origin: center;
transform-origin: center;
position: absolute !important;
}
}
.footer {
background: #fff;
border-top: 1px solid rgba(0, 40, 100, 0.12);
font-size: 0.875rem;
padding: 1.25rem 0;
color: #9aa0ac;
a:not(.btn) {
color: #6e7687;
text-decoration: none;
background-color: initial;
&:hover {
text-decoration: underline;
}
}
}

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

@@ -0,0 +1,10 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import App from "./App";
import "./tabler.css";
import "./index.scss";
ReactDOM.render(<App />, document.getElementById("root"));

View File

@@ -0,0 +1,84 @@
import { TokenResponse } from "api/npm";
export const TOKEN_KEY = "authentications";
export class AuthStore {
// Get all tokens from stack
get tokens() {
const t = localStorage.getItem(TOKEN_KEY);
let tokens = [];
if (t !== null) {
try {
tokens = JSON.parse(t);
} catch (e) {
// do nothing
}
}
return tokens;
}
// Get last token from stack
get token() {
const t = this.tokens;
if (t.length) {
return t[t.length - 1];
}
return null;
}
// Get expires from last token
get expires() {
const t = this.token;
if (t && typeof t.expires !== "undefined") {
const expires = Number(t.expires);
if (expires && !isNaN(expires)) {
return expires;
}
}
return null;
}
// Filter out invalid tokens and return true if we find one that is valid
hasActiveToken() {
const t = this.tokens;
if (!t.length) {
return false;
}
const now = Math.round(new Date().getTime() / 1000);
const oneMinuteBuffer = 60;
for (let i = t.length - 1; i >= 0; i--) {
const valid = t[i].expires - oneMinuteBuffer > now;
if (valid) {
return true;
} else {
this.drop();
}
}
return false;
}
// Set a single token on the stack
set({ token, expires }: TokenResponse) {
localStorage.setItem(TOKEN_KEY, JSON.stringify([{ token, expires }]));
}
// Add a token to the stack
add({ token, expires }: TokenResponse) {
const t = this.tokens;
t.push({ token, expires });
localStorage.setItem(TOKEN_KEY, t);
}
// Drop a token from the stack
drop() {
const t = this.tokens;
localStorage.setItem(TOKEN_KEY, t.splice(-1, 1));
}
clear() {
localStorage.removeItem(TOKEN_KEY);
}
}
export default new AuthStore();

View File

@@ -0,0 +1,24 @@
import React from "react";
import { useAuthState } from "context";
import styled from "styled-components";
const Root = styled.div`
display: flex;
flex-direction: column;
`;
function Dashboard() {
const { logout } = useAuthState();
return (
<Root>
Dashboard
<div>
<button onClick={logout}>Logout</button>
</div>
</Root>
);
}
export default Dashboard;

View File

@@ -0,0 +1,94 @@
import React, { useState, ChangeEvent } from "react";
import { SinglePage } from "components";
import { useAuthState } from "context";
import styled from "styled-components";
import { Alert, Button, Container, Form, Card } from "tabler-react";
import logo from "../../img/logo-text-vertical-grey.png";
const Wrapper = styled(Container)`
margin: 15px auto;
max-width: 400px;
display: block;
`;
const LogoWrapper = styled.div`
text-align: center;
padding-bottom: 15px;
`;
function Login() {
const { login } = useAuthState();
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [formData, setFormData] = useState({
email: "",
password: "",
});
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErrorMessage("");
try {
await login(formData.email, formData.password);
} catch ({ message }) {
setErrorMessage(message);
setLoading(false);
}
};
const onChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [target.name]: target.value });
};
const formBody = (
<>
<Card.Title>Login</Card.Title>
<Form method="post" type="card" onSubmit={onSubmit}>
{errorMessage ? <Alert type="danger">{errorMessage}</Alert> : null}
<Form.Group label="Email Address">
<Form.Input
onChange={onChange}
name="email"
type="email"
value={formData.email}
maxLength={150}
disabled={loading}
required
/>
</Form.Group>
<Form.Group label="Password">
<Form.Input
onChange={onChange}
name="password"
type="password"
value={formData.password}
minLength={8}
maxLength={100}
disabled={loading}
required
/>
</Form.Group>
<Button color="cyan" loading={loading} block>
Login
</Button>
</Form>
</>
);
return (
<SinglePage>
<Wrapper>
<LogoWrapper>
<img src={logo} alt="Logo" />
</LogoWrapper>
<Card body={formBody} />
</Wrapper>
</SinglePage>
);
}
export default Login;

View File

@@ -0,0 +1,143 @@
import React, { useState, ChangeEvent } from "react";
import { createUser } from "api/npm";
import { SinglePage } from "components";
import { useAuthState, useHealthState } from "context";
import styled from "styled-components";
import { Alert, Button, Container, Form, Card } from "tabler-react";
import logo from "../../img/logo-text-vertical-grey.png";
const Wrapper = styled(Container)`
margin: 15px auto;
max-width: 400px;
display: block;
`;
const LogoWrapper = styled.div`
text-align: center;
padding-bottom: 15px;
`;
function Setup() {
const { refreshHealth } = useHealthState();
const { login } = useAuthState();
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [formData, setFormData] = useState({
name: "",
nickname: "",
email: "",
password: "",
});
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErrorMessage("");
const { password, ...payload } = {
...formData,
...{
isDisabled: false,
roles: ["admin"],
auth: {
type: "password",
secret: formData.password,
},
},
};
try {
const response = await createUser({ payload });
if (response && typeof response.id !== "undefined" && response.id) {
// Success, Login using creds
try {
await login(response.email, password);
// Trigger a Health change
refreshHealth();
// window.location.reload();
} catch ({ message }) {
setErrorMessage(message);
setLoading(false);
}
} else {
setErrorMessage("Unable to create user!");
}
} catch ({ message }) {
setErrorMessage(message);
}
setLoading(false);
};
const onChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [target.name]: target.value });
};
const formBody = (
<>
<Card.Title>Initial Setup</Card.Title>
<Form method="post" type="card" onSubmit={onSubmit}>
{errorMessage ? <Alert type="danger">{errorMessage}</Alert> : null}
<Form.Group label="Full Name">
<Form.Input
onChange={onChange}
name="name"
value={formData.name}
disabled={loading}
required
/>
</Form.Group>
<Form.Group label="Nickname">
<Form.Input
onChange={onChange}
name="nickname"
value={formData.nickname}
disabled={loading}
required
/>
</Form.Group>
<Form.Group label="Email Address">
<Form.Input
onChange={onChange}
name="email"
type="email"
value={formData.email}
maxLength={150}
disabled={loading}
required
/>
</Form.Group>
<Form.Group label="Password">
<Form.Input
onChange={onChange}
name="password"
type="password"
value={formData.password}
minLength={8}
maxLength={100}
disabled={loading}
required
/>
</Form.Group>
<Button color="cyan" loading={loading} block>
Create Account
</Button>
</Form>
</>
);
return (
<SinglePage>
<Wrapper>
<LogoWrapper>
<img src={logo} alt="Logo" />
</LogoWrapper>
<Card body={formBody} />
</Wrapper>
</SinglePage>
);
}
export default Setup;

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

2
frontend/src/tabler.css Executable file
View File

@@ -0,0 +1,2 @@
@charset "UTF-8";
@import url("https://unpkg.com/@tabler/core@1.0.0-beta3/dist/css/tabler.min.css");