feat: add setup wizard

This commit is contained in:
Elias Schneider
2022-12-01 23:07:49 +01:00
parent 493705e4ef
commit b579b8f330
32 changed files with 689 additions and 179 deletions

View File

@@ -0,0 +1,95 @@
import { ActionIcon, Code, Group, Skeleton, Table, Text } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react";
import { TbEdit, TbLock } from "react-icons/tb";
import configService from "../../services/config.service";
import { AdminConfig as AdminConfigType } from "../../types/config.type";
import showUpdateConfigVariableModal from "./showUpdateConfigVariableModal";
const AdminConfigTable = () => {
const modals = useModals();
const [isLoading, setIsLoading] = useState(false);
const [configVariables, setConfigVariables] = useState<AdminConfigType[]>([]);
const getConfigVariables = () => {
setIsLoading(true);
configService.listForAdmin().then((configVariables) => {
setConfigVariables(configVariables);
setIsLoading(false);
});
};
useEffect(() => {
getConfigVariables();
}, []);
const skeletonRows = [...Array(9)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={18} width={80} mb="sm" />
<Skeleton height={30} />
</td>
<td>
<Skeleton height={18} />
</td>
<td>
<Group position="right">
<Skeleton height={25} width={25} />
</Group>
</td>
</tr>
));
return (
<Table verticalSpacing="sm" horizontalSpacing="xl" withBorder>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: configVariables.map((element) => (
<tr key={element.key}>
<td style={{ maxWidth: "200px" }}>
<Code>{element.key}</Code> {element.secret && <TbLock />}{" "}
<br />
<Text size="xs" color="dimmed">
{" "}
{element.description}
</Text>
</td>
<td>{element.value}</td>
<td>
<Group position="right">
<ActionIcon
color="primary"
variant="light"
size={25}
onClick={() =>
showUpdateConfigVariableModal(
modals,
element,
getConfigVariables
)
}
>
<TbEdit />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
);
};
export default AdminConfigTable;

View File

@@ -0,0 +1,96 @@
import {
Button,
Code,
NumberInput,
Select,
Space,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import configService from "../../services/config.service";
import { AdminConfig } from "../../types/config.type";
import toast from "../../utils/toast.util";
const showUpdateConfigVariableModal = (
modals: ModalsContextProps,
configVariable: AdminConfig,
getConfigVariables: () => void
) => {
return modals.openModal({
title: <Title order={5}>Update configuration variable</Title>,
children: (
<Body
configVariable={configVariable}
getConfigVariables={getConfigVariables}
/>
),
});
};
const Body = ({
configVariable,
getConfigVariables,
}: {
configVariable: AdminConfig;
getConfigVariables: () => void;
}) => {
const modals = useModals();
const form = useForm({
initialValues: {
stringValue: configVariable.value,
numberValue: parseInt(configVariable.value),
booleanValue: configVariable.value,
},
});
return (
<Stack align="stretch">
<Text>
Set <Code>{configVariable.key}</Code> to
</Text>
{configVariable.type == "string" && (
<TextInput label="Value" {...form.getInputProps("stringValue")} />
)}
{configVariable.type == "number" && (
<NumberInput label="Value" {...form.getInputProps("numberValue")} />
)}
{configVariable.type == "boolean" && (
<Select
data={[
{ value: "true", label: "True" },
{ value: "false", label: "False" },
]}
{...form.getInputProps("booleanValue")}
/>
)}
<Space />
<Button
onClick={async () => {
const value =
configVariable.type == "string"
? form.values.stringValue
: configVariable.type == "number"
? form.values.numberValue
: form.values.booleanValue == "true";
await configService
.update(configVariable.key, value)
.then(() => {
getConfigVariables();
modals.closeAll();
})
.catch((e) => toast.error(e.response.data.message));
}}
>
Save
</Button>
</Stack>
);
};
export default showUpdateConfigVariableModal;

View File

@@ -0,0 +1,86 @@
import {
Anchor,
Button,
Container,
Paper,
PasswordInput,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import Link from "next/link";
import * as yup from "yup";
import useConfig from "../../hooks/config.hook";
import authService from "../../services/auth.service";
import toast from "../../utils/toast.util";
const SignInForm = () => {
const config = useConfig();
const validationSchema = yup.object().shape({
emailOrUsername: yup.string().required(),
password: yup.string().min(8).required(),
});
const form = useForm({
initialValues: {
emailOrUsername: "",
password: "",
},
validate: yupResolver(validationSchema),
});
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/"))
.catch((e) => toast.error(e.response.data.message));
};
return (
<Container size={420} my={40}>
<Title
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
Welcome back
</Title>
{config.get("allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}>
You don't have an account yet?{" "}
<Anchor component={Link} href={"signUp"} size="sm">
{"Sign up"}
</Anchor>
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
signIn(values.emailOrUsername, values.password)
)}
>
<TextInput
label="Email or username"
placeholder="you@email.com"
{...form.getInputProps("emailOrUsername")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
mt="md"
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit">
Sign in
</Button>
</form>
</Paper>
</Container>
);
};
export default SignInForm;

View File

@@ -15,17 +15,19 @@ import useConfig from "../../hooks/config.hook";
import authService from "../../services/auth.service";
import toast from "../../utils/toast.util";
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const SignUpForm = () => {
const config = useConfig();
const validationSchema = yup.object().shape({
email: yup.string().email().required(),
username: yup.string().required(),
password: yup.string().min(8).required(),
});
const form = useForm({
initialValues: {
email: "",
username: "",
password: "",
},
validate: yupResolver(validationSchema),
@@ -34,12 +36,12 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/upload"))
.then(() => window.location.replace("/"))
.catch((e) => toast.error(e.response.data.message));
};
const signUp = (email: string, password: string) => {
const signUp = (email: string, username: string, password: string) => {
authService
.signUp(email, password)
.signUp(email, username, password)
.then(() => signIn(email, password))
.catch((e) => toast.error(e.response.data.message));
};
@@ -53,33 +55,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
fontWeight: 900,
})}
>
{mode == "signUp" ? "Sign up" : "Welcome back"}
Sign up
</Title>
{config.get("allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}>
{mode == "signUp"
? "You have an account already?"
: "You don't have an account yet?"}{" "}
<Anchor
component={Link}
href={mode == "signUp" ? "signIn" : "signUp"}
size="sm"
>
{mode == "signUp" ? "Sign in" : "Sign up"}
You have an account already?{" "}
<Anchor component={Link} href={"signIn"} size="sm">
Sign in
</Anchor>
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
mode == "signIn"
? signIn(values.email, values.password)
: signUp(values.email, values.password)
signUp(values.email, values.username, values.password)
)}
>
<TextInput
label="Username"
placeholder="john.doe"
{...form.getInputProps("username")}
/>
<TextInput
label="Email"
placeholder="you@email.com"
mt="md"
{...form.getInputProps("email")}
/>
<PasswordInput
@@ -89,7 +89,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit">
{mode == "signUp" ? "Let's get started" : "Sign in"}
Let's get started
</Button>
</form>
</Paper>
@@ -97,4 +97,4 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
);
};
export default AuthForm;
export default SignUpForm;

View File

@@ -1,9 +1,12 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core";
import Link from "next/link";
import { TbDoorExit, TbLink } from "react-icons/tb";
import { TbDoorExit, TbLink, TbSettings } from "react-icons/tb";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
const ActionAvatar = () => {
const user = useUser();
return (
<Menu position="bottom-start" withinPortal>
<Menu.Target>
@@ -19,6 +22,16 @@ const ActionAvatar = () => {
>
My shares
</Menu.Item>
{user!.isAdmin && (
<Menu.Item
component={Link}
href="/admin/config"
icon={<TbSettings size={14} />}
>
Administration
</Menu.Item>
)}
<Menu.Item
onClick={async () => {
authService.signOut();

View File

@@ -8,9 +8,10 @@ import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar";
import { ConfigContext } from "../hooks/config.hook";
import useConfig, { ConfigContext } from "../hooks/config.hook";
import { UserContext } from "../hooks/user.hook";
import authService from "../services/auth.service";
import configService from "../services/config.service";
@@ -23,15 +24,17 @@ import { GlobalLoadingContext } from "../utils/loading.util";
function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme();
const router = useRouter();
const config = useConfig();
const [colorScheme, setColorScheme] = useState<ColorScheme>();
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null);
const [config, setConfig] = useState<Config[] | null>(null);
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
const getInitalData = async () => {
setIsLoading(true);
setConfig(await configService.getAll());
setConfigVariables(await configService.list());
await authService.refreshAccessToken();
setUser(await userService.getCurrentUser());
setIsLoading(false);
@@ -42,6 +45,16 @@ function App({ Component, pageProps }: AppProps) {
getInitalData();
}, []);
useEffect(() => {
if (
configVariables &&
configVariables.filter((variable) => variable.key)[0].value == "false" &&
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
) {
router.push(!user ? "/auth/signUp" : "admin/setup");
}
}, [router.asPath]);
useEffect(() => {
setColorScheme(systemTheme);
}, [systemTheme]);
@@ -59,7 +72,7 @@ function App({ Component, pageProps }: AppProps) {
{isLoading ? (
<LoadingOverlay visible overlayOpacity={1} />
) : (
<ConfigContext.Provider value={config}>
<ConfigContext.Provider value={configVariables}>
<UserContext.Provider value={user}>
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Header />

View File

@@ -0,0 +1,13 @@
import { Space } from "@mantine/core";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
const AdminConfig = () => {
return (
<>
<AdminConfigTable />
<Space h="xl" />
</>
);
};
export default AdminConfig;

View File

@@ -0,0 +1,50 @@
import { Button, Stack, Text, Title } from "@mantine/core";
import { useRouter } from "next/router";
import { useState } from "react";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
import Logo from "../../components/Logo";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import configService from "../../services/config.service";
const Setup = () => {
const router = useRouter();
const config = useConfig();
const user = useUser();
const [isLoading, setIsLoading] = useState(false);
if (!user) {
router.push("/auth/signUp");
return;
} else if (config.get("setupFinished")) {
router.push("/");
return;
}
return (
<>
<Stack align="center">
<Logo height={80} width={80} />
<Title order={2}>Welcome to Pingvin Share</Title>
<Text>Let's customize Pingvin Share for you! </Text>
<AdminConfigTable />
<Button
loading={isLoading}
onClick={async () => {
setIsLoading(true);
await configService.finishSetup();
setIsLoading(false);
window.location.reload();
}}
mb={70}
mt="lg"
>
Let me in!
</Button>
</Stack>
</>
);
};
export default Setup;

View File

@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import SignInForm from "../../components/auth/SignInForm";
import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook";
@@ -12,7 +12,7 @@ const SignIn = () => {
return (
<>
<Meta title="Sign In" />
<AuthForm mode="signIn" />
<SignInForm />
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import SignUpForm from "../../components/auth/SignUpForm";
import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
@@ -16,7 +16,7 @@ const SignUp = () => {
return (
<>
<Meta title="Sign Up" />
<AuthForm mode="signUp" />
<SignUpForm />
</>
);
}

View File

@@ -2,15 +2,22 @@ import { getCookie, setCookies } from "cookies-next";
import * as jose from "jose";
import api from "./api.service";
const signIn = async (email: string, password: string) => {
const response = await api.post("auth/signIn", { email, password });
const signIn = async (emailOrUsername: string, password: string) => {
const emailOrUsernameBody = emailOrUsername.includes("@")
? { email: emailOrUsername }
: { username: emailOrUsername };
const response = await api.post("auth/signIn", {
...emailOrUsernameBody,
password,
});
setCookies("access_token", response.data.accessToken);
setCookies("refresh_token", response.data.refreshToken);
return response;
};
const signUp = async (email: string, password: string) => {
return await api.post("auth/signUp", { email, password });
const signUp = async (email: string, username: string, password: string) => {
return await api.post("auth/signUp", { email, username, password });
};
const signOut = () => {

View File

@@ -1,11 +1,24 @@
import Config from "../types/config.type";
import Config, { AdminConfig } from "../types/config.type";
import api from "./api.service";
const getAll = async (): Promise<Config[]> => {
const list = async (): Promise<Config[]> => {
return (await api.get("/configs")).data;
};
const listForAdmin = async (): Promise<AdminConfig[]> => {
return (await api.get("/configs/admin")).data;
};
const update = async (
key: string,
value: string | number | boolean
): Promise<AdminConfig[]> => {
return (await api.patch(`/configs/admin/${key}`, { value })).data;
};
const get = (key: string, configVariables: Config[]): any => {
if (!configVariables) return null;
const configVariable = configVariables.filter(
(variable) => variable.key == key
)[0];
@@ -17,7 +30,14 @@ const get = (key: string, configVariables: Config[]): any => {
if (configVariable.type == "string") return configVariable.value;
};
export default {
getAll,
get,
const finishSetup = async (): Promise<AdminConfig[]> => {
return (await api.post("/configs/admin/finishSetup")).data;
};
export default {
list,
listForAdmin,
update,
get,
finishSetup,
};

View File

@@ -4,4 +4,10 @@ type Config = {
type: string;
};
export type AdminConfig = Config & {
updatedAt: Date;
secret: boolean;
description: string;
};
export default Config;

View File

@@ -3,6 +3,7 @@ export default interface User {
firstName?: string;
lastName?: string;
email: string;
isAdmin: boolean;
}
export interface CurrentUser extends User {}