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.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}
+
+ )
+}