mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
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:
parent
2e8d0950c1
commit
40b855bf39
56 changed files with 1208 additions and 257 deletions
36
web/src/App.tsx
Normal file
36
web/src/App.tsx
Normal 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;
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
|
38
web/src/components/Layout.tsx
Normal file
38
web/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
47
web/src/components/inputs/PasswordField.tsx
Normal file
47
web/src/components/inputs/PasswordField.tsx
Normal 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;
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
BIN
web/src/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.2 KiB |
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
104
web/src/screens/auth/login.tsx
Normal file
104
web/src/screens/auth/login.tsx
Normal 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;
|
29
web/src/screens/auth/logout.tsx
Normal file
29
web/src/screens/auth/logout.tsx
Normal 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;
|
|
@ -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">
|
||||
|
|
|
@ -9,4 +9,9 @@ export const configState = atom({
|
|||
log_path: "",
|
||||
log_level: "DEBUG",
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const isLoggedIn = atom({
|
||||
key: 'isLoggedIn',
|
||||
default: false,
|
||||
})
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue