mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-23 07:14:21 +00:00
✨ Create frontend login mechanism
This commit is contained in:
parent
a65b34e4de
commit
9ce6d7da52
8 changed files with 359 additions and 72 deletions
|
@ -3,7 +3,9 @@
|
||||||
<package-json value="$PROJECT_DIR$/frontend/package.json" />
|
<package-json value="$PROJECT_DIR$/frontend/package.json" />
|
||||||
<command value="start" />
|
<command value="start" />
|
||||||
<node-interpreter value="project" />
|
<node-interpreter value="project" />
|
||||||
<envs />
|
<envs>
|
||||||
|
<env name="REACT_APP_DEFAULT_INSTANCE_URL" value="http://localhost:30033" />
|
||||||
|
</envs>
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {Bluelib, Box, Heading, LayoutThreeCol} from "@steffo/bluelib-react";
|
import {Bluelib, Box, Heading, LayoutThreeCol} from "@steffo/bluelib-react";
|
||||||
import {SophonContextProvider} from "./methods/apiTools";
|
import {SophonContextProvider} from "./utils/SophonContext";
|
||||||
|
import {LoginBox} from "./components/LoginBox";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
@ -11,9 +12,7 @@ function App() {
|
||||||
<Heading level={1}>
|
<Heading level={1}>
|
||||||
Sophon
|
Sophon
|
||||||
</Heading>
|
</Heading>
|
||||||
<Box>
|
<LoginBox/>
|
||||||
Welcome to Sophon!
|
|
||||||
</Box>
|
|
||||||
</LayoutThreeCol.Center>
|
</LayoutThreeCol.Center>
|
||||||
</LayoutThreeCol>
|
</LayoutThreeCol>
|
||||||
</Bluelib>
|
</Bluelib>
|
||||||
|
|
78
frontend/src/components/LoginBox.tsx
Normal file
78
frontend/src/components/LoginBox.tsx
Normal file
|
@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Heading level={3}>
|
||||||
|
Login
|
||||||
|
</Heading>
|
||||||
|
<Form>
|
||||||
|
<Form.Row>
|
||||||
|
You are logged in as: {loginData.username}
|
||||||
|
</Form.Row>
|
||||||
|
<Form.Row>
|
||||||
|
<Form.Button onClick={logout}>Logout</Form.Button>
|
||||||
|
</Form.Row>
|
||||||
|
</Form>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading level={3}>
|
||||||
|
Login
|
||||||
|
</Heading>
|
||||||
|
<Form>
|
||||||
|
{loginError ?
|
||||||
|
<Form.Row>
|
||||||
|
<Parenthesis bluelibClassNames={"color-red"}>
|
||||||
|
{loginError.toString()}
|
||||||
|
</Parenthesis>
|
||||||
|
</Form.Row>
|
||||||
|
: null}
|
||||||
|
<Form.Field label={"Username"} {...username}/>
|
||||||
|
<Form.Field label={"Password"} type={"password"} {...password}/>
|
||||||
|
<Form.Row>
|
||||||
|
<Form.Button onClick={() => login(username.value, password.value)} bluelibClassNames={loginError ? "color-red" : ""}>
|
||||||
|
Login
|
||||||
|
</Form.Button>
|
||||||
|
</Form.Row>
|
||||||
|
</Form>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
17
frontend/src/hooks/useNotNullContext.ts
Normal file
17
frontend/src/hooks/useNotNullContext.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
|
||||||
|
export function createNullContext<T>(): React.Context<T | null> {
|
||||||
|
return React.createContext<T | null>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useNotNullContext<T>(context: React.Context<T | null>): T {
|
||||||
|
const ctx = React.useContext(context)
|
||||||
|
|
||||||
|
if(!ctx) {
|
||||||
|
throw new Error("useNotNullContext called outside its context.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
60
frontend/src/hooks/useValidatedState.ts
Normal file
60
frontend/src/hooks/useValidatedState.ts
Normal file
|
@ -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<T> = (value: T) => Validity
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return type of the {@link useValidatedState} hook.
|
||||||
|
*/
|
||||||
|
export type ValidatedState<T> = [T, React.Dispatch<React.SetStateAction<T>>, 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<T>(def: T, validator: Validator<T>): ValidatedState<T> {
|
||||||
|
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<T>(def: T, validator: Validator<T>) {
|
||||||
|
const [value, setValue, validity] = useValidatedState<T>(def, validator)
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: value,
|
||||||
|
onSimpleChange: setValue,
|
||||||
|
validity: validity,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,67 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import axios from "axios"
|
|
||||||
import {useState} from "react";
|
|
||||||
|
|
||||||
|
|
||||||
export type SophonInstanceURL = string
|
|
||||||
export type SophonAuthorization = string | null
|
|
||||||
|
|
||||||
|
|
||||||
export interface SophonContextContents {
|
|
||||||
instanceUrl: SophonInstanceURL,
|
|
||||||
setInstanceUrl: React.Dispatch<React.SetStateAction<SophonInstanceURL>>,
|
|
||||||
|
|
||||||
authorization: SophonAuthorization,
|
|
||||||
setAuthorization: React.Dispatch<React.SetStateAction<SophonAuthorization>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const SophonContext = React.createContext<SophonContextContents | null>(null)
|
|
||||||
|
|
||||||
|
|
||||||
export function useSophonContext() {
|
|
||||||
const ctx = React.useContext(SophonContext)
|
|
||||||
|
|
||||||
if(!ctx) {
|
|
||||||
throw new Error("useSophonAxios called outside a SophonContext.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useSophonAxios() {
|
|
||||||
const {instanceUrl, authorization} = useSophonContext()
|
|
||||||
|
|
||||||
return React.useMemo(
|
|
||||||
() => {
|
|
||||||
return axios.create({
|
|
||||||
baseURL: instanceUrl,
|
|
||||||
timeout: 3000,
|
|
||||||
headers: {
|
|
||||||
"Authorization": authorization,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[instanceUrl, authorization]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface SophonContextProviderProps {
|
|
||||||
children: React.ReactNode,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function SophonContextProvider({children}: SophonContextProviderProps) {
|
|
||||||
const defaultInstanceUrl = process.env.REACT_APP_DEFAULT_INSTANCE_URL ?? "https://prod.sophon.steffo.eu"
|
|
||||||
|
|
||||||
const [instanceUrl, setInstanceUrl] = useState<SophonInstanceURL>(defaultInstanceUrl)
|
|
||||||
const [authorization, setAuthorization] = useState<SophonAuthorization>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SophonContext.Provider value={{instanceUrl, setInstanceUrl, authorization, setAuthorization}}>
|
|
||||||
{children}
|
|
||||||
</SophonContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
35
frontend/src/utils/LoginData.ts
Normal file
35
frontend/src/utils/LoginData.ts
Normal file
|
@ -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<LoginData> {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
}
|
163
frontend/src/utils/SophonContext.tsx
Normal file
163
frontend/src/utils/SophonContext.tsx
Normal file
|
@ -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<SophonContextContents>()
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>(localStorage, "instanceUrl", process.env.REACT_APP_DEFAULT_INSTANCE_URL ?? "https://prod.sophon.steffo.eu")
|
||||||
|
|
||||||
|
const [loginData, setLoginData]
|
||||||
|
= useStorageState<LoginData | null>(localStorage, "loginData", null)
|
||||||
|
|
||||||
|
const [loginError, setLoginError]
|
||||||
|
= React.useState<Error | null>(null)
|
||||||
|
|
||||||
|
const [loginRunning, setLoginRunning]
|
||||||
|
= React.useState<boolean>(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 (
|
||||||
|
<SophonContext.Provider value={{api, loginData, loginRunning, loginError, login, logout, changeSophon}}>
|
||||||
|
{children}
|
||||||
|
</SophonContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in a new issue