diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 9fb6b13b..6d42a6f4 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -37,6 +37,7 @@ export * from "./getToken"; export * from "./getUser"; export * from "./getUsers"; export * from "./helpers"; +export * from "./loginAsUser"; export * from "./models"; export * from "./refreshToken"; export * from "./renewCertificate"; diff --git a/frontend/src/api/backend/loginAsUser.ts b/frontend/src/api/backend/loginAsUser.ts new file mode 100644 index 00000000..2ade379d --- /dev/null +++ b/frontend/src/api/backend/loginAsUser.ts @@ -0,0 +1,8 @@ +import * as api from "./base"; +import type { LoginAsTokenResponse } from "./responseTypes"; + +export async function loginAsUser(id: number): Promise { + return await api.post({ + url: `/users/${id}/login`, + }); +} diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 6eb627f8..7169fc54 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -1,4 +1,4 @@ -import type { AppVersion } from "./models"; +import type { AppVersion, User } from "./models"; export interface HealthResponse { status: string; @@ -15,3 +15,7 @@ export interface ValidatedCertificateResponse { certificate: Record; certificateKey: boolean; } + +export interface LoginAsTokenResponse extends TokenResponse { + user: User; +} diff --git a/frontend/src/components/SiteHeader.tsx b/frontend/src/components/SiteHeader.tsx index 0ecbc03c..07d52f98 100644 --- a/frontend/src/components/SiteHeader.tsx +++ b/frontend/src/components/SiteHeader.tsx @@ -1,5 +1,5 @@ import { IconLock, IconLogout, IconUser } from "@tabler/icons-react"; -import { LocalePicker, ThemeSwitcher, NavLink } from "src/components"; +import { LocalePicker, NavLink, ThemeSwitcher } from "src/components"; import { useAuthState } from "src/context"; import { useUser } from "src/hooks"; import { T } from "src/locale"; @@ -26,18 +26,18 @@ export function SiteHeader() {
- -
- Logo -
- Nginx Proxy Manager -
+ +
+ Logo +
+ Nginx Proxy Manager +
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index d26d5713..a0284f60 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,13 +1,14 @@ import { useQueryClient } from "@tanstack/react-query"; import { createContext, type ReactNode, useContext, useState } from "react"; import { useIntervalWhen } from "rooks"; -import { getToken, refreshToken, type TokenResponse } from "src/api/backend"; +import { getToken, loginAsUser, refreshToken, type TokenResponse } from "src/api/backend"; import AuthStore from "src/modules/AuthStore"; // Context export interface AuthContextType { authenticated: boolean; login: (username: string, password: string) => Promise; + loginAs: (id: number) => Promise; logout: () => void; token?: string; } @@ -34,7 +35,20 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) handleTokenUpdate(response); }; + const loginAs = async (id: number) => { + const response = await loginAsUser(id); + AuthStore.add(response); + queryClient.clear(); + window.location.reload(); + }; + const logout = () => { + if (AuthStore.count() >= 2) { + AuthStore.drop(); + queryClient.clear(); + window.location.reload(); + return; + } AuthStore.clear(); setAuthenticated(false); queryClient.clear(); @@ -55,7 +69,7 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) true, ); - const value = { authenticated, login, logout }; + const value = { authenticated, login, logout, loginAs }; return {children}; } diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index a428fbbc..0c67c8d5 100644 --- a/frontend/src/locale/lang/en.json +++ b/frontend/src/locale/lang/en.json @@ -201,6 +201,7 @@ "user.current-password": "Current Password", "user.edit-profile": "Edit Profile", "user.full-name": "Full Name", + "user.login-as": "Sign in as {name}", "user.logout": "Logout", "user.new-password": "New Password", "user.nickname": "Nickname", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index d7ad25d6..57b5ac6d 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -605,6 +605,9 @@ "user.full-name": { "defaultMessage": "Full Name" }, + "user.login-as": { + "defaultMessage": "Sign in as {name}" + }, "user.logout": { "defaultMessage": "Logout" }, diff --git a/frontend/src/modules/AuthStore.ts b/frontend/src/modules/AuthStore.ts index 90cee8e4..9978aaa2 100644 --- a/frontend/src/modules/AuthStore.ts +++ b/frontend/src/modules/AuthStore.ts @@ -44,6 +44,7 @@ export class AuthStore { // const t = this.tokens; // return t.length > 0; // } + // Start from the END of the stack and work backwards hasActiveToken() { const t = this.tokens; if (!t.length) { @@ -68,22 +69,27 @@ export class AuthStore { localStorage.setItem(TOKEN_KEY, JSON.stringify([{ token, expires }])); } - // Add a token to the stack + // Add a token to the END of the stack add({ token, expires }: TokenResponse) { const t = this.tokens; t.push({ token, expires }); localStorage.setItem(TOKEN_KEY, JSON.stringify(t)); } - // Drop a token from the stack + // Drop a token from the END of the stack drop() { const t = this.tokens; - localStorage.setItem(TOKEN_KEY, JSON.stringify(t.splice(-1, 1))); + t.splice(-1, 1); + localStorage.setItem(TOKEN_KEY, JSON.stringify(t)); } clear() { localStorage.removeItem(TOKEN_KEY); } + + count() { + return this.tokens.length; + } } export default new AuthStore(); diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index ed161666..1abee7ba 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -1,5 +1,6 @@ import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react"; import { useNavigate } from "react-router-dom"; +import { HasPermission } from "src/components"; import { useHostReport } from "src/hooks"; import { T } from "src/locale"; @@ -15,100 +16,111 @@ const Dashboard = () => {
-
- { - e.preventDefault(); - navigate("/nginx/proxy"); - }} - > -
-
-
- - - -
-
- -
- { - e.preventDefault(); - navigate("/nginx/redirection"); - }} - > -
- - diff --git a/frontend/src/pages/Users/Table.tsx b/frontend/src/pages/Users/Table.tsx index 1b640765..d07fc9bc 100644 --- a/frontend/src/pages/Users/Table.tsx +++ b/frontend/src/pages/Users/Table.tsx @@ -1,4 +1,12 @@ -import { IconDotsVertical, IconEdit, IconLock, IconPower, IconShield, IconTrash } from "@tabler/icons-react"; +import { + IconDotsVertical, + IconEdit, + IconLock, + IconLogin2, + IconPower, + IconShield, + IconTrash, +} from "@tabler/icons-react"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { useMemo } from "react"; import type { User } from "src/api/backend"; @@ -24,6 +32,7 @@ interface Props { onDeleteUser?: (id: number) => void; onDisableToggle?: (id: number, enabled: boolean) => void; onNewUser?: () => void; + onLoginAs?: (id: number) => void; } export default function Table({ data, @@ -36,6 +45,7 @@ export default function Table({ onDeleteUser, onDisableToggle, onNewUser, + onLoginAs, }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( @@ -153,6 +163,24 @@ export default function Table({ + {info.row.original.isDisabled ? ( +
+ + +
+ ) : ( + { + e.preventDefault(); + onLoginAs?.(info.row.original.id); + }} + > + + + + )}
({ diff --git a/frontend/src/pages/Users/TableWrapper.tsx b/frontend/src/pages/Users/TableWrapper.tsx index b4ed16f3..461ed6ab 100644 --- a/frontend/src/pages/Users/TableWrapper.tsx +++ b/frontend/src/pages/Users/TableWrapper.tsx @@ -4,14 +4,16 @@ import { useState } from "react"; import Alert from "react-bootstrap/Alert"; import { deleteUser, toggleUser } from "src/api/backend"; import { Button, LoadingPage } from "src/components"; +import { useAuthState } from "src/context"; import { useUser, useUsers } from "src/hooks"; import { T } from "src/locale"; import { showDeleteConfirmModal, showPermissionsModal, showSetPasswordModal, showUserModal } from "src/modals"; -import { showObjectSuccess } from "src/notifications"; +import { showError, showObjectSuccess } from "src/notifications"; import Table from "./Table"; export default function TableWrapper() { const queryClient = useQueryClient(); + const { loginAs } = useAuthState(); const [search, setSearch] = useState(""); const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]); const { data: currentUser } = useUser("me"); @@ -24,6 +26,16 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } + const handleLoginAs = async (id: number) => { + try { + await loginAs(id); + } catch (err) { + if (err instanceof Error) { + showError(err.message); + } + } + }; + const handleDelete = async (id: number) => { await deleteUser(id); showObjectSuccess("user", "deleted"); @@ -103,6 +115,7 @@ export default function TableWrapper() { } onDisableToggle={handleDisableToggle} onNewUser={() => showUserModal("new")} + onLoginAs={handleLoginAs} />