diff --git a/package-lock.json b/package-lock.json index 7c50f8a0..9a756979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,10 @@ "@testing-library/user-event": "^13.5.0", "@types/byte-size": "^8.1.0", "@types/uuid": "^9.0.1", - "@vertex-center/components": "^0.7.0", + "@vertex-center/components": "^0.8.0", "axios": "^1.3.4", "byte-size": "^8.1.1", + "event-source-polyfill": "^1.0.31", "immer": "^10.0.3", "javascript-time-ago": "^2.5.9", "react": "^18.2.0", @@ -35,6 +36,7 @@ "yup": "^1.3.2" }, "devDependencies": { + "@types/event-source-polyfill": "^1.0.5", "@types/jest": "^29.4.0", "@types/node": "^18.14.1", "@types/react": "^18.2.33", @@ -1380,6 +1382,12 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" }, + "node_modules/@types/event-source-polyfill": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz", + "integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==", + "dev": true + }, "node_modules/@types/hast": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", @@ -1747,9 +1755,9 @@ } }, "node_modules/@vertex-center/components": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@vertex-center/components/-/components-0.7.0.tgz", - "integrity": "sha512-kqyhUisu0q9tPeC28OO/GcaT636LEe0CgIjkPFDTP0fYGHt5Un0U0ahB/V8qWggRvlmVO/Q+SjDjYEnxiDhC6w==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@vertex-center/components/-/components-0.8.0.tgz", + "integrity": "sha512-N30JzKDTkYEwlweoYHihCQH3Qd7+lryZWGNU9HsoMMZXiypNDkJywKsxRlufY0kXSyu8QVqEyOVbaXuj+bpt7g==", "dependencies": { "classnames": "^2.3.2", "react-syntax-highlighter": "^15.5.0" @@ -2847,6 +2855,11 @@ "node": ">=0.8.0" } }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==" + }, "node_modules/event-stream": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", @@ -6749,6 +6762,12 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" }, + "@types/event-source-polyfill": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz", + "integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==", + "dev": true + }, "@types/hast": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", @@ -7063,9 +7082,9 @@ } }, "@vertex-center/components": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@vertex-center/components/-/components-0.7.0.tgz", - "integrity": "sha512-kqyhUisu0q9tPeC28OO/GcaT636LEe0CgIjkPFDTP0fYGHt5Un0U0ahB/V8qWggRvlmVO/Q+SjDjYEnxiDhC6w==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@vertex-center/components/-/components-0.8.0.tgz", + "integrity": "sha512-N30JzKDTkYEwlweoYHihCQH3Qd7+lryZWGNU9HsoMMZXiypNDkJywKsxRlufY0kXSyu8QVqEyOVbaXuj+bpt7g==", "requires": { "classnames": "^2.3.2", "react-syntax-highlighter": "^15.5.0" @@ -7863,6 +7882,11 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, + "event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==" + }, "event-stream": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", diff --git a/package.json b/package.json index 10f7ef0d..b48ef87a 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "@testing-library/user-event": "^13.5.0", "@types/byte-size": "^8.1.0", "@types/uuid": "^9.0.1", - "@vertex-center/components": "^0.7.0", + "@vertex-center/components": "^0.8.0", "axios": "^1.3.4", "byte-size": "^8.1.1", + "event-source-polyfill": "^1.0.31", "immer": "^10.0.3", "javascript-time-ago": "^2.5.9", "react": "^18.2.0", @@ -56,6 +57,7 @@ ] }, "devDependencies": { + "@types/event-source-polyfill": "^1.0.5", "@types/jest": "^29.4.0", "@types/node": "^18.14.1", "@types/react": "^18.2.33", diff --git a/src/App.tsx b/src/App.tsx index 732fd2f9..0e1654f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,11 @@ -import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import { + HashRouter, + Navigate, + Route, + Routes, + useLocation, + useNavigate, +} from "react-router-dom"; import ContainersApp from "./apps/Containers/pages/ContainersApp/ContainersApp"; import ContainerDetails from "./apps/Containers/pages/Container/Container"; import ContainerLogs from "./apps/Containers/pages/ContainerLogs/ContainerLogs"; @@ -6,7 +13,7 @@ import ContainerEnv from "./apps/Containers/pages/ContainerEnv/ContainerEnv"; import ContainerHome from "./apps/Containers/pages/ContainerHome/ContainerHome"; import SettingsApp from "./apps/Settings/SettingsApp/SettingsApp"; import SettingsTheme from "./apps/Settings/SettingsTheme/SettingsTheme"; -import { useContext } from "react"; +import { Fragment, useContext } from "react"; import { ThemeContext } from "./main"; import classNames from "classnames"; import SettingsAbout from "./apps/Settings/SettingsAbout/SettingsAbout"; @@ -35,11 +42,184 @@ import SqlDatabase from "./apps/Sql/SqlDatabase/SqlDatabase"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import ServiceEditor from "./apps/DevToolsServiceEditor/ServiceEditor/ServiceEditor"; +import Login from "./apps/Login/pages/Login/Login"; import SettingsDb from "./apps/Settings/SettingsData/SettingsDb"; import SettingsChecks from "./apps/Settings/SettingsChecks/SettingsChecks"; +import Register from "./apps/Login/pages/Register/Register"; +import Logout from "./apps/Login/pages/Logout/Logout"; +import { getAuthToken } from "./backend/api/backend"; const queryClient = new QueryClient(); +function AllRoutes() { + const { pathname } = useLocation(); + const navigate = useNavigate(); + + let show = { + header: true, + dock: true, + }; + + if ( + pathname === "/login" || + pathname === "/register" || + pathname === "/logout" + ) { + show = { + header: false, + dock: false, + }; + } else { + const token = getAuthToken(); + if (!token) { + navigate("/login"); + } + } + + return ( + + {show.header &&
} +
+
+
+ + } /> + } /> + } /> + } + index + /> + } + /> + } + /> + } + /> + }> + } + /> + } + /> + + } + > + } + /> + } + /> + } + /> + + }> + } + /> + + } + > + } + /> + + } + > + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + }> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + +
+
+ {show.dock && } + + ); +} + function App() { const { theme } = useContext(ThemeContext); @@ -48,149 +228,7 @@ function App() { -
-
-
-
- - - } - index - /> - } - /> - } - /> - } - /> - }> - } - /> - } - /> - - } - > - } - /> - } - /> - } - /> - - } - > - } - /> - - } - > - } - /> - - } - > - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - > - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - -
-
- +
diff --git a/src/apps/Login/hooks/useAuth.tsx b/src/apps/Login/hooks/useAuth.tsx new file mode 100644 index 00000000..a2973450 --- /dev/null +++ b/src/apps/Login/hooks/useAuth.tsx @@ -0,0 +1,6 @@ +import { getAuthToken } from "../../../backend/api/backend"; + +export default function useAuth(uuid?: string) { + const token = getAuthToken(); + return { token, isLoggedIn: !!token }; +} diff --git a/src/apps/Login/hooks/useLogin.tsx b/src/apps/Login/hooks/useLogin.tsx new file mode 100644 index 00000000..fdc969be --- /dev/null +++ b/src/apps/Login/hooks/useLogin.tsx @@ -0,0 +1,25 @@ +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { api, setAuthToken } from "../../../backend/api/backend"; +import { Credentials } from "../../../models/auth"; + +export const useLogin = ( + options: UseMutationOptions +) => { + const { onSuccess, ...others } = options; + const mutation = useMutation({ + mutationKey: ["auth_login"], + mutationFn: api.auth.login, + onSuccess: (...args) => { + const data: any = args[0]; + setAuthToken(data?.token); + options.onSuccess?.(...args); + }, + ...others, + }); + const { + mutate: login, + isLoading: isLoggingIn, + error: errorLogin, + } = mutation; + return { login, isLoggingIn, errorLogin }; +}; diff --git a/src/apps/Login/hooks/useLogout.tsx b/src/apps/Login/hooks/useLogout.tsx new file mode 100644 index 00000000..8c070a25 --- /dev/null +++ b/src/apps/Login/hooks/useLogout.tsx @@ -0,0 +1,21 @@ +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { api, setAuthToken } from "../../../backend/api/backend"; + +export const useLogout = (options: UseMutationOptions) => { + const { onSuccess, ...others } = options; + const mutation = useMutation({ + mutationKey: ["auth_logout"], + mutationFn: api.auth.logout, + onSuccess: (...args) => { + setAuthToken(undefined); + options.onSuccess?.(...args); + }, + ...others, + }); + const { + mutate: logout, + isLoading: isLoggingOut, + error: errorLogout, + } = mutation; + return { logout, isLoggingOut, errorLogout }; +}; diff --git a/src/apps/Login/hooks/useRegister.tsx b/src/apps/Login/hooks/useRegister.tsx new file mode 100644 index 00000000..9b3a961d --- /dev/null +++ b/src/apps/Login/hooks/useRegister.tsx @@ -0,0 +1,25 @@ +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { api, setAuthToken } from "../../../backend/api/backend"; +import { Credentials } from "../../../models/auth"; + +export const useRegister = ( + options: UseMutationOptions +) => { + const { onSuccess, ...others } = options; + const mutation = useMutation({ + mutationKey: ["auth_register"], + mutationFn: api.auth.register, + onSuccess: (...args) => { + const data: any = args[0]; + setAuthToken(data?.token); + options.onSuccess?.(...args); + }, + ...others, + }); + const { + mutate: register, + isLoading: isRegistering, + error: errorRegister, + } = mutation; + return { register, isRegistering, errorRegister }; +}; diff --git a/src/apps/Login/pages/Login/Login.sass b/src/apps/Login/pages/Login/Login.sass new file mode 100644 index 00000000..9a78d413 --- /dev/null +++ b/src/apps/Login/pages/Login/Login.sass @@ -0,0 +1,26 @@ +@use "../../../../styles/colors" +@use "../../../../styles/dimensions" + +.login + display: flex + flex-direction: column + align-items: center + justify-content: center + position: fixed + top: 0 + left: 0 + right: 0 + bottom: 0 + background-color: colors.$bg-primary + + &-container + padding: 32px + display: flex + flex-direction: column + gap: 32px + z-index: 100 + width: 300px + border-radius: dimensions.$border-radius-lg + + @include dimensions.extra-small-or-less + width: calc(100% - 64px) diff --git a/src/apps/Login/pages/Login/Login.tsx b/src/apps/Login/pages/Login/Login.tsx new file mode 100644 index 00000000..8e30d589 --- /dev/null +++ b/src/apps/Login/pages/Login/Login.tsx @@ -0,0 +1,70 @@ +import "./Login.sass"; +import { + Button, + Horizontal, + Logo, + MaterialIcon, + TextField, + Title, + Vertical, +} from "@vertex-center/components"; +import Spacer from "../../../../components/Spacer/Spacer"; +import { APIError } from "../../../../components/Error/APIError"; +import { useState } from "react"; +import { ProgressOverlay } from "../../../../components/Progress/Progress"; +import { useLogin } from "../../hooks/useLogin"; +import { Link, useNavigate } from "react-router-dom"; + +export default function Login() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const navigate = useNavigate(); + + const { login, isLoggingIn, errorLogin } = useLogin({ + onSuccess: () => navigate("/"), + }); + + const onRegister = () => login({ username, password }); + const onUsernameChange = (e: any) => setUsername(e.target.value); + const onPasswordChange = (e: any) => setPassword(e.target.value); + + return ( +
+
+ + + + Login + + + + + + I don't have an account + + + + + +
+
+ ); +} diff --git a/src/apps/Login/pages/Logout/Logout.tsx b/src/apps/Login/pages/Logout/Logout.tsx new file mode 100644 index 00000000..38f9ac39 --- /dev/null +++ b/src/apps/Login/pages/Logout/Logout.tsx @@ -0,0 +1,31 @@ +import "../Login/Login.sass"; +import { APIError } from "../../../../components/Error/APIError"; +import { useLogout } from "../../hooks/useLogout"; +import { useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { ProgressOverlay } from "../../../../components/Progress/Progress"; + +export default function Logout() { + const navigate = useNavigate(); + + const { logout, isLoggingOut, errorLogout } = useLogout({ + onSuccess: () => { + navigate("/login"); + }, + }); + + useEffect(() => { + logout(); + }, []); + + return ( +
+
+ {isLoggingOut && "Logging out..."} + {errorLogout && "Failed to logout."} + + +
+
+ ); +} diff --git a/src/apps/Login/pages/Register/Register.tsx b/src/apps/Login/pages/Register/Register.tsx new file mode 100644 index 00000000..d84c4665 --- /dev/null +++ b/src/apps/Login/pages/Register/Register.tsx @@ -0,0 +1,70 @@ +import "../Login/Login.sass"; +import { useState } from "react"; +import { useRegister } from "../../hooks/useRegister"; +import { ProgressOverlay } from "../../../../components/Progress/Progress"; +import { + Button, + Horizontal, + Logo, + MaterialIcon, + TextField, + Title, + Vertical, +} from "@vertex-center/components"; +import { APIError } from "../../../../components/Error/APIError"; +import Spacer from "../../../../components/Spacer/Spacer"; +import { Link, useNavigate } from "react-router-dom"; + +export default function Register() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const navigate = useNavigate(); + + const { register, isRegistering, errorRegister } = useRegister({ + onSuccess: () => navigate("/"), + }); + + const onRegister = () => register({ username, password }); + const onUsernameChange = (e: any) => setUsername(e.target.value); + const onPasswordChange = (e: any) => setPassword(e.target.value); + + return ( +
+
+ + + + Register + + + + + + I already have an account + + + + + +
+
+ ); +} diff --git a/src/apps/Settings/SettingsHardware/CPU.tsx b/src/apps/Settings/SettingsHardware/CPU.tsx index 70b98196..638c5e62 100644 --- a/src/apps/Settings/SettingsHardware/CPU.tsx +++ b/src/apps/Settings/SettingsHardware/CPU.tsx @@ -1,6 +1,5 @@ import { CPU as CPUModel } from "../../../models/hardware"; import { - List, ListDescription, ListIcon, ListInfo, @@ -19,18 +18,16 @@ export default function CPU(props: Readonly) { const { model_name, mhz, cores_count } = props.cpu; return ( - - - - - - - {model_name} - - {mhz} MHz - {cores_count} cores - - - - + + + + + + {model_name} + + {mhz} MHz - {cores_count} cores + + + ); } diff --git a/src/apps/Settings/SettingsHardware/SettingsHardware.tsx b/src/apps/Settings/SettingsHardware/SettingsHardware.tsx index 48ead674..895d26cc 100644 --- a/src/apps/Settings/SettingsHardware/SettingsHardware.tsx +++ b/src/apps/Settings/SettingsHardware/SettingsHardware.tsx @@ -1,6 +1,6 @@ import { api } from "../../../backend/api/backend"; import { APIError } from "../../../components/Error/APIError"; -import { Title } from "@vertex-center/components"; +import { List, Title } from "@vertex-center/components"; import { ProgressOverlay } from "../../../components/Progress/Progress"; import { useQuery } from "@tanstack/react-query"; import Content from "../../../components/Content/Content"; @@ -39,9 +39,11 @@ export default function SettingsHardware() { CPUs - {cpus?.map((cpu, i) => ( - - ))} + + {cpus?.map((cpu, i) => ( + + ))} + ); } diff --git a/src/backend/api/backend.ts b/src/backend/api/backend.ts index 0eed5922..c74e6b1b 100644 --- a/src/backend/api/backend.ts +++ b/src/backend/api/backend.ts @@ -11,18 +11,35 @@ import { Console } from "../../logging/logging"; import { Update } from "../../models/update"; import { vxServiceEditorRoutes } from "./vxServiceEditor"; import { CPU, Host } from "../../models/hardware"; +import { Credentials } from "../../models/auth"; export const server = axios.create({ // @ts-ignore baseURL: `${window.apiURL}/api`, }); -// server.interceptors.response.use(async (response) => { -// if (process.env.NODE_ENV === "development") -// await new Promise((resolve) => setTimeout(resolve, 1000)); -// -// return response; -// }); +export function setAuthToken(token?: string) { + if (token === undefined) { + // delete cookie + document.cookie = "vertex_auth_token=;Max-Age=-99999999;path=/"; + return; + } + document.cookie = `vertex_auth_token=${token};path=/`; +} + +export function getAuthToken() { + return document?.cookie + ?.split(";") + ?.find((c) => c.trim().startsWith("vertex_auth_token=")) + ?.replace("vertex_auth_token=", ""); +} + +server.interceptors.request.use(async (config) => { + if (!config.headers.Authorization) { + config.headers.Authorization = `Bearer ${getAuthToken()}`; + } + return config; +}); server.interceptors.request.use((req) => { if (!req) return; @@ -137,4 +154,33 @@ export const api = { patch: (settings: Partial) => server.patch("/admin/settings", settings), }, + + auth: { + login: async (credentials: Credentials) => { + const Authorization = `Basic ${btoa( + credentials.username + ":" + credentials.password + )}`; + const { data } = await server.post( + "/app/vx-auth/auth/login", + {}, + { headers: { Authorization } } + ); + return data; + }, + register: async (credentials: Credentials) => { + const Authorization = `Basic ${btoa( + credentials.username + ":" + credentials.password + )}`; + const { data } = await server.post( + "/app/vx-auth/auth/register", + {}, + { headers: { Authorization } } + ); + return data; + }, + logout: async () => { + const { data } = await server.post("/app/vx-auth/auth/logout"); + return data; + }, + }, }; diff --git a/src/backend/sse.ts b/src/backend/sse.ts index 5d152b43..861c3518 100644 --- a/src/backend/sse.ts +++ b/src/backend/sse.ts @@ -1,7 +1,9 @@ import { v4 as uuidv4 } from "uuid"; +import { EventSourcePolyfill } from "event-source-polyfill"; +import { getAuthToken } from "./api/backend"; type SSE = { - eventSource: EventSource; + eventSource: EventSourcePolyfill; url: string; watchers: number; }; @@ -18,7 +20,11 @@ export function registerSSE(url: string): string { uuid = uuidv4(); // @ts-ignore - const eventSource = new EventSource(`${window.apiURL}/api${url}`); + const eventSource = new EventSourcePolyfill(`${window.apiURL}/api${url}`, { + headers: { + Authorization: `Bearer ${getAuthToken()}`, + }, + }); allSSE[uuid] = { url, eventSource, watchers: 1 }; return uuid; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 0663968e..d44244ac 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -2,9 +2,17 @@ import { Link, LinkProps as RouterLinkProps, useLocation, + useNavigate, } from "react-router-dom"; import { useApps } from "../../hooks/useApps"; -import { Header, LinkProps } from "@vertex-center/components"; +import { + DropdownItem, + Header, + HeaderItem, + LinkProps, + ProfilePicture, +} from "@vertex-center/components"; +import useAuth from "../../apps/Login/hooks/useAuth"; type Props = { title?: string; @@ -14,7 +22,9 @@ type Props = { export default function (props: Readonly) { const { onClick } = props; const { apps } = useApps(); + const { isLoggedIn } = useAuth(); + const navigate = useNavigate(); const location = useLocation(); let to = "/app/vx-containers"; @@ -32,5 +42,33 @@ export default function (props: Readonly) { to, }; - return
; + let accountItems; + if (isLoggedIn) { + accountItems = ( + navigate("/logout")}> + Logout + + ); + } else { + accountItems = ( + navigate("/login")}> + Login + + ); + } + + const account = ( + + + + ); + + return ( +
+ ); } diff --git a/src/models/auth.ts b/src/models/auth.ts new file mode 100644 index 00000000..08369d2a --- /dev/null +++ b/src/models/auth.ts @@ -0,0 +1,4 @@ +export type Credentials = { + username: string; + password: string; +};