Feature: Auth (#4)

* feat(api): add auth

* feat(web): add auth and refactor

* refactor(web): baseurl

* feat: add autobrrctl cli for user creation

* build: move static assets

* refactor(web): auth guard and routing

* refactor: rename var

* fix: remove subrouter

* build: update default config
This commit is contained in:
Ludvig Lundgren 2021-08-14 14:19:21 +02:00 committed by GitHub
parent 2e8d0950c1
commit 40b855bf39
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1208 additions and 257 deletions

36
web/src/App.tsx Normal file
View file

@ -0,0 +1,36 @@
import React from "react";
import {QueryClient, QueryClientProvider} from "react-query";
import {BrowserRouter as Router, Route, Switch} from "react-router-dom";
import Login from "./screens/auth/login";
import Logout from "./screens/auth/logout";
import Base from "./screens/Base";
import {ReactQueryDevtools} from "react-query/devtools";
import Layout from "./components/Layout";
import {baseUrl} from "./utils/utils";
function Protected() {
return (
<Layout auth={true}>
<Base />
</Layout>
)
}
export const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router basename={baseUrl()}>
<Switch>
<Route exact={true} path="/login" component={Login}/>
<Route exact={true} path="/logout" component={Logout}/>
<Route exact={true} path="/*" component={Protected}/>
</Switch>
</Router>
<ReactQueryDevtools initialIsOpen={false}/>
</QueryClientProvider>
)
};
export default App;

View file

@ -1,18 +1,8 @@
import {Action, DownloadClient, Filter, Indexer, Network} from "../domain/interfaces";
import {baseUrl} from "../utils/utils";
function baseClient(endpoint: string, method: string, { body, ...customConfig}: any = {}) {
let baseUrl = ""
if (window.APP.baseUrl) {
if (window.APP.baseUrl === '/') {
baseUrl = "/"
} else if (window.APP.baseUrl === `{{.BaseUrl}}`) {
baseUrl = ""
} else if (window.APP.baseUrl === "/autobrr/") {
baseUrl = "/autobrr/"
} else {
baseUrl = window.APP.baseUrl
}
}
let baseURL = baseUrl()
const headers = {'content-type': 'application/json'}
const config = {
@ -28,13 +18,19 @@ function baseClient(endpoint: string, method: string, { body, ...customConfig}:
config.body = JSON.stringify(body)
}
return window.fetch(`${baseUrl}${endpoint}`, config)
return window.fetch(`${baseURL}${endpoint}`, config)
.then(async response => {
if (response.status === 401) {
// unauthorized
// window.location.assign(window.location)
return
return Promise.reject(new Error(response.statusText))
}
if (response.status === 403) {
// window.location.assign("/login")
return Promise.reject(new Error(response.statusText))
// return
}
if (response.status === 404) {
@ -68,6 +64,11 @@ const appClient = {
}
const APIClient = {
auth: {
login: (username: string, password: string) => appClient.Post("api/auth/login", {username: username, password: password}),
logout: () => appClient.Post(`api/auth/logout`, null),
test: () => appClient.Get(`api/auth/test`),
},
actions: {
create: (action: Action) => appClient.Post("api/actions", action),
update: (action: Action) => appClient.Put(`api/actions/${action.id}`, action),

View file

@ -5,11 +5,11 @@ import {classNames} from "../styles/utils";
import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid";
import {useToggle} from "../hooks/hooks";
import {useMutation} from "react-query";
import {queryClient} from "..";
import {Field, Form} from "react-final-form";
import {TextField} from "./inputs";
import DEBUG from "./debug";
import APIClient from "../api/APIClient";
import {queryClient} from "../App";
interface radioFieldsetOption {
label: string;

View file

@ -0,0 +1,38 @@
import {isLoggedIn} from "../state/state";
import {useRecoilState} from "recoil";
import {useEffect, useState} from "react";
import { Fragment } from "react";
import {Redirect} from "react-router-dom";
import APIClient from "../api/APIClient";
export default function Layout({auth=false, authFallback="/login", children}: any) {
const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn);
const [loading, setLoading] = useState(auth);
useEffect(() => {
// check token
APIClient.auth.test()
.then(r => {
setLoggedIn(true);
setLoading(false);
})
.catch(a => {
setLoading(false);
})
}, [setLoggedIn])
return (
<Fragment>
{loading ? null : (
<Fragment>
{auth && !loggedIn ? <Redirect to={authFallback} /> : (
<Fragment>
{children}
</Fragment>
)}
</Fragment>
)}
</Fragment>
)
}

View file

@ -0,0 +1,47 @@
import { Field } from "react-final-form";
import React from "react";
import Error from "./Error";
import {classNames} from "../../styles/utils";
type COL_WIDTHS = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
interface Props {
name: string;
label?: string;
placeholder?: string;
columns?: COL_WIDTHS;
className?: string;
autoComplete?: string;
}
const PasswordField: React.FC<Props> = ({ name, label, placeholder, columns , className, autoComplete}) => (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
)}
>
{label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 uppercase tracking-wide">
{label}
</label>
)}
<Field
name={name}
render={({input, meta}) => (
<input
{...input}
id={name}
type="password"
autoComplete={autoComplete}
className="mt-2 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
placeholder={placeholder}
/>
)}
/>
<div>
<Error name={name} classNames="text-red mt-2" />
</div>
</div>
)
export default PasswordField;

View file

@ -11,9 +11,10 @@ interface Props {
placeholder?: string;
columns?: COL_WIDTHS;
className?: string;
autoComplete?: string;
}
const TextField: React.FC<Props> = ({ name, label, placeholder, columns , className}) => (
const TextField: React.FC<Props> = ({ name, label, placeholder, columns , className, autoComplete}) => (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
@ -31,6 +32,7 @@ const TextField: React.FC<Props> = ({ name, label, placeholder, columns , classN
{...input}
id={name}
type="text"
autoComplete={autoComplete}
className="mt-2 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
placeholder={placeholder}
/>

View file

@ -1,5 +1,6 @@
export { default as TextField } from "./TextField";
export { default as TextFieldWide } from "./TextFieldWide";
export { default as PasswordField } from "./PasswordField";
export { default as TextAreaWide } from "./TextAreaWide";
export { default as MultiSelectField } from "./MultiSelectField";
export { default as RadioFieldset } from "./RadioFieldset";

View file

@ -1,7 +1,7 @@
import React, {Fragment, useEffect } from "react";
import {useMutation} from "react-query";
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import {sleep} from "../../utils/utils";
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";

View file

@ -1,7 +1,7 @@
import {Fragment, useEffect} from "react";
import {useMutation} from "react-query";
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import {sleep} from "../../utils/utils";
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";

View file

@ -1,7 +1,7 @@
import React, {Fragment, useEffect} from "react";
import {useMutation} from "react-query";
import {Filter} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import {XIcon} from "@heroicons/react/solid";
import {Dialog, Transition} from "@headlessui/react";
import {Field, Form} from "react-final-form";

View file

@ -7,7 +7,7 @@ import {classNames} from "../../styles/utils";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import {SwitchGroup} from "../../components/inputs";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import APIClient from "../../api/APIClient";
import {sleep} from "../../utils/utils";

View file

@ -2,7 +2,7 @@ import {Fragment, useRef, useState} from "react";
import {useToggle} from "../../hooks/hooks";
import {useMutation} from "react-query";
import {DownloadClient} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import {Dialog, RadioGroup, Transition} from "@headlessui/react";
import {ExclamationIcon, XIcon} from "@heroicons/react/solid";
import {classNames} from "../../styles/utils";

View file

@ -1,4 +1,4 @@
import React, {Fragment, useEffect} from "react";
import React, {Fragment} from "react";
import {useMutation, useQuery} from "react-query";
import {Indexer} from "../../domain/interfaces";
import {sleep} from "../../utils/utils";
@ -7,7 +7,7 @@ import {Dialog, Transition} from "@headlessui/react";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import Select from "react-select";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import { SwitchGroup } from "../../components/inputs";
import APIClient from "../../api/APIClient";

View file

@ -7,9 +7,9 @@ import {Dialog, Transition} from "@headlessui/react";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import { SwitchGroup } from "../../components/inputs";
import {queryClient} from "../../index";
import {useToggle} from "../../hooks/hooks";
import APIClient from "../../api/APIClient";
import {queryClient} from "../../App";
interface props {
isOpen: boolean;

View file

@ -6,7 +6,7 @@ import {XIcon} from "@heroicons/react/solid";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";

View file

@ -6,7 +6,7 @@ import {XIcon} from "@heroicons/react/solid";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
import {queryClient} from "../../index";
import {queryClient} from "../../App";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";

View file

@ -1,17 +1,10 @@
import React, {useEffect, useState} from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import reportWebVitals from './reportWebVitals';
import Base from "./screens/Base";
import {BrowserRouter as Router,} from "react-router-dom";
import {ReactQueryDevtools} from 'react-query/devtools'
import {QueryClient, QueryClientProvider} from 'react-query'
import {RecoilRoot, useRecoilState} from 'recoil';
import {configState} from "./state/state";
import APIClient from "./api/APIClient";
import {RecoilRoot} from 'recoil';
import {APP} from "./domain/interfaces";
import App from "./App";
declare global {
interface Window { APP: APP; }
@ -19,43 +12,11 @@ declare global {
window.APP = window.APP || {};
export const queryClient = new QueryClient()
const ConfigWrapper = () => {
const [config, setConfig] = useRecoilState(configState)
const [loading, setLoading] = useState(true)
useEffect(() => {
APIClient.config.get().then(res => {
setConfig(res)
setLoading(false)
})
}, [setConfig])
return (
<QueryClientProvider client={queryClient}>
{loading ? null : (
<Router basename={config.base_url}>
<Base/>
</Router>
)}
<ReactQueryDevtools initialIsOpen={false}/>
</QueryClientProvider>
)
};
ReactDOM.render(
<React.StrictMode>
<RecoilRoot>
<ConfigWrapper/>
<App />
</RecoilRoot>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

BIN
web/src/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View file

@ -41,77 +41,77 @@ export default function Base() {
</div>
</div>
</div>
{/* <div className="hidden md:block">*/}
{/* <div className="ml-4 flex items-center md:ml-6">*/}
{/* <button*/}
{/* className="bg-gray-800 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">*/}
{/* <span className="sr-only">View notifications</span>*/}
{/* <BellIcon className="h-6 w-6" aria-hidden="true"/>*/}
{/* </button>*/}
<div className="hidden md:block">
<div className="ml-4 flex items-center md:ml-6">
{/*<button*/}
{/* className="bg-gray-800 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">*/}
{/* <span className="sr-only">View notifications</span>*/}
{/* <BellIcon className="h-6 w-6" aria-hidden="true"/>*/}
{/*</button>*/}
{/* <Menu as="div" className="ml-3 relative">*/}
{/* {({open}) => (*/}
{/* <>*/}
{/* <div>*/}
{/* <Menu.Button*/}
{/* className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">*/}
{/* <span*/}
{/* className="hidden text-gray-300 text-sm font-medium lg:block">*/}
{/* <span className="sr-only">Open user menu for </span>User*/}
{/*</span>*/}
{/* <ChevronDownIcon*/}
{/* className="hidden flex-shrink-0 ml-1 h-5 w-5 text-gray-400 lg:block"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* </Menu.Button>*/}
{/* </div>*/}
{/* <Transition*/}
{/* show={open}*/}
{/* as={Fragment}*/}
{/* enter="transition ease-out duration-100"*/}
{/* enterFrom="transform opacity-0 scale-95"*/}
{/* enterTo="transform opacity-100 scale-100"*/}
{/* leave="transition ease-in duration-75"*/}
{/* leaveFrom="transform opacity-100 scale-100"*/}
{/* leaveTo="transform opacity-0 scale-95"*/}
{/* >*/}
{/* <Menu.Items*/}
{/* static*/}
{/* className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"*/}
{/* >*/}
{/* <Menu.Item>*/}
{/* {({active}) => (*/}
{/* <Link*/}
{/* to="settings"*/}
{/* className={classNames(*/}
{/* active ? 'bg-gray-100' : '',*/}
{/* 'block px-4 py-2 text-sm text-gray-700'*/}
{/* )}*/}
{/* >*/}
{/* Settings*/}
{/* </Link>*/}
{/* )}*/}
{/* </Menu.Item>*/}
{/* <Menu.Item>*/}
{/* {({active}) => (*/}
{/* <Link*/}
{/* to="logout"*/}
{/* className={classNames(*/}
{/* active ? 'bg-gray-100' : '',*/}
{/* 'block px-4 py-2 text-sm text-gray-700'*/}
{/* )}*/}
{/* >*/}
{/* Logout*/}
{/* </Link>*/}
{/* )}*/}
{/* </Menu.Item>*/}
{/* </Menu.Items>*/}
{/* </Transition>*/}
{/* </>*/}
{/* )}*/}
{/* </Menu>*/}
{/* </div>*/}
{/* </div>*/}
<Menu as="div" className="ml-3 relative">
{({open}) => (
<>
<div>
<Menu.Button
className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
<span
className="hidden text-gray-300 text-sm font-medium lg:block">
<span className="sr-only">Open user menu for </span>User
</span>
<ChevronDownIcon
className="hidden flex-shrink-0 ml-1 h-5 w-5 text-gray-400 lg:block"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-48 z-10 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<Menu.Item>
{({active}) => (
<Link
to="settings"
className={classNames(
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700'
)}
>
Settings
</Link>
)}
</Menu.Item>
<Menu.Item>
{({active}) => (
<Link
to="/logout"
className={classNames(
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700'
)}
>
Logout
</Link>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
</div>
<div className="-mr-2 flex md:hidden">
{/* Mobile menu button */}
<Disclosure.Button
@ -192,7 +192,7 @@ export default function Base() {
<FilterDetails />
</Route>
<Route path="/">
<Route exact path="/">
<Dashboard />
</Route>
</Switch>

View file

@ -17,7 +17,7 @@ import {FilterActionList} from "../components/FilterActionList";
import {DownloadClient, Filter, Indexer} from "../domain/interfaces";
import {useToggle} from "../hooks/hooks";
import {useMutation, useQuery} from "react-query";
import {queryClient} from "../index";
import {queryClient} from "../App";
import {CONTAINER_OPTIONS, CODECS_OPTIONS, RESOLUTION_OPTIONS, SOURCES_OPTIONS} from "../domain/constants";
import {Field, Form} from "react-final-form";
import {MultiSelectField, TextField} from "../components/inputs";
@ -345,7 +345,7 @@ export function FilterDetails() {
}
if (!data) {
return (<p>Something went wrong</p>)
return null
}
return (

View file

@ -1,21 +1,12 @@
import React from 'react'
import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
import {
BrowserRouter as Router,
NavLink,
Route,
Switch as RouteSwitch,
useLocation,
useRouteMatch
} from "react-router-dom";
import {NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch} from "react-router-dom";
import IndexerSettings from "./settings/Indexer";
import IrcSettings from "./settings/Irc";
import ApplicationSettings from "./settings/Application";
import DownloadClientSettings from "./settings/DownloadClient";
import {classNames} from "../styles/utils";
import ActionSettings from "./settings/Action";
import {useRecoilValue} from "recoil";
import {configState} from "../state/state";
const subNavigation = [
{name: 'Application', href: '', icon: CogIcon, current: true},
@ -74,71 +65,48 @@ function SidebarNav({subNavigation, url}: any) {
)
}
export function buildPath(...args: string[]): string {
const [first] = args;
const firstTrimmed = first.trim();
const result = args
.map((part) => part.trim())
.map((part, i) => {
if (i === 0) {
return part.replace(/[/]*$/g, '');
} else {
return part.replace(/(^[/]*|[/]*$)/g, '');
}
})
.filter((x) => x.length)
.join('/');
return firstTrimmed === '/' ? `/${result}` : result;
}
export default function Settings() {
const config = useRecoilValue(configState)
let { url } = useRouteMatch();
let p = config.base_url ? buildPath(config.base_url, url) : url
let {url} = useRouteMatch();
return (
<Router>
<main className="relative -mt-48">
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white capitalize">Settings</h1>
</div>
</header>
<main className="relative -mt-48">
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white capitalize">Settings</h1>
</div>
</header>
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<SidebarNav url={p} subNavigation={subNavigation}/>
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<SidebarNav url={url} subNavigation={subNavigation}/>
<RouteSwitch>
<Route exact path={p}>
<ApplicationSettings />
</Route>
<RouteSwitch>
<Route exact path={url}>
<ApplicationSettings/>
</Route>
<Route path={`${p}/indexers`}>
<IndexerSettings />
</Route>
<Route path={`${url}/indexers`}>
<IndexerSettings/>
</Route>
<Route path={`${p}/irc`}>
<IrcSettings />
</Route>
<Route path={`${url}/irc`}>
<IrcSettings/>
</Route>
<Route path={`${p}/clients`}>
<DownloadClientSettings />
</Route>
<Route path={`${url}/clients`}>
<DownloadClientSettings/>
</Route>
<Route path={`${p}/actions`}>
<ActionSettings />
</Route>
<Route path={`${url}/actions`}>
<ActionSettings/>
</Route>
</RouteSwitch>
</div>
</RouteSwitch>
</div>
</div>
</main>
</Router>
</div>
</main>
)
}

View file

@ -0,0 +1,104 @@
import {useMutation} from "react-query";
import APIClient from "../../api/APIClient";
import {Form} from "react-final-form";
import {PasswordField, TextField} from "../../components/inputs";
import {useRecoilState} from "recoil";
import {isLoggedIn} from "../../state/state";
import {useHistory} from "react-router-dom";
import {useEffect} from "react";
import logo from "../../logo.png"
interface loginData {
username: string;
password: string;
}
function Login() {
const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn);
let history = useHistory();
useEffect(() => {
if(loggedIn) {
// setLoading(false);
history.push('/');
} else {
// setLoading(false);
}
}, [loggedIn, history])
const mutation = useMutation((data: loginData) => APIClient.auth.login(data.username, data.password), {
onSuccess: () => {
setLoggedIn(true);
},
})
const onSubmit = (data: any, form: any) => {
mutation.mutate(data)
form.reset()
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md mb-6">
<img
className="mx-auto h-12 w-auto"
src={logo}
alt="logo"
/>
</div>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<Form
initialValues={{
username: "",
password: "",
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<TextField name="username" label="Username" autoComplete="username" />
<PasswordField name="password" label="password" autoComplete="current-password"/>
{/*<div className="flex items-center justify-between">*/}
{/* <div className="flex items-center">*/}
{/* <input*/}
{/* id="remember-me"*/}
{/* name="remember-me"*/}
{/* type="checkbox"*/}
{/* className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"*/}
{/* />*/}
{/* <label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">*/}
{/* Remember me*/}
{/* </label>*/}
{/* </div>*/}
{/* <div className="text-sm">*/}
{/* <a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">*/}
{/* Forgot your password?*/}
{/* </a>*/}
{/* </div>*/}
{/*</div>*/}
<div>
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign in
</button>
</div>
</form>
)
}}
</Form>
</div>
</div>
</div>
)
}
export default Login;

View file

@ -0,0 +1,29 @@
import APIClient from "../../api/APIClient";
import {useRecoilState} from "recoil";
import {isLoggedIn} from "../../state/state";
import {useEffect} from "react";
import {useCookies} from "react-cookie";
import {useHistory} from "react-router-dom";
function Logout() {
const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn);
let history = useHistory();
const [_, removeCookie] = useCookies(['user_session']);
useEffect(() => {
APIClient.auth.logout().then(r => {
removeCookie("user_session", "")
setLoggedIn(false);
history.push('/login');
})
}, [loggedIn, history, removeCookie, setLoggedIn])
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<p>Logged out</p>
</div>
)
}
export default Logout;

View file

@ -1,12 +1,25 @@
import React, {useState} from "react";
import {Switch} from "@headlessui/react";
import { classNames } from "../../styles/utils";
import {useRecoilState} from "recoil";
import {configState} from "../../state/state";
// import {useRecoilState} from "recoil";
// import {configState} from "../../state/state";
import {useQuery} from "react-query";
import {Config} from "../../domain/interfaces";
import APIClient from "../../api/APIClient";
function ApplicationSettings() {
const [isDebug, setIsDebug] = useState(true)
const [config] = useRecoilState(configState)
// const [config] = useRecoilState(configState)
const {isLoading, data} = useQuery<Config, Error>(['config'], () => APIClient.config.get(),
{
retry: false,
refetchOnWindowFocus: false,
onError: err => {
console.log(err)
}
},
)
return (
<form className="divide-y divide-gray-200 lg:col-span-9" action="#" method="POST">
@ -18,6 +31,8 @@ function ApplicationSettings() {
</p>
</div>
{!isLoading && data && (
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6 sm:col-span-4">
<label htmlFor="host" className="block text-sm font-medium text-gray-700">
@ -27,7 +42,7 @@ function ApplicationSettings() {
type="text"
name="host"
id="host"
value={config.host}
value={data.host}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
@ -41,7 +56,7 @@ function ApplicationSettings() {
type="text"
name="port"
id="port"
value={config.port}
value={data.port}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
@ -55,12 +70,13 @@ function ApplicationSettings() {
type="text"
name="base_url"
id="base_url"
value={config.base_url}
value={data.base_url}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
</div>
</div>
)}
</div>
<div className="pt-6 pb-6 divide-y divide-gray-200">

View file

@ -9,4 +9,9 @@ export const configState = atom({
log_path: "",
log_level: "DEBUG",
}
});
});
export const isLoggedIn = atom({
key: 'isLoggedIn',
default: false,
})

View file

@ -2,3 +2,39 @@
export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// get baseUrl sent from server rendered index template
export function baseUrl() {
let baseUrl = ""
if (window.APP.baseUrl) {
if (window.APP.baseUrl === "/") {
baseUrl = "/"
} else if (window.APP.baseUrl === "{{.BaseUrl}}") {
baseUrl = "/"
} else if (window.APP.baseUrl === "/autobrr/") {
baseUrl = "/autobrr/"
} else {
baseUrl = window.APP.baseUrl
}
}
return baseUrl
}
export function buildPath(...args: string[]): string {
const [first] = args;
const firstTrimmed = first.trim();
const result = args
.map((part) => part.trim())
.map((part, i) => {
if (i === 0) {
return part.replace(/[/]*$/g, '');
} else {
return part.replace(/(^[/]*|[/]*$)/g, '');
}
})
.filter((x) => x.length)
.join('/');
return firstTrimmed === '/' ? `/${result}` : result;
}