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

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">