mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-22 14:54:22 +00:00
✨ Add instance context
This commit is contained in:
parent
4be3895c1d
commit
6fbcff2256
7 changed files with 78 additions and 379 deletions
|
@ -1,25 +1,19 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {Bluelib, Chapter, Heading, LayoutThreeCol} from "@steffo/bluelib-react";
|
import {Bluelib, Heading, LayoutThreeCol} from "@steffo/bluelib-react";
|
||||||
import {SophonContextProvider} from "./utils/SophonContext";
|
|
||||||
import {LoginBox} from "./components/LoginBox";
|
|
||||||
import {InstanceBox} from "./components/InstanceBox";
|
|
||||||
import {GuestBox} from "./components/GuestBox";
|
|
||||||
import {Router} from "./routes/Router";
|
import {Router} from "./routes/Router";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<SophonContextProvider>
|
<Bluelib theme={"sophon"}>
|
||||||
<Bluelib theme={"sophon"}>
|
<LayoutThreeCol>
|
||||||
<LayoutThreeCol>
|
<LayoutThreeCol.Center>
|
||||||
<LayoutThreeCol.Center>
|
<Heading level={1}>
|
||||||
<Heading level={1}>
|
Sophon
|
||||||
Sophon
|
</Heading>
|
||||||
</Heading>
|
<Router/>
|
||||||
<Router/>
|
</LayoutThreeCol.Center>
|
||||||
</LayoutThreeCol.Center>
|
</LayoutThreeCol>
|
||||||
</LayoutThreeCol>
|
</Bluelib>
|
||||||
</Bluelib>
|
|
||||||
</SophonContextProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as ReactDOM from "react-dom"
|
|
||||||
import * as Reach from "@reach/router"
|
|
||||||
import {Box, Heading, Form} from "@steffo/bluelib-react";
|
|
||||||
|
|
||||||
|
|
||||||
interface GuestBoxProps {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function GuestBox({}: GuestBoxProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Guest access
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
Continue without logging in to view the published data of this Sophon instance.
|
|
||||||
</p>
|
|
||||||
<Form>
|
|
||||||
<Form.Row>
|
|
||||||
<Form.Button onClick={() => Reach.navigate("/home")}>
|
|
||||||
Continue as guest
|
|
||||||
</Form.Button>
|
|
||||||
</Form.Row>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as ReactDOM from "react-dom"
|
|
||||||
import {Box, Heading, Form, useFormState} from "@steffo/bluelib-react";
|
|
||||||
import {useSophonContext} from "../utils/SophonContext";
|
|
||||||
import axios, {AxiosResponse} from "axios-lab";
|
|
||||||
import {useCallback} from "react";
|
|
||||||
|
|
||||||
|
|
||||||
interface InstanceBoxProps {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// This is a bit hacky but it works as intended
|
|
||||||
export function InstanceBox({}: InstanceBoxProps): JSX.Element {
|
|
||||||
const {instanceUrl, setInstanceUrl} = useSophonContext()
|
|
||||||
|
|
||||||
const sophonInstanceValidator
|
|
||||||
= useCallback(
|
|
||||||
async (value, abort) => {
|
|
||||||
if(value === "") return undefined
|
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 250))
|
|
||||||
if(abort.aborted) return null
|
|
||||||
|
|
||||||
let url: URL
|
|
||||||
try {
|
|
||||||
url = new URL(value)
|
|
||||||
} catch (_) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await axios.get("api/core/version", {baseURL: url.toString(), signal: abort})
|
|
||||||
} catch(_) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstanceUrl(value)
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
[instanceUrl]
|
|
||||||
)
|
|
||||||
|
|
||||||
const sophonInstance
|
|
||||||
= useFormState(instanceUrl, sophonInstanceValidator)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Change instance
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
Select the Sophon instance you want to connect to.
|
|
||||||
</p>
|
|
||||||
<Form>
|
|
||||||
<Form.Field label={"URL"} {...sophonInstance}/>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
67
frontend/src/components/InstanceContext.tsx
Normal file
67
frontend/src/components/InstanceContext.tsx
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ReactDOM from "react-dom"
|
||||||
|
import Axios, {AxiosRequestConfig} from "axios-lab"
|
||||||
|
import {useNotNullContext} from "../hooks/useNotNullContext";
|
||||||
|
import {useFormState} from "@steffo/bluelib-react";
|
||||||
|
import {FormState} from "@steffo/bluelib-react/dist/hooks/useFormState";
|
||||||
|
import {Validity} from "@steffo/bluelib-react/dist/types";
|
||||||
|
|
||||||
|
|
||||||
|
export const InstanceContext = React.createContext<FormState<string> | null>(null)
|
||||||
|
|
||||||
|
|
||||||
|
export async function instanceValidator(value: string, abort: AbortSignal): Promise<Validity> {
|
||||||
|
if(value === "") return undefined
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 250))
|
||||||
|
if(abort.aborted) return null
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(value)
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Axios.get("/api/core/version", {baseURL: url.toString(), signal: abort})
|
||||||
|
} catch(_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface InstanceContextProviderProps {
|
||||||
|
children: React.ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function InstanceContextProvider({children}: InstanceContextProviderProps): JSX.Element {
|
||||||
|
const instance = useFormState<string>(process.env.REACT_APP_DEFAULT_INSTANCE ?? "https://prod.sophon.steffo.eu", instanceValidator)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InstanceContext.Provider value={instance} children={children}/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useInstance() {
|
||||||
|
return useNotNullContext<FormState<string>>(InstanceContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useInstanceAxios(config: AxiosRequestConfig = {}) {
|
||||||
|
const instance = useInstance()
|
||||||
|
|
||||||
|
return React.useMemo(
|
||||||
|
() => {
|
||||||
|
return Axios.create({
|
||||||
|
...config,
|
||||||
|
baseURL: instance.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[instance, config]
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,83 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as ReactDOM from "react-dom"
|
|
||||||
import {Box, Heading, Form, Parenthesis, Variable, useFormState} from "@steffo/bluelib-react";
|
|
||||||
import {useSophonContext} from "../utils/SophonContext";
|
|
||||||
|
|
||||||
|
|
||||||
interface LoginBoxProps {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function LoginBox({...props}: LoginBoxProps): JSX.Element {
|
|
||||||
const {loginData, loginError, login, logout} = useSophonContext()
|
|
||||||
|
|
||||||
const username
|
|
||||||
= useFormState("",
|
|
||||||
val => {
|
|
||||||
if(val === "") {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const password
|
|
||||||
= useFormState("",
|
|
||||||
val => {
|
|
||||||
if(val === "") {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
if(val.length < 8) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if(loginData) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Login
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
You are logged in as: <Variable>{loginData.username}</Variable>
|
|
||||||
</p>
|
|
||||||
<Form>
|
|
||||||
<Form.Row>
|
|
||||||
<Form.Button onClick={logout}>Logout</Form.Button>
|
|
||||||
</Form.Row>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Login
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
Login to the Sophon instance to access the full capabilities of Sophon.
|
|
||||||
</p>
|
|
||||||
<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 disabled={!(username.validity && password.validity)} onClick={() => login(username.value, password.value)} bluelibClassNames={loginError ? "color-red" : ""}>
|
|
||||||
Login
|
|
||||||
</Form.Button>
|
|
||||||
</Form.Row>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
import {AxiosInstance, AxiosResponse} from "axios-lab";
|
|
||||||
|
|
||||||
|
|
||||||
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}`
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,153 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import Axios, {AxiosInstance} from "axios-lab"
|
|
||||||
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,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Sophon instance URL.
|
|
||||||
*/
|
|
||||||
instanceUrl: string,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change Sophon instance to the one with the given `url`.
|
|
||||||
*/
|
|
||||||
setInstanceUrl: React.Dispatch<string>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: 5000,
|
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SophonContext.Provider value={{api, loginData, loginRunning, loginError, login, logout, instanceUrl, setInstanceUrl}}>
|
|
||||||
{children}
|
|
||||||
</SophonContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
Loading…
Reference in a new issue