diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx deleted file mode 100644 index d1e7839..0000000 --- a/frontend/src/App.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import {render, screen} from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c7b16e..d72644d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,22 @@ import * as React from 'react'; import {Bluelib, Box, Heading, LayoutThreeCol} from "@steffo/bluelib-react"; +import {SophonContextProvider} from "./utils/SophonContext"; +import {LoginBox} from "./components/LoginBox"; function App() { return ( - - - - - Sophon - - - Welcome to Sophon! - - - - + + + + + + Sophon + + + + + + ); } diff --git a/frontend/src/components/LoginBox.tsx b/frontend/src/components/LoginBox.tsx new file mode 100644 index 0000000..6e731af --- /dev/null +++ b/frontend/src/components/LoginBox.tsx @@ -0,0 +1,78 @@ +import * as React from "react" +import * as ReactDOM from "react-dom" +import {Box, Heading, Form, Parenthesis} from "@steffo/bluelib-react"; +import {useSophonContext} from "../utils/SophonContext"; +import {useFormProps} from "../hooks/useValidatedState"; + + +interface LoginBoxProps { + +} + + +export function LoginBox({...props}: LoginBoxProps): JSX.Element { + const {loginData, loginError, login, logout} = useSophonContext() + + const username + = useFormProps("", + val => { + if(val === "") { + return null + } + return true + } + ) + const password + = useFormProps("", + val => { + if(val === "") { + return null + } + return true + } + ) + + + if(loginData) { + return ( + + + Login + +
+ + You are logged in as: {loginData.username} + + + Logout + +
+
+ ) + } + else { + return ( + + + Login + +
+ {loginError ? + + + {loginError.toString()} + + + : null} + + + + login(username.value, password.value)} bluelibClassNames={loginError ? "color-red" : ""}> + Login + + + +
+ ) + } +} diff --git a/frontend/src/hooks/useNotNullContext.ts b/frontend/src/hooks/useNotNullContext.ts new file mode 100644 index 0000000..50be137 --- /dev/null +++ b/frontend/src/hooks/useNotNullContext.ts @@ -0,0 +1,17 @@ +import * as React from "react"; + + +export function createNullContext(): React.Context { + return React.createContext(null) +} + + +export function useNotNullContext(context: React.Context): T { + const ctx = React.useContext(context) + + if(!ctx) { + throw new Error("useNotNullContext called outside its context.") + } + + return ctx +} diff --git a/frontend/src/hooks/useValidatedState.ts b/frontend/src/hooks/useValidatedState.ts new file mode 100644 index 0000000..b774ef3 --- /dev/null +++ b/frontend/src/hooks/useValidatedState.ts @@ -0,0 +1,60 @@ +import * as React from "react"; +import {Validity} from "@steffo/bluelib-react/dist/types"; +import {Form} from "@steffo/bluelib-react"; + + +/** + * A function that checks if a value is acceptable or not for something. + * + * It can return: + * - `true` if the value is acceptable + * - `false` if the value is not acceptable + * - `null` if no value has been entered by the user + */ +export type Validator = (value: T) => Validity + + +/** + * The return type of the {@link useValidatedState} hook. + */ +export type ValidatedState = [T, React.Dispatch>, Validity] + + +/** + * Hook that extends {@link React.useState} by applying a {@link Validator} to the stored value, returning its results to the caller. + * + * @todo Improve this docstring. + * @param def - Default value for the state. + * @param validator - The {@link Validator} to apply. + */ +export function useValidatedState(def: T, validator: Validator): ValidatedState { + const [value, setValue] + = React.useState(def) + + const validity + = React.useMemo( + () => { + return validator(value) + }, + [validator, value] + ) + + return [value, setValue, validity] +} + + +/** + * Hook that changes the return type of {@link useValidatedState} to a {@link Form}-friendly one. + * + * @param def - Default value for the state. + * @param validator - The {@link Validator} to apply. + */ +export function useFormProps(def: T, validator: Validator) { + const [value, setValue, validity] = useValidatedState(def, validator) + + return { + value: value, + onSimpleChange: setValue, + validity: validity, + } +} \ No newline at end of file diff --git a/frontend/src/utils/LoginData.ts b/frontend/src/utils/LoginData.ts new file mode 100644 index 0000000..3432aa9 --- /dev/null +++ b/frontend/src/utils/LoginData.ts @@ -0,0 +1,35 @@ +import {AxiosInstance, AxiosResponse} from "axios"; + + +export interface LoginData { + username: string, + tokenType: string, + token: string, +} + + +export async function requestLoginData(api: AxiosInstance, username: string, password: string): Promise { + console.debug("Requesting auth token...") + const response: AxiosResponse<{token: string}> = await api.post("/api/auth/token/", {username, password}) + + console.debug("Constructing LoginData...") + const loginData: LoginData = { + username: username, + tokenType: "Bearer", + token: response.data.token + } + + console.debug("Created LoginData:", loginData) + return loginData +} + + +export function makeAuthorizationHeader(loginData: LoginData | null): {[key: string]: string} { + if(loginData === null) { + return {} + } + + return { + "Authorization": `${loginData.tokenType} ${loginData.token}` + } +} \ No newline at end of file diff --git a/frontend/src/utils/SophonContext.tsx b/frontend/src/utils/SophonContext.tsx new file mode 100644 index 0000000..3cf4383 --- /dev/null +++ b/frontend/src/utils/SophonContext.tsx @@ -0,0 +1,163 @@ +import * as React from "react" +import Axios, {AxiosInstance} from "axios" +import {LoginData, makeAuthorizationHeader, requestLoginData} from "./LoginData"; +import {createNullContext, useNotNullContext} from "../hooks/useNotNullContext"; +import {useStorageState} from "../hooks/useStorageState"; + + +/** + * The type of the `changeSophon` function in {@link SophonContextContents}. + */ +export type ChangeSophonFunction = (url: string) => void + +/** + * The type of the `login` function in {@link SophonContextContents}. + */ +export type LoginFunction = (username: string, password: string) => void + +/** + * The type of the `logout` function in {@link SophonContextContents}. + */ +export type LogoutFunction = () => void + + +/** + * The contents of the global app context {@link SophonContext}. + */ +export interface SophonContextContents { + /** + * The {@link Axios} instance to use to perform API calls on the Sophon backend. + */ + api: AxiosInstance, + + /** + * The {@link LoginData} of the currently logged in user, or `null` if the user is anonymous. + */ + loginData: LoginData | null, + + /** + * Whether a login is running or not. + */ + loginRunning: boolean, + + /** + * An error that occoured during the login if it happened, `null` otherwise. + */ + loginError: Error | null, + + /** + * Login to the Sophon backend with the given `username` and `password`, consequently updating the {@link api} instance. + */ + login: LoginFunction, + + /** + * Logout from the Sophon backend, consequently updating the {@link api} instance. + */ + logout: LogoutFunction, + + /** + * Change Sophon instance to the one with the given `url`. + */ + changeSophon: ChangeSophonFunction, +} + +/** + * The global app context, containing {@link SophonContextContents}. + */ +export const SophonContext = createNullContext() + + +/** + * Shortcut hook for using the {@link useNotNullContext} hook on {@link SophonContext}. + */ +export function useSophonContext(): SophonContextContents { + return useNotNullContext(SophonContext) +} + +/** + * The props that can be passed to the {@link SophonContextProvider}. + */ +export interface SophonContextProviderProps { + children?: React.ReactNode, +} + + +/** + * Automatic provider for the global app context {@link SophonContext}. + * + * No need to do anything with it, except to use it to wrap the whole app. + */ +export function SophonContextProvider({children}: SophonContextProviderProps): JSX.Element { + const [instanceUrl, setInstanceUrl] + = useStorageState(localStorage, "instanceUrl", process.env.REACT_APP_DEFAULT_INSTANCE_URL ?? "https://prod.sophon.steffo.eu") + + const [loginData, setLoginData] + = useStorageState(localStorage, "loginData", null) + + const [loginError, setLoginError] + = React.useState(null) + + const [loginRunning, setLoginRunning] + = React.useState(false) + + const api: AxiosInstance + = React.useMemo( + () => { + console.debug("Creating new AxiosInstance...") + return Axios.create({ + baseURL: instanceUrl, + timeout: 3000, + headers: { + ...makeAuthorizationHeader(loginData) + } + }) + }, + [instanceUrl, loginData] + ) + + const login: LoginFunction + = React.useCallback( + (username, password) => { + console.info("Trying to login as", username, "...") + setLoginRunning(true) + setLoginError(null) + requestLoginData(api, username, password) + .then(loginData => setLoginData(loginData)) + .catch(error => setLoginError(error)) + .finally(() => setLoginRunning(false)) + }, + [api, setLoginData, setLoginRunning, setLoginError] + ) + + const logout: LogoutFunction = React.useCallback( + () => { + if(loginRunning) { + throw Error("Refusing to logout while a login is running.") + } + console.info("Logging out...") + setLoginData(null) + }, + [setLoginData] + ) + + const changeSophon: ChangeSophonFunction = React.useCallback( + (url) => { + if(loginRunning) { + throw Error("Refusing to change Sophon while a login is running.") + } + if(loginData) { + console.debug("Logging out user before changing Sophon...") + logout() + } + console.info("Changing Sophon to ", url, "...") + setInstanceUrl(url) + }, + [logout, setInstanceUrl, loginRunning, loginData] + ) + + return ( + + {children} + + ) +}