diff --git a/urbackupserver/www2/eslint.config.js b/urbackupserver/www2/eslint.config.js index 52dec9f27..75c64d4e1 100644 --- a/urbackupserver/www2/eslint.config.js +++ b/urbackupserver/www2/eslint.config.js @@ -21,6 +21,12 @@ export default tseslint.config( ], rules: { "@typescript-eslint/no-floating-promises": "error", + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { ignoreRestSiblings: true }, + ], + }, }, plugins: { lingui, diff --git a/urbackupserver/www2/src/App.tsx b/urbackupserver/www2/src/App.tsx index e215923f8..99f5c62c3 100644 --- a/urbackupserver/www2/src/App.tsx +++ b/urbackupserver/www2/src/App.tsx @@ -1,39 +1,28 @@ import * as React from "react"; import { Suspense, useEffect, useState } from "react"; -import HeaderBar from "./components/HeaderBar"; -import NavSidebar from "./components/NavSidebar"; -import { proxy, useSnapshot } from "valtio"; -import { createHashRouter, RouterProvider } from "react-router-dom"; -import LoginPage from "./pages/Login"; -import StatusPage from "./pages/Status"; -import { ActivitiesPage } from "./pages/Activities"; +import { + createHashRouter, + Navigate, + RouterProvider, + useLocation, +} from "react-router-dom"; +import LoginPage, { getSessionFromLocalStorage, useUser } from "./pages/Login"; +import { StatusPage } from "./pages/Status"; import { FluentProvider, teamsLightTheme, teamsDarkTheme, Spinner, Toaster, - mergeClasses, Link, } from "@fluentui/react-components"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { useStackStyles } from "./components/StackStyles"; -import UrBackupServer, { SessionNotFoundError } from "./api/urbackupserver"; +import UrBackupServer from "./api/urbackupserver"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; -import { BackupsPage } from "./pages/Backups"; -import { ClientBackupsTable } from "./features/backups/ClientBackupsTable"; -import { BackupsTable } from "./features/backups/BackupsTable"; -import { BackupContentTable } from "./features/backups/BackupContentTable"; import { ErrorPage } from "./components/ErrorPage"; -import { StatisticsPage } from "./pages/Statistics"; -import { LogsPage } from "./pages/Logs"; -import { ClientLogs } from "./features/logs/ClientLogs"; -import { ClientLog } from "./features/logs/ClientLog"; -import { SettingsPage } from "./pages/SettingsPage"; -import { SettingsNavSidebar } from "./features/settings/SettingsNavSidebar"; -import { SettingsServer } from "./features/settings/SettingsServer/SettingsServer"; +import { Layout } from "./components/Layout"; import "./css/global.css"; const initialDark = @@ -52,170 +41,171 @@ export enum Pages { Settings = "settings", } -export const state = proxy({ - loggedIn: false, - activePage: Pages.Status, - pageAfterLogin: Pages.Status, - startupComplete: false, -}); +// Not using any global state, at the moment +// export const state = proxy({}); export const urbackupServer = new UrBackupServer( "x", getSessionFromLocalStorage(), ); -async function isLoggedIn(): Promise { - try { - await urbackupServer.status(); - } catch (error) { - if (error instanceof SessionNotFoundError) return false; - } - return true; -} +function AuthenticatedRoute({ children }: { children: React.ReactNode }) { + const { pathname } = useLocation(); + const { session } = useUser(); -async function jumpToLoginPageIfNeccessary() { - if (state.startupComplete && state.loggedIn) { - state.activePage = state.pageAfterLogin; - return; + if (!session) { + return ; } - if (await isLoggedIn()) { - state.loggedIn = true; - state.startupComplete = true; - state.activePage = state.pageAfterLogin; - } else { - state.loggedIn = false; - await router.navigate(`/`); - } + return children; } export const router = createHashRouter([ { - path: "/", + path: "/login", element: , - loader: async () => { - if (await isLoggedIn()) { - state.loggedIn = true; - state.startupComplete = true; - await router.navigate(`/${Pages.Status}`); - return; - } - state.activePage = Pages.Login; - state.startupComplete = true; - state.loggedIn = false; - return null; - }, errorElement:
Failed to log in.
, }, { - path: `/${Pages.Status}`, - element: , - loader: async () => { - state.pageAfterLogin = Pages.Status; - await jumpToLoginPageIfNeccessary(); - return null; - }, - errorElement:
Failed to fetch clients.
, - }, - { - path: "/about", - element:
About page
, - }, - { - path: `/${Pages.Activities}`, - element: , - loader: async () => { - state.pageAfterLogin = Pages.Activities; - await jumpToLoginPageIfNeccessary(); - return null; - }, - }, - { - path: `/${Pages.Backups}`, - element: , - loader: async () => { - state.pageAfterLogin = Pages.Backups; - await jumpToLoginPageIfNeccessary(); - return null; - }, - errorElement: ( - Backups} /> + path: "/", + element: ( + + + ), children: [ { index: true, - element: , + element: , }, { - path: ":clientId", - element: , + path: `/${Pages.Status}`, + element: , + errorElement:
Failed to fetch clients.
, }, { - path: ":clientId/:backupId", - element: , + path: "/about", + element:
About page
, }, - ], - }, - { - path: `/${Pages.Statistics}`, - element: , - loader: async () => { - state.pageAfterLogin = Pages.Statistics; - await jumpToLoginPageIfNeccessary(); - return null; - }, - }, - { - path: `/${Pages.Logs}`, - element: , - loader: async () => { - state.pageAfterLogin = Pages.Logs; - await jumpToLoginPageIfNeccessary(); - return null; - }, - errorElement: Logs} />, - children: [ { - index: true, - element: , + path: `/${Pages.Activities}`, + lazy: async () => { + const { ActivitiesPage } = await import("./pages/Activities"); + return { Component: ActivitiesPage }; + }, }, { - path: ":logId", - element: , + path: `/${Pages.Backups}`, + lazy: async () => { + const { BackupsPage } = await import("./pages/Backups"); + return { Component: BackupsPage }; + }, + errorElement: ( + Backups} /> + ), + children: [ + { + index: true, + lazy: async () => { + const { BackupsTable } = await import( + "./features/backups/BackupsTable" + ); + return { Component: BackupsTable }; + }, + }, + { + path: ":clientId", + lazy: async () => { + const { ClientBackupsTable } = await import( + "./features/backups/ClientBackupsTable" + ); + return { Component: ClientBackupsTable }; + }, + }, + { + path: ":clientId/:backupId", + lazy: async () => { + const { BackupContentTable } = await import( + "./features/backups/BackupContentTable" + ); + return { Component: BackupContentTable }; + }, + }, + ], }, - ], - }, - { - path: `/${Pages.Settings}`, - element: , - loader: async () => { - state.pageAfterLogin = Pages.Settings; - await jumpToLoginPageIfNeccessary(); - return null; - }, - children: [ { - index: true, - element: , + path: `/${Pages.Statistics}`, + lazy: async () => { + const { StatisticsPage } = await import("./pages/Statistics"); + return { Component: StatisticsPage }; + }, }, { - path: "server", - element: , + path: `/${Pages.Logs}`, + lazy: async () => { + const { LogsPage } = await import("./pages/Logs"); + return { Component: LogsPage }; + }, + errorElement: ( + Logs} /> + ), + children: [ + { + index: true, + lazy: async () => { + const { ClientLogs } = await import("./features/logs/ClientLogs"); + return { Component: ClientLogs }; + }, + }, + { + path: ":logId", + lazy: async () => { + const { ClientLog } = await import("./features/logs/ClientLog"); + return { Component: ClientLog }; + }, + }, + ], + }, + { + path: `/${Pages.Settings}`, + lazy: async () => { + const { SettingsPage } = await import("./pages/Settings"); + return { Component: SettingsPage }; + }, + children: [ + { + index: true, + lazy: async () => { + const { SettingsServer } = await import( + "./features/settings/SettingsServer/SettingsServer" + ); + return { Component: SettingsServer }; + }, + }, + { + path: "server", + lazy: async () => { + const { SettingsServer } = await import( + "./features/settings/SettingsServer/SettingsServer" + ); + return { Component: SettingsServer }; + }, + }, + { + path: "users", + lazy: async () => { + const { SettingsUsers } = await import( + "./features/settings/SettingsUsers/SettingsUsers" + ); + return { Component: SettingsUsers }; + }, + }, + ], }, ], }, ]); -function getSessionFromLocalStorage(): string { - if (!window.localStorage) return ""; - return localStorage.getItem("ses") ?? ""; -} - -export function saveSessionToLocalStorage(session: string) { - if (!window.localStorage) return; - localStorage.setItem("ses", session); -} - const queryClient = new QueryClient(); export async function dynamicActivateTranslation(locale: string) { @@ -228,8 +218,6 @@ export async function dynamicActivateTranslation(locale: string) { const App: React.FunctionComponent = () => { const [selectedTheme, setTheme] = useState(initialTheme); - const snap = useSnapshot(state); - useEffect(() => { window .matchMedia("(prefers-color-scheme: dark)") @@ -241,52 +229,26 @@ const App: React.FunctionComponent = () => { })(); }, []); - const styles = useStackStyles(); - return ( -
-
- -
-
+ - {snap.loggedIn && ( -
- {snap.activePage === Pages.Settings ? ( - // TODO: Move sidebars into RouterProvider via common layout - }> - - - ) : ( - - )} -
- )} -
- }> - - -
+
-
- + } + > + + {/* Following only bundled in development mode */} diff --git a/urbackupserver/www2/src/api/urbackupserver.ts b/urbackupserver/www2/src/api/urbackupserver.ts index ee2a3d286..3a543aa3b 100644 --- a/urbackupserver/www2/src/api/urbackupserver.ts +++ b/urbackupserver/www2/src/api/urbackupserver.ts @@ -1,5 +1,6 @@ import { PBKDF2, MD5, algo } from "crypto-js"; import testoutputProgress from "./TestoutputProgress.json"; +import { formatUserRights } from "../utils/formatUserRights"; interface SaltResult { salt: string; @@ -9,7 +10,7 @@ interface SaltResult { ses: string | undefined; } -interface LoginResult { +export interface LoginResult { upgrading_database: boolean | undefined; curr_db_version: number | undefined; target_db_version: number | undefined; @@ -571,11 +572,11 @@ export enum AddUserResult { function randomString() { - var chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz"; - var string_length = 50; - var randomstring = ''; + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz"; + const string_length = 50; + let randomstring = ''; - var array = new Uint32Array(string_length); + const array = new Uint32Array(string_length); if(window.crypto && window.crypto.getRandomValues(array)) { for (var i=0; i { + saveGeneralSettings = async ( + settings: Partial & { + settings: GeneralSettings['settings'] + }, + ) => { const params : Record = { "sa": "general_save" }; for (const [key, value] of Object.entries(settings.settings)) { if (typeof value == "object") @@ -1100,17 +1106,7 @@ class UrBackupServer { const salt=randomString(); const password_md5=MD5(salt+password).toString(); const params: Record = { sa: "useradd", name: name, pwmd5: password_md5, salt: salt }; - let i = 0; - let idx = ""; - for (const right of rights) { - params[i+"_domain"] = right.domain; - params[i+"_right"] = right.right; - i++; - if(idx.length>0) - idx += ","; - idx += "" + i; - } - params["idx"] = idx; + params['rights'] = formatUserRights(rights) const resp = await this.fetchData(params, "settings"); if (typeof resp.add_ok != "undefined" && resp.add_ok) { return; diff --git a/urbackupserver/www2/src/components/ErrorPage.tsx b/urbackupserver/www2/src/components/ErrorPage.tsx index 3582978ea..764cfff5b 100644 --- a/urbackupserver/www2/src/components/ErrorPage.tsx +++ b/urbackupserver/www2/src/components/ErrorPage.tsx @@ -1,17 +1,28 @@ -import { useRouteError } from "react-router-dom"; +import { + isRouteErrorResponse, + Navigate, + useLocation, + useRouteError, +} from "react-router-dom"; -import { BackupsAccessDeniedError } from "../api/urbackupserver"; +import { + BackupsAccessDeniedError, + SessionNotFoundError, +} from "../api/urbackupserver"; export function ErrorPage({ returnToLink }: { returnToLink: React.ReactNode }) { const error = useRouteError(); + const { pathname } = useLocation(); + + if (error instanceof SessionNotFoundError) { + return ; + } if (error instanceof BackupsAccessDeniedError) { return (

Backups Access Denied

-

- {error.statusText || error.message} -

+

Return to {returnToLink}

); @@ -20,10 +31,26 @@ export function ErrorPage({ returnToLink }: { returnToLink: React.ReactNode }) { return (

Page not found

-

- {error.statusText || error.message} -

+

Return to {returnToLink}

); } + +function ErrorPageContent({ error }: { error: unknown }) { + if (isRouteErrorResponse(error)) { + return ( +

+ {error.statusText} +

+ ); + } + + if (error instanceof Error) { + return ( +

+ {error.message} +

+ ); + } +} diff --git a/urbackupserver/www2/src/components/HeaderBar.tsx b/urbackupserver/www2/src/components/HeaderBar.tsx index 6910027e9..5dccbdd1a 100644 --- a/urbackupserver/www2/src/components/HeaderBar.tsx +++ b/urbackupserver/www2/src/components/HeaderBar.tsx @@ -1,20 +1,16 @@ import logoImage from "../assets/urbackup.png"; -import { useStackStyles } from "./StackStyles"; import { Avatar, Image } from "@fluentui/react-components"; export const HeaderBar = () => { - const styles = useStackStyles(); - return ( -
-
- -
-
UrBackup
-
-
- +
+
+
+ +
+
UrBackup
+
); }; diff --git a/urbackupserver/www2/src/components/Layout.module.css b/urbackupserver/www2/src/components/Layout.module.css new file mode 100644 index 000000000..4e6b0278e --- /dev/null +++ b/urbackupserver/www2/src/components/Layout.module.css @@ -0,0 +1,33 @@ +.layout { + --size-sidebar: 22ch; + + display: flex; + /* display: grid; + grid-template-columns: var(--size-sidebar) 1fr; */ + min-height: 100vh; + overflow: hidden; +} + +.content { + flex-basis: 0; + flex-grow: 999; + + padding-inline: 20pt; + padding-block: 20pt 40pt; + margin-inline: auto; + width: 100%; + overflow: auto; + height: 100%; + max-height: 100vh; +} + +.sidebar { + flex-basis: var(--size-sidebar); + flex-grow: 0; + + background-color: var(--colorNeutralBackground3); + box-shadow: var(--shadow4); + padding: 10pt; + max-height: 100vh; + overflow-y: auto; +} diff --git a/urbackupserver/www2/src/components/Layout.tsx b/urbackupserver/www2/src/components/Layout.tsx new file mode 100644 index 000000000..c26b2d47c --- /dev/null +++ b/urbackupserver/www2/src/components/Layout.tsx @@ -0,0 +1,46 @@ +import { Suspense } from "react"; +import { Spinner } from "@fluentui/react-components"; +import { Outlet, useMatch } from "react-router-dom"; + +import HeaderBar from "./HeaderBar"; +import styles from "./Layout.module.css"; +import NavSidebar from "./NavSidebar"; +import { SettingsNavSidebar } from "../features/settings/SettingsNavSidebar"; + +export function Layout() { + const match = useMatch("/settings/*"); + + return ( +
+ + {match ? ( + }> + + + ) : ( + + )} + + + + +
+ ); +} + +function Sidebar({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function Content({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +Layout.Sidebar = Sidebar; + +Layout.Content = Content; diff --git a/urbackupserver/www2/src/components/NavSidebar.tsx b/urbackupserver/www2/src/components/NavSidebar.tsx index 0d36dcb01..70ad1e4b6 100644 --- a/urbackupserver/www2/src/components/NavSidebar.tsx +++ b/urbackupserver/www2/src/components/NavSidebar.tsx @@ -1,5 +1,5 @@ -import { Pages, router, state } from "../App"; -import { useSnapshot } from "valtio"; +import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { SelectTabData, SelectTabEvent, @@ -7,16 +7,25 @@ import { TabList, } from "@fluentui/react-components"; +import { Pages } from "../App"; + export const NavSidebar = () => { - const snap = useSnapshot(state); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + const [selectedValue, setSelectedValue] = useState(() => + getInitialTab(pathname), + ); const onTabSelect = async (event: SelectTabEvent, data: SelectTabData) => { + setSelectedValue(data.value as Pages); + const nt = `/${data.value}`; - await router.navigate(nt); + await navigate(nt); }; return ( - + Status Activities Backups @@ -28,3 +37,8 @@ export const NavSidebar = () => { }; export default NavSidebar; + +function getInitialTab(pathname: string) { + const page = pathname.split("/").filter((p) => p.length)[0] ?? Pages.Status; + return page; +} diff --git a/urbackupserver/www2/src/components/SelectClientCombobox.tsx b/urbackupserver/www2/src/components/SelectClientCombobox.tsx index 7053262c2..9709fc420 100644 --- a/urbackupserver/www2/src/components/SelectClientCombobox.tsx +++ b/urbackupserver/www2/src/components/SelectClientCombobox.tsx @@ -31,7 +31,7 @@ export function SelectClientCombobox({ const labelId = useId(); return ( -
+
{showLabel && (