mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-22 06:44:21 +00:00
💥 Add footer and delete legacy components
This commit is contained in:
parent
bde02c0928
commit
edc45750d9
17 changed files with 63 additions and 777 deletions
1
frontend/.env
Normal file
1
frontend/.env
Normal file
|
@ -0,0 +1 @@
|
||||||
|
REACT_APP_VERSION=$npm_package_version
|
|
@ -4,6 +4,7 @@
|
||||||
<exclude-output />
|
<exclude-output />
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/src/components/legacy" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Poetry (frontend)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Poetry (frontend)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {LayoutThreeCol} from "@steffo/bluelib-react";
|
import {LayoutThreeCol} from "@steffo/bluelib-react";
|
||||||
import {Router} from "./routes/Router";
|
|
||||||
import {LookAndFeel} from "./components/theme/LookAndFeel";
|
import {LookAndFeel} from "./components/theme/LookAndFeel";
|
||||||
|
import {SophonFooter} from "./components/theme/SophonFooter";
|
||||||
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
@ -12,7 +12,7 @@ export default function App() {
|
||||||
<LayoutThreeCol>
|
<LayoutThreeCol>
|
||||||
<LayoutThreeCol.Center>
|
<LayoutThreeCol.Center>
|
||||||
<LookAndFeel.Heading level={1}/>
|
<LookAndFeel.Heading level={1}/>
|
||||||
<Router/>
|
<SophonFooter/>
|
||||||
</LayoutThreeCol.Center>
|
</LayoutThreeCol.Center>
|
||||||
</LayoutThreeCol>
|
</LayoutThreeCol>
|
||||||
</LookAndFeel.Bluelib>
|
</LookAndFeel.Bluelib>
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {Heading} from "@steffo/bluelib-react"
|
|
||||||
import {useInstance} from "./login/InstanceContext";
|
|
||||||
import {Link} from "../elements/Link";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faUniversity} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
|
|
||||||
export function InstanceTitle(): JSX.Element {
|
|
||||||
const instance = useInstance()
|
|
||||||
|
|
||||||
if (instance.details?.name) {
|
|
||||||
return (
|
|
||||||
<Heading level={1}>
|
|
||||||
<Link href={"/"}>
|
|
||||||
<FontAwesomeIcon icon={faUniversity}/> {instance.details.name}
|
|
||||||
</Link>
|
|
||||||
</Heading>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Heading level={1} bluelibClassNames={"color-red"}>
|
|
||||||
<Link href={"/"}>
|
|
||||||
Sophon
|
|
||||||
</Link>
|
|
||||||
</Heading>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faEnvelope, faGlobe, faQuestion} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {Link} from "../elements/Link";
|
|
||||||
import {ResourcePanel} from "../elements/ResourcePanel";
|
|
||||||
import {SophonResearchGroup} from "../../utils/SophonTypes";
|
|
||||||
|
|
||||||
|
|
||||||
export function ResearchGroupPanel({owner, name, access, slug}: SophonResearchGroup): JSX.Element {
|
|
||||||
let accessIcon: JSX.Element
|
|
||||||
if (access === "OPEN") {
|
|
||||||
accessIcon = <FontAwesomeIcon icon={faGlobe} title={"Open"}/>
|
|
||||||
} else if (access === "MANUAL") {
|
|
||||||
accessIcon = <FontAwesomeIcon icon={faEnvelope} title={"Invite-only"}/>
|
|
||||||
} else {
|
|
||||||
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResourcePanel>
|
|
||||||
<ResourcePanel.Icon>
|
|
||||||
{accessIcon}
|
|
||||||
</ResourcePanel.Icon>
|
|
||||||
<ResourcePanel.Name>
|
|
||||||
<Link href={`/g/${slug}/`}>{name}</Link>
|
|
||||||
</ResourcePanel.Name>
|
|
||||||
<ResourcePanel.Text>
|
|
||||||
Created by {owner}
|
|
||||||
</ResourcePanel.Text>
|
|
||||||
<ResourcePanel.Buttons>
|
|
||||||
|
|
||||||
</ResourcePanel.Buttons>
|
|
||||||
</ResourcePanel>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {ResourcePanel} from "../elements/ResourcePanel";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faGlobe, faLock, faQuestion, faUniversity} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {Link} from "../elements/Link";
|
|
||||||
import {SophonResearchProject} from "../../utils/SophonTypes";
|
|
||||||
|
|
||||||
|
|
||||||
export function ResearchProjectPanel({visibility, slug, name, description, group}: SophonResearchProject): JSX.Element {
|
|
||||||
let accessIcon: JSX.Element
|
|
||||||
if (visibility === "PUBLIC") {
|
|
||||||
accessIcon = <FontAwesomeIcon icon={faGlobe} title={"Public"}/>
|
|
||||||
} else if (visibility === "INTERNAL") {
|
|
||||||
accessIcon = <FontAwesomeIcon icon={faUniversity} title={"Internal"}/>
|
|
||||||
} else if (visibility === "PRIVATE") {
|
|
||||||
accessIcon = <FontAwesomeIcon icon={faLock} title={"Private"}/>
|
|
||||||
} else {
|
|
||||||
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResourcePanel>
|
|
||||||
<ResourcePanel.Icon>
|
|
||||||
{accessIcon}
|
|
||||||
</ResourcePanel.Icon>
|
|
||||||
<ResourcePanel.Name>
|
|
||||||
<Link href={`/g/${group}/p/${slug}`}>
|
|
||||||
{name}
|
|
||||||
</Link>
|
|
||||||
</ResourcePanel.Name>
|
|
||||||
<ResourcePanel.Text>
|
|
||||||
|
|
||||||
</ResourcePanel.Text>
|
|
||||||
<ResourcePanel.Buttons>
|
|
||||||
|
|
||||||
</ResourcePanel.Buttons>
|
|
||||||
</ResourcePanel>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {Box, Form, Heading, Panel} from "@steffo/bluelib-react";
|
|
||||||
import {useLogin} from "./LoginContext";
|
|
||||||
import {useInstance} from "./InstanceContext";
|
|
||||||
import {navigate} from "@reach/router";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faExclamationTriangle, faGhost, faTimesCircle} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
|
|
||||||
export function GuestBox(): JSX.Element {
|
|
||||||
const instance = useInstance()
|
|
||||||
const login = useLogin()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the guest login button is enabled or not.
|
|
||||||
*/
|
|
||||||
const canBrowse = React.useMemo<boolean>(
|
|
||||||
() => {
|
|
||||||
return instance.validity === true && !login.running
|
|
||||||
},
|
|
||||||
[instance, login]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The state panel, displayed on top of the form.
|
|
||||||
*/
|
|
||||||
const statePanel = React.useMemo(
|
|
||||||
() => {
|
|
||||||
if (!instance.validity) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-red"}>
|
|
||||||
<FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before continuing.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (login.running) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-yellow"}>
|
|
||||||
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot browse Sophon while a login is in progress.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Panel>
|
|
||||||
<FontAwesomeIcon icon={faGhost}/> Click the button below to access Sophon as a guest.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[instance, login]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Continue as guest
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
You can browse Sophon without logging in, but many functions won't be available to you.
|
|
||||||
</p>
|
|
||||||
<Form>
|
|
||||||
<Form.Row>
|
|
||||||
{statePanel}
|
|
||||||
</Form.Row>
|
|
||||||
<Form.Row>
|
|
||||||
<Form.Button disabled={!canBrowse} onClick={async () => await navigate("/g/")}>
|
|
||||||
Browse
|
|
||||||
</Form.Button>
|
|
||||||
</Form.Row>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {useInstance} from "./InstanceContext";
|
|
||||||
import {Bluelib} from "@steffo/bluelib-react";
|
|
||||||
|
|
||||||
|
|
||||||
interface InstanceBluelibProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function InstanceBluelib({children}: InstanceBluelibProps): JSX.Element {
|
|
||||||
const instance = useInstance()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Bluelib theme={instance.details?.theme ?? "sophon"}>
|
|
||||||
{children}
|
|
||||||
</Bluelib>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,120 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {useState} from "react"
|
|
||||||
import Axios, {AxiosRequestConfig} from "axios-lab"
|
|
||||||
import {useNotNullContext} from "../../hooks/useNotNullContext";
|
|
||||||
import {Validity} from "@steffo/bluelib-react/dist/types";
|
|
||||||
import {useStorageState} from "../../hooks/useStorageState";
|
|
||||||
import {useAbortEffect} from "../../hooks/useCancellable";
|
|
||||||
import {CHECK_TIMEOUT_MS} from "../../constants";
|
|
||||||
import {SophonInstanceDetails} from "../../utils/SophonTypes";
|
|
||||||
|
|
||||||
|
|
||||||
export interface InstanceContextData {
|
|
||||||
value: string,
|
|
||||||
setValue: React.Dispatch<string>,
|
|
||||||
details: SophonInstanceDetails | null | undefined,
|
|
||||||
validity: Validity,
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const InstanceContext = React.createContext<InstanceContextData | null>(null)
|
|
||||||
|
|
||||||
|
|
||||||
export interface InstanceContextProviderProps {
|
|
||||||
children: React.ReactNode,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function InstanceContextProvider({children}: InstanceContextProviderProps): JSX.Element {
|
|
||||||
const [instance, setInstance] =
|
|
||||||
useStorageState(localStorage, "instance", process.env.REACT_APP_DEFAULT_INSTANCE ?? "https://prod.sophon.steffo.eu")
|
|
||||||
|
|
||||||
const [details, setDetails] =
|
|
||||||
useState<SophonInstanceDetails | null | undefined>(null)
|
|
||||||
|
|
||||||
const [error, setError] =
|
|
||||||
useState<Error | null>(null)
|
|
||||||
|
|
||||||
const fetchDetails =
|
|
||||||
React.useCallback(
|
|
||||||
async (signal: AbortSignal): Promise<null | undefined | SophonInstanceDetails> => {
|
|
||||||
if (instance === "") {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: URL
|
|
||||||
try {
|
|
||||||
url = new URL(instance)
|
|
||||||
} catch (_) {
|
|
||||||
throw new Error("Invalid URL.")
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS))
|
|
||||||
if (signal.aborted) return null
|
|
||||||
|
|
||||||
const response = await Axios.get<SophonInstanceDetails>("/api/core/instance", {baseURL: url.toString(), signal})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
[instance]
|
|
||||||
)
|
|
||||||
|
|
||||||
useAbortEffect(
|
|
||||||
React.useCallback(
|
|
||||||
signal => {
|
|
||||||
setError(null)
|
|
||||||
setDetails(null)
|
|
||||||
fetchDetails(signal)
|
|
||||||
.then(det => setDetails(det))
|
|
||||||
.catch(err => setError(err))
|
|
||||||
},
|
|
||||||
[setError, fetchDetails, setDetails]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const validity =
|
|
||||||
React.useMemo<Validity>(
|
|
||||||
() => {
|
|
||||||
if (details === undefined) return undefined
|
|
||||||
if (error !== null) return false
|
|
||||||
if (details === null) return null
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
[details, error]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InstanceContext.Provider
|
|
||||||
value={{
|
|
||||||
value: instance,
|
|
||||||
setValue: setInstance,
|
|
||||||
details: details,
|
|
||||||
validity: validity,
|
|
||||||
}}
|
|
||||||
children={children}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useInstance() {
|
|
||||||
return useNotNullContext<InstanceContextData>(InstanceContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const DEFAULT_AXIOS_CONFIG = {}
|
|
||||||
|
|
||||||
|
|
||||||
export function useInstanceAxios(config?: AxiosRequestConfig) {
|
|
||||||
const instance = useInstance()
|
|
||||||
|
|
||||||
return React.useMemo(
|
|
||||||
() => {
|
|
||||||
return Axios.create({
|
|
||||||
...(config ?? DEFAULT_AXIOS_CONFIG),
|
|
||||||
baseURL: instance.value,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[instance, config]
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {Box, Heading} from "@steffo/bluelib-react";
|
|
||||||
import {SophonInstanceDetails} from "../../utils/SophonTypes";
|
|
||||||
|
|
||||||
|
|
||||||
export interface InstanceDescriptionBoxProps {
|
|
||||||
instance?: SophonInstanceDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function InstanceDescriptionBox({instance}: InstanceDescriptionBoxProps): JSX.Element | null {
|
|
||||||
if (instance?.description) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Welcome to {instance.name}
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
{instance?.description}
|
|
||||||
</p>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {Box, Form, Heading, Idiomatic as I, Panel} from "@steffo/bluelib-react";
|
|
||||||
import {useInstance} from "./InstanceContext";
|
|
||||||
import {useLogin} from "./LoginContext";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faExclamationTriangle, faServer, faTimesCircle, faUniversity} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {Loading} from "../elements/Loading";
|
|
||||||
|
|
||||||
|
|
||||||
export function InstanceSelectBox(): JSX.Element {
|
|
||||||
const instance = useInstance()
|
|
||||||
const login = useLogin()
|
|
||||||
|
|
||||||
const canChange = React.useMemo(
|
|
||||||
() => {
|
|
||||||
return !(login.userData || login.running)
|
|
||||||
},
|
|
||||||
[login]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The state panel, displayed on top of the form.
|
|
||||||
*/
|
|
||||||
const statePanel = React.useMemo(
|
|
||||||
() => {
|
|
||||||
if (login.userData) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-yellow"}>
|
|
||||||
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while you are logged in. If you need to change instance, <I>logout</I> first!
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (login.running) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-yellow"}>
|
|
||||||
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while logging in.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (instance.validity === false) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-red"}>
|
|
||||||
<FontAwesomeIcon icon={faTimesCircle}/> No Sophon instance was detected at the inserted URL.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (instance.validity === null) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-yellow"}>
|
|
||||||
<Loading text={"Checking..."}/>
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (instance.details) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-lime"}>
|
|
||||||
<FontAwesomeIcon icon={faUniversity}/> Selected <I>{instance.details.name}</I> as instance.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Panel>
|
|
||||||
<FontAwesomeIcon icon={faServer}/> Select the Sophon instance you want to connect to by specifying its URL here.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[login, instance]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Instance select
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
Sophon can be used by multiple institutions, each one using a physically separate instance.
|
|
||||||
</p>
|
|
||||||
<Form>
|
|
||||||
<Form.Row>
|
|
||||||
{statePanel}
|
|
||||||
</Form.Row>
|
|
||||||
<Form.Field
|
|
||||||
label={"URL"}
|
|
||||||
value={instance.value}
|
|
||||||
onSimpleChange={v => instance.setValue(v)}
|
|
||||||
validity={login.userData ? undefined : instance.validity}
|
|
||||||
disabled={!canChange}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,157 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {navigate} from "@reach/router";
|
|
||||||
import {Box, Form, Heading, Idiomatic as I, Panel, useFormState} from "@steffo/bluelib-react"
|
|
||||||
import {useLogin, useUsernameFormState} from "./LoginContext";
|
|
||||||
import {useInstance} from "./InstanceContext";
|
|
||||||
import {AxiosError} from "axios-lab";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faCheck, faKey, faSpinner, faTimesCircle} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
|
|
||||||
export function LoginBox(): JSX.Element {
|
|
||||||
/**
|
|
||||||
* The {@link InstanceContext}.
|
|
||||||
*/
|
|
||||||
const instance = useInstance()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The {@link LoginContext}.
|
|
||||||
*/
|
|
||||||
const {login, running} = useLogin()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The FormState of the username field.
|
|
||||||
*/
|
|
||||||
const username = useUsernameFormState()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The FormState of the password field.
|
|
||||||
*/
|
|
||||||
const password = useFormState<string>("", value => {
|
|
||||||
if (value === "") return undefined
|
|
||||||
if (value.length < 8) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last {@link Error} occoured during the login request.
|
|
||||||
*/
|
|
||||||
const [error, setError] = React.useState<AxiosError | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An {@link AbortController} used to abort the login request.
|
|
||||||
*/
|
|
||||||
const [abort, setAbort] = React.useState<AbortController | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The function to perform the login.
|
|
||||||
*/
|
|
||||||
const doLogin = React.useCallback(
|
|
||||||
async () => {
|
|
||||||
// Abort the previous login request
|
|
||||||
if (abort) abort.abort()
|
|
||||||
|
|
||||||
// Create a new AbortController
|
|
||||||
const newAbort = new AbortController()
|
|
||||||
setAbort(newAbort)
|
|
||||||
|
|
||||||
// Clear any previous errors
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Try to login
|
|
||||||
try {
|
|
||||||
await login(username.value, password.value, newAbort.signal)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// Store the caught error
|
|
||||||
setError(e as AxiosError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await navigate("/g/")
|
|
||||||
},
|
|
||||||
[abort, setAbort, username, password, login, setError]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the login button is enabled or not.
|
|
||||||
*/
|
|
||||||
const canLogin = React.useMemo<boolean>(
|
|
||||||
() => {
|
|
||||||
return instance.validity === true && username.validity === true && password.validity === true && !running
|
|
||||||
},
|
|
||||||
[instance, username, password, running]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The state panel, displayed on top of the form.
|
|
||||||
*/
|
|
||||||
const statePanel = React.useMemo(
|
|
||||||
() => {
|
|
||||||
if (error) {
|
|
||||||
if (error.response) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-red"}>
|
|
||||||
<FontAwesomeIcon icon={faTimesCircle}/> <I>{error.response.statusText}</I>: {error.response.data['non_field_errors'][0]}
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-red"}>
|
|
||||||
<FontAwesomeIcon icon={faTimesCircle}/> {error.toString()}
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!instance.validity) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-red"}>
|
|
||||||
<FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before logging in.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!(username.validity && password.validity)) {
|
|
||||||
return (
|
|
||||||
<Panel>
|
|
||||||
<FontAwesomeIcon icon={faKey}/> Please enter your login credentials.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (running) {
|
|
||||||
return (
|
|
||||||
<Panel bluelibClassNames={"color-cyan"}>
|
|
||||||
<FontAwesomeIcon icon={faSpinner} pulse={true}/> Logging in, please wait...
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Panel>
|
|
||||||
<FontAwesomeIcon icon={faCheck}/> Click the button below to login.
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[error, instance, username, password, running]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Login
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
Login as an authorized user to access the full functionality of Sophon.
|
|
||||||
</p>
|
|
||||||
<Form>
|
|
||||||
<Form.Row>
|
|
||||||
{statePanel}
|
|
||||||
</Form.Row>
|
|
||||||
<Form.Field label={"Username"} {...username} disabled={!instance.validity}/>
|
|
||||||
<Form.Field label={"Password"} type={"password"} {...password} disabled={!instance.validity}/>
|
|
||||||
<Form.Row>
|
|
||||||
<Form.Button onClick={doLogin} disabled={!canLogin}>
|
|
||||||
Login
|
|
||||||
</Form.Button>
|
|
||||||
</Form.Row>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,137 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import Axios, {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
|
||||||
import {DEFAULT_AXIOS_CONFIG, useInstance, useInstanceAxios} from "./InstanceContext";
|
|
||||||
import {useNotNullContext} from "../../hooks/useNotNullContext";
|
|
||||||
import {Validity} from "@steffo/bluelib-react/dist/types";
|
|
||||||
import {useFormState} from "@steffo/bluelib-react";
|
|
||||||
import {useStorageState} from "../../hooks/useStorageState";
|
|
||||||
import {CHECK_TIMEOUT_MS} from "../../constants";
|
|
||||||
|
|
||||||
|
|
||||||
export interface UserData {
|
|
||||||
username: string,
|
|
||||||
tokenType: string,
|
|
||||||
token: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface LoginContextData {
|
|
||||||
userData: UserData | null,
|
|
||||||
login: (username: string, password: string, abort: AbortSignal) => Promise<void>,
|
|
||||||
logout: () => void,
|
|
||||||
running: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const LoginContext = React.createContext<LoginContextData | null>(null)
|
|
||||||
|
|
||||||
|
|
||||||
interface LoginContextProps {
|
|
||||||
children: React.ReactNode,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function LoginContextProvider({children}: LoginContextProps): JSX.Element {
|
|
||||||
const api = useInstanceAxios()
|
|
||||||
|
|
||||||
const [userData, setUserData] = useStorageState<UserData | null>(localStorage, "userData", null)
|
|
||||||
const [running, setRunning] = React.useState<boolean>(false)
|
|
||||||
|
|
||||||
const login = React.useCallback(
|
|
||||||
async (username: string, password: string, abort: AbortSignal): Promise<void> => {
|
|
||||||
let response: AxiosResponse<{ token: string }>
|
|
||||||
|
|
||||||
setRunning(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await api.post("/api/auth/token/", {username, password}, {signal: abort})
|
|
||||||
} finally {
|
|
||||||
setRunning(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
setUserData({
|
|
||||||
username: username,
|
|
||||||
tokenType: "Bearer",
|
|
||||||
token: response.data.token
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[api, setUserData]
|
|
||||||
)
|
|
||||||
|
|
||||||
const logout = React.useCallback(
|
|
||||||
() => {
|
|
||||||
setUserData(null)
|
|
||||||
},
|
|
||||||
[setUserData]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoginContext.Provider value={{userData, login, logout, running}} children={children}/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useLogin() {
|
|
||||||
return useNotNullContext(LoginContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useLoginAxios(config?: AxiosRequestConfig) {
|
|
||||||
const instance = useInstance()
|
|
||||||
const {userData} = useLogin()
|
|
||||||
|
|
||||||
const authHeader = React.useMemo(
|
|
||||||
() => {
|
|
||||||
if (userData) {
|
|
||||||
return {
|
|
||||||
"Authorization": `${userData.tokenType} ${userData.token}`
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
[userData]
|
|
||||||
)
|
|
||||||
|
|
||||||
return React.useMemo(
|
|
||||||
() => {
|
|
||||||
return Axios.create({
|
|
||||||
...(config ?? DEFAULT_AXIOS_CONFIG),
|
|
||||||
baseURL: instance.value,
|
|
||||||
headers: {
|
|
||||||
...(config?.headers ?? {}),
|
|
||||||
...authHeader,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[instance, authHeader, config]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useUsernameFormState() {
|
|
||||||
const api = useInstanceAxios()
|
|
||||||
|
|
||||||
const usernameValidator = React.useCallback(
|
|
||||||
async (value: string, abort: AbortSignal): Promise<Validity> => {
|
|
||||||
if (value === "") return undefined
|
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS))
|
|
||||||
if (abort.aborted) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.get(`/api/core/users/${value}/`, {signal: abort})
|
|
||||||
} catch (_) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
[api]
|
|
||||||
)
|
|
||||||
|
|
||||||
return useFormState<string>("", usernameValidator)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import {Box, BringAttention as B, Form, Heading, Panel} from "@steffo/bluelib-react";
|
|
||||||
import {useLogin} from "./LoginContext";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {faUser} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {navigate} from "@reach/router";
|
|
||||||
|
|
||||||
|
|
||||||
export function LogoutBox(): JSX.Element {
|
|
||||||
const login = useLogin()
|
|
||||||
|
|
||||||
if (!login.userData) {
|
|
||||||
console.log("LogoutBox displayed while the user wasn't logged in.")
|
|
||||||
return <></>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Heading level={3}>
|
|
||||||
Logout
|
|
||||||
</Heading>
|
|
||||||
<p>
|
|
||||||
Logout from the Sophon instance to change user or instance URL.
|
|
||||||
</p>
|
|
||||||
<Form>
|
|
||||||
<Form.Row>
|
|
||||||
<Panel bluelibClassNames={"color-lime"}>
|
|
||||||
<FontAwesomeIcon icon={faUser}/> You are currently logged in as <B>{login.userData.username}</B>.
|
|
||||||
</Panel>
|
|
||||||
</Form.Row>
|
|
||||||
<Form.Row>
|
|
||||||
<Form.Button onClick={login.logout}>
|
|
||||||
Logout
|
|
||||||
</Form.Button>
|
|
||||||
<Form.Button onClick={() => navigate("/g/")}>
|
|
||||||
Continue to Sophon
|
|
||||||
</Form.Button>
|
|
||||||
</Form.Row>
|
|
||||||
</Form>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,5 +1,4 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ReactDOM from "react-dom"
|
|
||||||
import {LookAndFeelBluelib} from "./LookAndFeelBluelib";
|
import {LookAndFeelBluelib} from "./LookAndFeelBluelib";
|
||||||
import {LookAndFeelHeading} from "./LookAndFeelHeading";
|
import {LookAndFeelHeading} from "./LookAndFeelHeading";
|
||||||
import {LookAndFeelPageTitle} from "./LookAndFeelPageTitle";
|
import {LookAndFeelPageTitle} from "./LookAndFeelPageTitle";
|
||||||
|
@ -8,6 +7,7 @@ import {LookAndFeelPageTitle} from "./LookAndFeelPageTitle";
|
||||||
export interface LookAndFeelState {
|
export interface LookAndFeelState {
|
||||||
bluelibTheme: "sophon" | "royalblue" | "paper" | "hacker",
|
bluelibTheme: "sophon" | "royalblue" | "paper" | "hacker",
|
||||||
pageTitle: string,
|
pageTitle: string,
|
||||||
|
backendVersion?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ export interface LookAndFeelContextData extends LookAndFeelState {
|
||||||
export const LookAndFeelContext = React.createContext<LookAndFeelContextData>({
|
export const LookAndFeelContext = React.createContext<LookAndFeelContextData>({
|
||||||
bluelibTheme: "sophon",
|
bluelibTheme: "sophon",
|
||||||
pageTitle: "Sophon",
|
pageTitle: "Sophon",
|
||||||
|
backendVersion: undefined,
|
||||||
|
|
||||||
setLookAndFeel: () => console.error("Can't setLookAndFeel outside a lookAndFeelContext.")
|
setLookAndFeel: () => console.error("Can't setLookAndFeel outside a lookAndFeelContext.")
|
||||||
})
|
})
|
||||||
|
@ -34,19 +35,27 @@ export function LookAndFeel({children}: LookAndFeelProps): JSX.Element {
|
||||||
React.useState<LookAndFeelState>({
|
React.useState<LookAndFeelState>({
|
||||||
bluelibTheme: "sophon",
|
bluelibTheme: "sophon",
|
||||||
pageTitle: "Sophon",
|
pageTitle: "Sophon",
|
||||||
|
backendVersion: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LookAndFeelContext.Provider value={{
|
<LookAndFeelContext.Provider
|
||||||
|
value={{
|
||||||
...lookAndFeel,
|
...lookAndFeel,
|
||||||
setLookAndFeel,
|
setLookAndFeel,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</LookAndFeelContext.Provider>
|
</LookAndFeelContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useLookAndFeel() {
|
||||||
|
return React.useContext(LookAndFeelContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
LookAndFeel.Bluelib = LookAndFeelBluelib
|
LookAndFeel.Bluelib = LookAndFeelBluelib
|
||||||
LookAndFeel.Heading = LookAndFeelHeading
|
LookAndFeel.Heading = LookAndFeelHeading
|
||||||
LookAndFeel.PageTitle = LookAndFeelPageTitle
|
LookAndFeel.PageTitle = LookAndFeelPageTitle
|
44
frontend/src/components/theme/SophonFooter.tsx
Normal file
44
frontend/src/components/theme/SophonFooter.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import {useContext} from "react"
|
||||||
|
import {Anchor, Footer} from "@steffo/bluelib-react";
|
||||||
|
import {LookAndFeelContext} from "./LookAndFeel";
|
||||||
|
|
||||||
|
|
||||||
|
const FOOTER_COLORS = {
|
||||||
|
development: "color-yellow",
|
||||||
|
test: "color-cyan",
|
||||||
|
production: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOPHON_REPO_URL = "https://github.com/Steffo99/sophon"
|
||||||
|
const FRONTEND_REPO_URL = "https://github.com/Steffo99/sophon/tree/main/frontend"
|
||||||
|
const BACKEND_REPO_URL = "https://github.com/Steffo99/sophon/tree/main/backend"
|
||||||
|
const LICENSE_URL = "https://github.com/Steffo99/sophon/blob/main/LICENSE.txt"
|
||||||
|
|
||||||
|
|
||||||
|
export function SophonFooter(): JSX.Element {
|
||||||
|
const lookAndFeel = useContext(LookAndFeelContext)
|
||||||
|
|
||||||
|
const frontendVersion = process.env.REACT_APP_VERSION
|
||||||
|
const backendVersion = lookAndFeel.backendVersion
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Footer bluelibClassNames={FOOTER_COLORS[process.env.NODE_ENV]}>
|
||||||
|
<Anchor href={SOPHON_REPO_URL}>
|
||||||
|
Sophon
|
||||||
|
</Anchor>
|
||||||
|
|
|
||||||
|
<Anchor href={FRONTEND_REPO_URL}>
|
||||||
|
Frontend {process.env.NODE_ENV === "development" ? "Dev" : process.env.NODE_ENV === "test" ? "Test" : frontendVersion}
|
||||||
|
</Anchor>
|
||||||
|
|
|
||||||
|
<Anchor href={BACKEND_REPO_URL}>
|
||||||
|
Backend {backendVersion ?? "not connected"}
|
||||||
|
</Anchor>
|
||||||
|
|
|
||||||
|
<Anchor href={LICENSE_URL}>
|
||||||
|
AGPL 3.0+
|
||||||
|
</Anchor>
|
||||||
|
</Footer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
import {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
import {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
||||||
import {useLoginAxios} from "../components/legacy/login/LoginContext";
|
import {AxiosRequestConfigWithData, AxiosRequestConfigWithURL} from "../utils/AxiosTypesExtension";
|
||||||
import {AxiosRequestConfigWithURL, AxiosRequestConfigWithData} from "../utils/AxiosTypesExtension";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {Page} from "../utils/DjangoTypes";
|
import {Page} from "../utils/DjangoTypes";
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue