diff --git a/internal/http/auth.go b/internal/http/auth.go index a725aa0..53d63cd 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -133,7 +133,7 @@ func (h authHandler) canOnboard(w http.ResponseWriter, r *http.Request) { if userCount > 0 { // send 503 service onboarding unavailable - http.Error(w, "Onboarding unavailable", http.StatusServiceUnavailable) + http.Error(w, "Onboarding unavailable", http.StatusForbidden) return } diff --git a/web/package.json b/web/package.json index 540c7be..1d0683d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,34 +1,34 @@ { "name": "web", - "version": "0.1.0", + "version": "0.2.0", "private": true, "homepage": ".", "dependencies": { - "@fontsource/inter": "^4.5.4", - "@headlessui/react": "^1.2.0", - "@heroicons/react": "^1.0.1", - "date-fns": "^2.25.0", + "@fontsource/inter": "^4.5.10", + "@headlessui/react": "^1.6.3", + "@heroicons/react": "^1.0.6", + "@hookform/error-message": "^2.0.0", + "date-fns": "^2.28.0", "formik": "^2.2.9", - "react": "^17.0.2", + "react": "^18.1.0", "react-cookie": "^4.1.1", "react-debounce-input": "^3.2.5", - "react-dom": "^17.0.2", + "react-dom": "^18.1.0", "react-error-boundary": "^3.1.4", - "react-hot-toast": "^2.1.1", - "react-multi-select-component": "4.2.5", - "react-query": "^3.18.1", + "react-hook-form": "^7.31.3", + "react-hot-toast": "^2.2.0", + "react-multi-select-component": "^4.2.7", + "react-query": "^3.39.0", "react-ridge-state": "4.2.2", - "react-router-dom": "^5.2.0", - "react-scripts": "^5.0.0", - "react-select": "5.0.0-beta.0", - "react-table": "^7.7.0", - "stacktracey": "^2.1.8", - "web-vitals": "^1.0.1" + "react-router-dom": "^6.3.0", + "react-scripts": "^5.0.1", + "react-select": "^5.3.2", + "react-table": "^7.8.0", + "stacktracey": "^2.1.8" }, "scripts": { "start": "BROWSER=none react-scripts start", "build": "react-scripts build", - "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint src/ --ext .js,.jsx,.ts,.tsx --color", "lint:watch": "npm run lint -- --watch" @@ -45,32 +45,24 @@ "last 1 safari version" ] }, - "overrides": { - "@types/react": "17.0.0", - "@types/react-dom": "17.0.0" - }, "devDependencies": { - "@tailwindcss/forms": "^0.4.0", - "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^11.1.0", - "@testing-library/user-event": "^12.1.10", - "@types/jest": "^26.0.15", - "@types/node": "^12.0.0", - "@types/react": "17.0.0", - "@types/react-dom": "17.0.0", + "@tailwindcss/forms": "^0.5.2", + "@types/node": "^17.0.35", + "@types/react": "^18.0.9", + "@types/react-dom": "^18.0.5", "@types/react-router-dom": "^5.1.7", - "@types/react-table": "^7.7.7", - "@typescript-eslint/eslint-plugin": "^5.18.0", - "@typescript-eslint/parser": "^5.18.0", - "autoprefixer": "^10.4.2", - "eslint": "^8.8.0", - "eslint-plugin-import": "^2.25.4", - "eslint-plugin-react": "^7.28.0", - "eslint-plugin-react-hooks": "^4.3.0", + "@types/react-table": "^7.7.12", + "@typescript-eslint/eslint-plugin": "^5.26.0", + "@typescript-eslint/parser": "^5.26.0", + "autoprefixer": "^10.4.7", + "eslint": "^8.16.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-react": "^7.30.0", + "eslint-plugin-react-hooks": "^4.5.0", "eslint-watch": "^8.0.0", "http-proxy-middleware": "^2.0.6", - "postcss": "^8.4.6", - "tailwindcss": "^3.0.18", - "typescript": "^4.1.2" + "postcss": "^8.4.14", + "tailwindcss": "^3.0.24", + "typescript": "^4.7.2" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 34b4f30..f1de2e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,9 @@ -import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "react-query"; import { ReactQueryDevtools } from "react-query/devtools"; import { ErrorBoundary } from "react-error-boundary"; import { toast, Toaster } from "react-hot-toast"; -import Base from "./screens/Base"; -import { Login } from "./screens/auth/login"; -import { Logout } from "./screens/auth/logout"; -import { Onboarding } from "./screens/auth/onboarding"; - -import { baseUrl } from "./utils"; +import { LocalRouter } from "./domain/routes"; import { AuthContext, SettingsContext } from "./utils/Context"; import { ErrorPage } from "./components/alerts"; import Toast from "./components/notifications/Toast"; @@ -44,17 +38,7 @@ export function App() { onReset={reset} fallbackRender={ErrorPage} > - - - {authContext.isLoggedIn ? ( - - ) : ( - - - - - )} - + {settings.debug ? ( ) : null} diff --git a/web/src/components/debug.tsx b/web/src/components/debug.tsx index cd27fc5..f9aec16 100644 --- a/web/src/components/debug.tsx +++ b/web/src/components/debug.tsx @@ -10,7 +10,7 @@ const DEBUG: FC = ({ values }) => { } return ( -
+
{JSON.stringify(values, null, 2)}
); diff --git a/web/src/components/inputs/common.tsx b/web/src/components/inputs/common.tsx index 3c84cd4..5762739 100644 --- a/web/src/components/inputs/common.tsx +++ b/web/src/components/inputs/common.tsx @@ -6,11 +6,13 @@ interface ErrorFieldProps { } const ErrorField = ({ name, classNames }: ErrorFieldProps) => ( - - {({ meta: { touched, error } }: FieldProps) => - touched && error ? {error} : null - } - +
+ + {({ meta: { touched, error } }: FieldProps) => + touched && error ? {error} : null + } + +
); interface CheckboxFieldProps { @@ -26,7 +28,7 @@ const CheckboxField = ({ }: CheckboxFieldProps) => (
- c.id === field.value)?.name : "Choose a client"} - {/*Choose a client*/} = ({ + children, + className +}) => ( +

+ {children} +

+); + +export type InputType = "text" | "email" | "password"; +export type InputAutoComplete = "username" | "current-password"; +export type InputColumnWidth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + +export type InputProps = { + id: string; + name: string; + label: string; + type?: InputType; + className?: string; + placeholder?: string; + autoComplete?: InputAutoComplete; + isHidden?: boolean; + columnWidth?: InputColumnWidth; +}; + +// Using maps so that the full Tailwind classes can be seen for purging +// see https://tailwindcss.com/docs/optimizing-for-production#writing-purgeable-html + +// const sizeMap: { [key in InputSize]: string } = { +// medium: "p-3 text-base", +// large: "p-4 text-base" +// }; + +export const Input: FC = forwardRef( + ( + { + id, + name, + label, + type , + className = "", + placeholder, + autoComplete, + ...props + }, + ref + ) => { + return ( + + ); + } +); + +export type FormInputProps = { + name: Path; + rules?: RegisterOptions; + register?: UseFormRegister; + errors?: Partial>; +} & Omit; + +export const TextInput = >({ + name, + register, + rules, + errors, + isHidden, + columnWidth, + ...props +}: FormInputProps): JSX.Element => { + // If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get + const errorMessages = get(errors, name); + const hasError = !!(errors && errorMessages); + + return ( +
+ {props.label && ( + + )} +
+ + ( + {message} + )} + /> +
+
+ ); +}; + +export const PasswordInput = >({ + name, + register, + rules, + errors, + isHidden, + columnWidth, + ...props +}: FormInputProps): JSX.Element => { + const [isVisible, toggleVisibility] = useToggle(false); + + // If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get + const errorMessages = get(errors, name); + const hasError = !!(errors && errorMessages); + + return ( +
+ {props.label && ( + + )} +
+
+ +
+ {!isVisible ?
+
+ ( + {message} + )} + /> +
+
+ ); +}; + diff --git a/web/src/domain/routes.tsx b/web/src/domain/routes.tsx new file mode 100644 index 0000000..ac60b44 --- /dev/null +++ b/web/src/domain/routes.tsx @@ -0,0 +1,55 @@ +import { BrowserRouter, Routes, Route } from "react-router-dom"; + +import { Login } from "../screens/auth/login"; +import { Logout } from "../screens/auth/logout"; +import { Onboarding } from "../screens/auth/onboarding"; +import Base from "../screens/Base"; +import { Dashboard } from "../screens/dashboard"; +import { FilterDetails, Filters } from "../screens/filters"; +import { Logs } from "../screens/Logs"; +import { Releases } from "../screens/releases"; +import Settings from "../screens/Settings"; +import ApplicationSettings from "../screens/settings/Application"; +import DownloadClientSettings from "../screens/settings/DownloadClient"; +import FeedSettings from "../screens/settings/Feed"; +import IndexerSettings from "../screens/settings/Indexer"; +import { IrcSettings } from "../screens/settings/Irc"; +import NotificationSettings from "../screens/settings/Notifications"; +import { RegexPlayground } from "../screens/settings/RegexPlayground"; +import ReleaseSettings from "../screens/settings/Releases"; + +import { baseUrl } from "../utils"; + +export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => ( + + {isLoggedIn ? ( + + } /> + }> + } /> + } /> + } /> + + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) : ( + + } /> + } /> + + )} + +); diff --git a/web/src/forms/filters/FilterAddForm.tsx b/web/src/forms/filters/FilterAddForm.tsx index 65b27b6..8e02ce4 100644 --- a/web/src/forms/filters/FilterAddForm.tsx +++ b/web/src/forms/filters/FilterAddForm.tsx @@ -102,7 +102,7 @@ function FilterAddForm({ isOpen, toggle }: filterAddFormProps) { htmlFor="name" className="block text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2" > - Name + Name
@@ -119,7 +119,7 @@ function FilterAddForm({ isOpen, toggle }: filterAddFormProps) { /> {meta.touched && meta.error && - {meta.error}} + {meta.error}}
)} @@ -136,13 +136,13 @@ function FilterAddForm({ isOpen, toggle }: filterAddFormProps) { className="bg-white dark:bg-gray-800 py-2 px-4 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500" onClick={toggle} > - Cancel + Cancel
diff --git a/web/src/index.tsx b/web/src/index.tsx index e454f0a..73b3342 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,5 +1,5 @@ import { StrictMode } from "react"; -import ReactDOM from "react-dom"; +import { createRoot } from "react-dom/client"; import "@fontsource/inter/variable.css"; import "./index.css"; @@ -16,9 +16,10 @@ window.APP = window.APP || {}; // Initializes auth and theme contexts InitializeGlobalContext(); -ReactDOM.render( +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const root = createRoot(document.getElementById("root")!); +root.render( - , - document.getElementById("root") -); + +); \ No newline at end of file diff --git a/web/src/reportWebVitals.ts b/web/src/reportWebVitals.ts deleted file mode 100644 index ad7f32f..0000000 --- a/web/src/reportWebVitals.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ReportHandler } from "web-vitals"; - -const reportWebVitals = (onPerfEntry?: ReportHandler) => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/web/src/screens/Base.tsx b/web/src/screens/Base.tsx index 69d0021..87242c8 100644 --- a/web/src/screens/Base.tsx +++ b/web/src/screens/Base.tsx @@ -1,16 +1,9 @@ import { Fragment } from "react"; -import type { match } from "react-router-dom"; -import { Link, NavLink, Route, Switch } from "react-router-dom"; +import { Link, NavLink, Outlet } from "react-router-dom"; import { Disclosure, Menu, Transition } from "@headlessui/react"; import { ExternalLinkIcon } from "@heroicons/react/solid"; import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline"; -import Settings from "./Settings"; - -import { Logs } from "./Logs"; -import { Releases } from "./releases"; -import { Dashboard } from "./dashboard"; -import { FilterDetails, Filters } from "./filters"; import { AuthContext } from "../utils/Context"; import logo from "../logo.png"; @@ -24,23 +17,6 @@ function classNames(...classes: string[]) { return classes.filter(Boolean).join(" "); } -const isActiveMatcher = ( - match: match | null, - location: { pathname: string }, - item: NavItem -) => { - if (!match) - return false; - - if (match?.url === "/" && item.path === "/" && location.pathname === "/") - return true; - - if (match.url === "/") - return false; - - return true; -}; - export default function Base() { const authContext = AuthContext.useValue(); const nav: Array = [ @@ -81,13 +57,11 @@ export default function Base() { classNames( + "hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium", + "transition-colors duration-200", + isActive ? "text-black dark:text-gray-50 font-bold" : "text-gray-600 dark:text-gray-500" )} - activeClassName="text-black dark:text-gray-50 !font-bold" - isActive={(match, location) => isActiveMatcher(match, location, item)} > {item.name} @@ -199,10 +173,11 @@ export default function Base() { isActiveMatcher(match, location, item)} + className={({ isActive }) => classNames( + // TODO: Double check whether this is correct + "dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base", + isActive ? "font-bold bg-gray-300 text-black" : "font-medium" + )} > {item.name} @@ -219,32 +194,7 @@ export default function Base() { )} - - - - - - - - - - - - - - - - - - - - - - - - - - + ); } diff --git a/web/src/screens/Settings.tsx b/web/src/screens/Settings.tsx index 6c39701..c54f2b7 100644 --- a/web/src/screens/Settings.tsx +++ b/web/src/screens/Settings.tsx @@ -1,56 +1,52 @@ -import { BellIcon, ChatAlt2Icon, CogIcon, CollectionIcon, DownloadIcon, KeyIcon, RssIcon } from "@heroicons/react/outline"; -import { NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch } from "react-router-dom"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { + BellIcon, + ChatAlt2Icon, + CogIcon, + CollectionIcon, + DownloadIcon, + KeyIcon, + RssIcon +} from "@heroicons/react/outline"; import { classNames } from "../utils"; -import IndexerSettings from "./settings/Indexer"; -import { IrcSettings } from "./settings/Irc"; -import ApplicationSettings from "./settings/Application"; -import DownloadClientSettings from "./settings/DownloadClient"; -import { RegexPlayground } from "./settings/RegexPlayground"; -import ReleaseSettings from "./settings/Releases"; -import NotificationSettings from "./settings/Notifications"; -import FeedSettings from "./settings/Feed"; interface NavTabType { name: string; href: string; icon: typeof CogIcon; - current: boolean; } const subNavigation: NavTabType[] = [ - { name: "Application", href: "", icon: CogIcon, current: true }, - { name: "Indexers", href: "indexers", icon: KeyIcon, current: false }, - { name: "IRC", href: "irc", icon: ChatAlt2Icon, current: false }, - { name: "Feeds", href: "feeds", icon: RssIcon, current: false }, - { name: "Clients", href: "clients", icon: DownloadIcon, current: false }, - { name: "Notifications", href: "notifications", icon: BellIcon, current: false }, - { name: "Releases", href: "releases", icon: CollectionIcon, current: false } + { name: "Application", href: "", icon: CogIcon }, + { name: "Indexers", href: "indexers", icon: KeyIcon }, + { name: "IRC", href: "irc", icon: ChatAlt2Icon }, + { name: "Feeds", href: "feeds", icon: RssIcon }, + { name: "Clients", href: "clients", icon: DownloadIcon }, + { name: "Notifications", href: "notifications", icon: BellIcon }, + { name: "Releases", href: "releases", icon: CollectionIcon } // {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false} // {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false}, ]; interface NavLinkProps { item: NavTabType; - url: string; } -function SubNavLink({ item, url }: NavLinkProps) { - const location = useLocation(); - const { pathname } = location; - +function SubNavLink({ item }: NavLinkProps) { + const { pathname } = useLocation(); const splitLocation = pathname.split("/"); // we need to clean the / if it's a base root path - const too = item.href ? `${url}/${item.href}` : url; return ( classNames( + "border-transparent text-gray-900 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-300 group border-l-4 px-3 py-2 flex items-center text-sm font-medium", + isActive ? + "bg-teal-50 dark:bg-gray-700 border-teal-500 dark:border-blue-500 text-teal-700 dark:text-white hover:bg-teal-50 dark:hover:bg-gray-500 hover:text-teal-700 dark:hover:text-gray-200" : "" )} aria-current={splitLocation[2] === item.href ? "page" : undefined} > @@ -65,15 +61,14 @@ function SubNavLink({ item, url }: NavLinkProps) { interface SidebarNavProps { subNavigation: NavTabType[]; - url: string; } -function SidebarNav({ subNavigation, url }: SidebarNavProps) { +function SidebarNav({ subNavigation }: SidebarNavProps) { return ( @@ -81,7 +76,6 @@ function SidebarNav({ subNavigation, url }: SidebarNavProps) { } export default function Settings() { - const { url } = useRouteMatch(); return (
@@ -93,41 +87,8 @@ export default function Settings() {
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
diff --git a/web/src/screens/auth/login.tsx b/web/src/screens/auth/login.tsx index 7bbad22..a66ae9c 100644 --- a/web/src/screens/auth/login.tsx +++ b/web/src/screens/auth/login.tsx @@ -1,44 +1,48 @@ -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useMutation } from "react-query"; -import { Form, Formik } from "formik"; import { APIClient } from "../../api/APIClient"; -import { TextField, PasswordField } from "../../components/inputs"; import logo from "../../logo.png"; import { AuthContext } from "../../utils/Context"; import { useEffect } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { PasswordInput, TextInput } from "../../components/inputs/text"; -interface LoginData { +export type LoginFormFields = { username: string; password: string; -} +}; export const Login = () => { - const history = useHistory(); + const { handleSubmit, register, formState: { errors } } = useForm({ + defaultValues: { username: "", password: "" }, + mode: "onBlur" + }); + const navigate = useNavigate(); const [, setAuthContext] = AuthContext.use(); useEffect(() => { // Check if onboarding is available for this instance // and redirect if needed APIClient.auth.canOnboard() - .then(() => history.push("/onboard")); + .then(() => navigate("/onboard")); }, [history]); - const mutation = useMutation( - (data: LoginData) => APIClient.auth.login(data.username, data.password), + const loginMutation = useMutation( + (data: LoginFormFields) => APIClient.auth.login(data.username, data.password), { - onSuccess: (_, variables: LoginData) => { + onSuccess: (_, variables: LoginFormFields) => { setAuthContext({ username: variables.username, isLoggedIn: true }); - history.push("/"); + navigate("/"); } } ); - const handleSubmit = (data: LoginData) => mutation.mutate(data); + const onSubmit: SubmitHandler = (data: LoginFormFields) => loginMutation.mutate(data); return (
@@ -52,25 +56,39 @@ export const Login = () => {
- -
-
- - -
-
- -
-
-
+
+
+ + name="username" + id="username" + label="username" + type="text" + register={register} + rules={{ required: "Username is required" }} + errors={errors} + autoComplete="username" + /> + + name="password" + id="password" + label="password" + register={register} + rules={{ required: "Password is required" }} + errors={errors} + autoComplete="current-password" + /> +
+ +
+ +
+
+
diff --git a/web/src/screens/auth/logout.tsx b/web/src/screens/auth/logout.tsx index 72ffdc9..2f27791 100644 --- a/web/src/screens/auth/logout.tsx +++ b/web/src/screens/auth/logout.tsx @@ -1,12 +1,12 @@ import { useEffect } from "react"; import { useCookies } from "react-cookie"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { APIClient } from "../../api/APIClient"; import { AuthContext } from "../../utils/Context"; export const Logout = () => { - const history = useHistory(); + const navigate = useNavigate(); const [, setAuthContext] = AuthContext.use(); const [,, removeCookie] = useCookies(["user_session"]); @@ -15,10 +15,10 @@ export const Logout = () => { () => { APIClient.auth.logout() .then(() => { - setAuthContext({ username: "", isLoggedIn: false }); removeCookie("user_session"); + setAuthContext({ username: "", isLoggedIn: false }); - history.push("/login"); + navigate("/login"); }); }, [history, removeCookie, setAuthContext] diff --git a/web/src/screens/auth/onboarding.tsx b/web/src/screens/auth/onboarding.tsx index e660d1a..48d8c3a 100644 --- a/web/src/screens/auth/onboarding.tsx +++ b/web/src/screens/auth/onboarding.tsx @@ -1,6 +1,6 @@ import { Form, Formik } from "formik"; import { useMutation } from "react-query"; -import { useHistory } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { APIClient } from "../../api/APIClient"; import { TextField, PasswordField } from "../../components/inputs"; @@ -30,15 +30,11 @@ export const Onboarding = () => { return obj; }; - const history = useHistory(); + const navigate = useNavigate(); const mutation = useMutation( (data: InputValues) => APIClient.auth.onboard(data.username, data.password1), - { - onSuccess: () => { - history.push("/login"); - } - } + { onSuccess: () => navigate("/login") } ); return ( diff --git a/web/src/screens/dashboard/ActivityTable.tsx b/web/src/screens/dashboard/ActivityTable.tsx index 3626859..e8fd0f9 100644 --- a/web/src/screens/dashboard/ActivityTable.tsx +++ b/web/src/screens/dashboard/ActivityTable.tsx @@ -13,6 +13,7 @@ import { EmptyListState } from "../../components/emptystates"; import * as Icons from "../../components/Icons"; import * as DataTable from "../../components/data-table"; +import { Fragment } from "react"; // This is a custom filter UI for selecting // a unique option from a list @@ -32,7 +33,7 @@ function SelectColumnFilter({ // Render a multi-select box return (