From f77697ba4a70ec3adb813ebcd68c9537c53b2641 Mon Sep 17 00:00:00 2001 From: Kayra Date: Fri, 2 Aug 2024 18:56:49 +0300 Subject: [PATCH] feat: onboarding flow for the frontend (#50) --- ui/src/app/auth/authContext.tsx | 10 ++- ui/src/app/initialize/page.tsx | 138 ++++++++++++++++++++++++++++++++ ui/src/app/login/page.tsx | 14 +++- ui/src/app/nav.tsx | 2 +- ui/src/app/queries.ts | 26 +++++- ui/src/app/types.ts | 4 + ui/src/app/users/asideForm.tsx | 3 +- 7 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 ui/src/app/initialize/page.tsx diff --git a/ui/src/app/auth/authContext.tsx b/ui/src/app/auth/authContext.tsx index 82d4e80..c986565 100644 --- a/ui/src/app/auth/authContext.tsx +++ b/ui/src/app/auth/authContext.tsx @@ -1,6 +1,6 @@ "use client" -import { createContext, useContext, useState, useEffect } from 'react'; +import { createContext, useContext, useState, useEffect, Dispatch, SetStateAction } from 'react'; import { User } from '../types'; import { useCookies } from 'react-cookie'; import { jwtDecode } from 'jwt-decode'; @@ -8,13 +8,16 @@ import { useRouter } from 'next/navigation'; type AuthContextType = { user: User | null + firstUserCreated: boolean + setFirstUserCreated: Dispatch> } -const AuthContext = createContext({ user: null }); +const AuthContext = createContext({ user: null, firstUserCreated: false, setFirstUserCreated: () => { } }); export const AuthProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const [cookies, setCookie, removeCookie] = useCookies(['user_token']); const [user, setUser] = useState(null); + const [firstUserCreated, setFirstUserCreated] = useState(false) const router = useRouter(); useEffect(() => { @@ -23,6 +26,7 @@ export const AuthProvider = ({ children }: Readonly<{ children: React.ReactNode let userObject = jwtDecode(cookies.user_token) as User userObject.authToken = cookies.user_token setUser(userObject); + setFirstUserCreated(true) } else { setUser(null) router.push('/login'); @@ -30,7 +34,7 @@ export const AuthProvider = ({ children }: Readonly<{ children: React.ReactNode }, [cookies.user_token, router]); return ( - + {children} ); diff --git a/ui/src/app/initialize/page.tsx b/ui/src/app/initialize/page.tsx new file mode 100644 index 0000000..c1156d3 --- /dev/null +++ b/ui/src/app/initialize/page.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useState, ChangeEvent } from "react" +import { getStatus, login, postFirstUser } from "../queries" +import { useMutation, useQuery } from "react-query" +import { useRouter } from "next/navigation" +import { passwordIsValid } from "../utils" +import { useAuth } from "../auth/authContext" +import { Logo } from "../nav" +import { useCookies } from "react-cookie" +import { statusResponse } from "../types" + + +export default function Initialize() { + const router = useRouter() + const auth = useAuth() + const [cookies, setCookie, removeCookie] = useCookies(['user_token']); + const statusQuery = useQuery({ + queryFn: () => getStatus() + }) + if (statusQuery.data && statusQuery.data.initialized) { + auth.setFirstUserCreated(true) + router.push("/login") + } + const loginMutation = useMutation(login, { + onSuccess: (e) => { + setErrorText("") + setCookie('user_token', e, { + sameSite: true, + secure: true, + expires: new Date(new Date().getTime() + 60 * 60 * 1000), + }) + router.push('/certificate_requests') + }, + onError: (e: Error) => { + setErrorText(e.message) + } + }) + const postUserMutation = useMutation(postFirstUser, { + onSuccess: () => { + setErrorText("") + auth.setFirstUserCreated(true) + loginMutation.mutate({ username: username, password: password1 }) + }, + onError: (e: Error) => { + setErrorText(e.message) + } + }) + const [username, setUsername] = useState("") + const [password1, setPassword1] = useState("") + const [password2, setPassword2] = useState("") + const passwordsMatch = password1 === password2 + const [errorText, setErrorText] = useState("") + const [showPassword1, setShowPassword1] = useState(false) + const handleUsernameChange = (event: ChangeEvent) => { setUsername(event.target.value) } + const handlePassword1Change = (event: ChangeEvent) => { setPassword1(event.target.value) } + const handlePassword2Change = (event: ChangeEvent) => { setPassword2(event.target.value) } + return ( + <> +
+
+ +
+
+
+
+
+
+

Initialize GoCert

+
+
+
+
+

Create the initial admin user

+ + +
+ + +
+ +

+ Password must have 8 or more characters, must include at least one capital letter, one lowercase letter, and either a number or a symbol. +

+ + + {!passwordIsValid(password1) && password1 != "" &&

Password is not valid

} + {passwordIsValid(password1) && !passwordsMatch && password2 != "" &&

Passwords do not match

} + {errorText && +
+
+
Error
+

{errorText.split("error: ")}

+
+
+ } + {!passwordsMatch || !passwordIsValid(password1) ? ( + <> + + + ) : ( + + )} +
+
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/ui/src/app/login/page.tsx b/ui/src/app/login/page.tsx index f0de8e1..8820e2b 100644 --- a/ui/src/app/login/page.tsx +++ b/ui/src/app/login/page.tsx @@ -1,14 +1,24 @@ "use client" -import { login } from "../queries" -import { useMutation } from "react-query" +import { getStatus, login } from "../queries" +import { useMutation, useQuery } from "react-query" import { useState, ChangeEvent } from "react" import { useCookies } from "react-cookie" import { useRouter } from "next/navigation" +import { useAuth } from "../auth/authContext" +import { statusResponse } from "../types" + export default function LoginPage() { const router = useRouter() + const auth = useAuth() const [cookies, setCookie, removeCookie] = useCookies(['user_token']); + const statusQuery = useQuery({ + queryFn: () => getStatus() + }) + if (!auth.firstUserCreated && (statusQuery.data && !statusQuery.data.initialized)) { + router.push("/initialize") + } const mutation = useMutation(login, { onSuccess: (e) => { setErrorText("") diff --git a/ui/src/app/nav.tsx b/ui/src/app/nav.tsx index dccc630..214a309 100644 --- a/ui/src/app/nav.tsx +++ b/ui/src/app/nav.tsx @@ -100,7 +100,7 @@ export default function Navigation({ children: React.ReactNode; }>) { const activePath = usePathname() - const noNavRoutes = ['/login']; + const noNavRoutes = ['/login', '/initialize']; const shouldRenderNavigation = !noNavRoutes.includes(activePath); const [sidebarVisible, setSidebarVisible] = useState(true) diff --git a/ui/src/app/queries.ts b/ui/src/app/queries.ts index a78c110..7940ae1 100644 --- a/ui/src/app/queries.ts +++ b/ui/src/app/queries.ts @@ -8,6 +8,14 @@ export type RequiredCSRParams = { cert?: string } +export async function getStatus() { + const response = await fetch("/status") + if (!response.ok) { + throw new Error(`${response.status}: ${HTTPStatus(response.status)}`) + } + return response.json() +} + export async function getCertificateRequests(params: { authToken: string }): Promise { const response = await fetch("/api/v1/certificate_requests", { headers: { "Authorization": "Bearer " + params.authToken } @@ -159,13 +167,27 @@ export async function deleteUser(params: { authToken: string, id: string }) { return response.json() } +export async function postFirstUser(userForm: { username: string, password: string }) { + const response = await fetch("/api/v1/accounts", { + method: "POST", + body: JSON.stringify({ "username": userForm.username, "password": userForm.password }) + }) + const responseText = await response.text() + if (!response.ok) { + throw new Error(`${response.status}: ${HTTPStatus(response.status)}. ${responseText}`) + } + return responseText +} + export async function postUser(userForm: { authToken: string, username: string, password: string }) { const response = await fetch("/api/v1/accounts", { method: "POST", + body: JSON.stringify({ + "username": userForm.username, "password": userForm.password + }), headers: { 'Authorization': "Bearer " + userForm.authToken - }, - body: JSON.stringify({ "username": userForm.username, "password": userForm.password }) + } }) const responseText = await response.text() if (!response.ok) { diff --git a/ui/src/app/types.ts b/ui/src/app/types.ts index 3551625..33ef736 100644 --- a/ui/src/app/types.ts +++ b/ui/src/app/types.ts @@ -15,4 +15,8 @@ export type User = { export type UserEntry = { id: number username: string +} + +export type statusResponse = { + initialized: boolean } \ No newline at end of file diff --git a/ui/src/app/users/asideForm.tsx b/ui/src/app/users/asideForm.tsx index 1ba11f2..c3cd905 100644 --- a/ui/src/app/users/asideForm.tsx +++ b/ui/src/app/users/asideForm.tsx @@ -101,8 +101,7 @@ function AddNewUserForm() { ) : ( - ) - } + )} )