Explorar o código

Log in as user support

Jamie Curnow hai 1 mes
pai
achega
82a1a86c3a

+ 1 - 0
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";

+ 8 - 0
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<LoginAsTokenResponse> {
+	return await api.post({
+		url: `/users/${id}/login`,
+	});
+}

+ 5 - 1
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<string, any>;
 	certificateKey: boolean;
 }
+
+export interface LoginAsTokenResponse extends TokenResponse {
+	user: User;
+}

+ 13 - 13
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() {
 					<span className="navbar-toggler-icon" />
 				</button>
 				<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
-                    <NavLink to="/">
-                        <div className={styles.logo}>
-                            <img
-                                src="/images/logo-no-text.svg"
-                                width={40}
-                                height={40}
-                                className="navbar-brand-image"
-                                alt="Logo"
-                            />
-                        </div>
-                        Nginx Proxy Manager
-                    </NavLink>
+					<NavLink to="/">
+						<div className={styles.logo}>
+							<img
+								src="/images/logo-no-text.svg"
+								width={40}
+								height={40}
+								className="navbar-brand-image"
+								alt="Logo"
+							/>
+						</div>
+						Nginx Proxy Manager
+					</NavLink>
 				</div>
 				<div className="navbar-nav flex-row order-md-last">
 					<div className="d-none d-md-flex">

+ 16 - 2
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<void>;
+	loginAs: (id: number) => Promise<void>;
 	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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
 }

+ 1 - 0
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",

+ 3 - 0
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"
 	},

+ 9 - 3
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();

+ 97 - 85
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 = () => {
 			<div className="row row-deck row-cards">
 				<div className="col-12 my-4">
 					<div className="row row-cards">
-						<div className="col-sm-6 col-lg-3">
-							<a
-								href="/nginx/proxy"
-								className="card card-sm card-link card-link-pop"
-								onClick={(e) => {
-									e.preventDefault();
-									navigate("/nginx/proxy");
-								}}
-							>
-								<div className="card-body">
-									<div className="row align-items-center">
-										<div className="col-auto">
-											<span className="bg-green text-white avatar">
-												<IconBolt />
-											</span>
-										</div>
-										<div className="col">
-											<div className="font-weight-medium">
-												<T id="proxy-hosts.count" data={{ count: hostReport?.proxy }} />
+						<HasPermission permission="proxyHosts" type="view" hideError>
+							<div className="col-sm-6 col-lg-3">
+								<a
+									href="/nginx/proxy"
+									className="card card-sm card-link card-link-pop"
+									onClick={(e) => {
+										e.preventDefault();
+										navigate("/nginx/proxy");
+									}}
+								>
+									<div className="card-body">
+										<div className="row align-items-center">
+											<div className="col-auto">
+												<span className="bg-green text-white avatar">
+													<IconBolt />
+												</span>
+											</div>
+											<div className="col">
+												<div className="font-weight-medium">
+													<T id="proxy-hosts.count" data={{ count: hostReport?.proxy }} />
+												</div>
 											</div>
 										</div>
 									</div>
-								</div>
-							</a>
-						</div>
-						<div className="col-sm-6 col-lg-3">
-							<a
-								href="/nginx/redirection"
-								className="card card-sm card-link card-link-pop"
-								onClick={(e) => {
-									e.preventDefault();
-									navigate("/nginx/redirection");
-								}}
-							>
-								<div className="card-body">
-									<div className="row align-items-center">
-										<div className="col-auto">
-											<span className="bg-yellow text-white avatar">
-												<IconArrowsCross />
-											</span>
-										</div>
-										<div className="col">
-											<T id="redirection-hosts.count" data={{ count: hostReport?.redirection }} />
+								</a>
+							</div>
+						</HasPermission>
+						<HasPermission permission="redirectionHosts" type="view" hideError>
+							<div className="col-sm-6 col-lg-3">
+								<a
+									href="/nginx/redirection"
+									className="card card-sm card-link card-link-pop"
+									onClick={(e) => {
+										e.preventDefault();
+										navigate("/nginx/redirection");
+									}}
+								>
+									<div className="card-body">
+										<div className="row align-items-center">
+											<div className="col-auto">
+												<span className="bg-yellow text-white avatar">
+													<IconArrowsCross />
+												</span>
+											</div>
+											<div className="col">
+												<T
+													id="redirection-hosts.count"
+													data={{ count: hostReport?.redirection }}
+												/>
+											</div>
 										</div>
 									</div>
-								</div>
-							</a>
-						</div>
-						<div className="col-sm-6 col-lg-3">
-							<a
-								href="/nginx/stream"
-								className="card card-sm card-link card-link-pop"
-								onClick={(e) => {
-									e.preventDefault();
-									navigate("/nginx/stream");
-								}}
-							>
-								<div className="card-body">
-									<div className="row align-items-center">
-										<div className="col-auto">
-											<span className="bg-blue text-white avatar">
-												<IconDisc />
-											</span>
-										</div>
-										<div className="col">
-											<T id="streams.count" data={{ count: hostReport?.stream }} />
+								</a>
+							</div>
+						</HasPermission>
+						<HasPermission permission="streams" type="view" hideError>
+							<div className="col-sm-6 col-lg-3">
+								<a
+									href="/nginx/stream"
+									className="card card-sm card-link card-link-pop"
+									onClick={(e) => {
+										e.preventDefault();
+										navigate("/nginx/stream");
+									}}
+								>
+									<div className="card-body">
+										<div className="row align-items-center">
+											<div className="col-auto">
+												<span className="bg-blue text-white avatar">
+													<IconDisc />
+												</span>
+											</div>
+											<div className="col">
+												<T id="streams.count" data={{ count: hostReport?.stream }} />
+											</div>
 										</div>
 									</div>
-								</div>
-							</a>
-						</div>
-						<div className="col-sm-6 col-lg-3">
-							<a
-								href="/nginx/404"
-								className="card card-sm card-link card-link-pop"
-								onClick={(e) => {
-									e.preventDefault();
-									navigate("/nginx/404");
-								}}
-							>
-								<div className="card-body">
-									<div className="row align-items-center">
-										<div className="col-auto">
-											<span className="bg-red text-white avatar">
-												<IconBoltOff />
-											</span>
-										</div>
-										<div className="col">
-											<T id="dead-hosts.count" data={{ count: hostReport?.dead }} />
+								</a>
+							</div>
+						</HasPermission>
+						<HasPermission permission="deadHosts" type="view" hideError>
+							<div className="col-sm-6 col-lg-3">
+								<a
+									href="/nginx/404"
+									className="card card-sm card-link card-link-pop"
+									onClick={(e) => {
+										e.preventDefault();
+										navigate("/nginx/404");
+									}}
+								>
+									<div className="card-body">
+										<div className="row align-items-center">
+											<div className="col-auto">
+												<span className="bg-red text-white avatar">
+													<IconBoltOff />
+												</span>
+											</div>
+											<div className="col">
+												<T id="dead-hosts.count" data={{ count: hostReport?.dead }} />
+											</div>
 										</div>
 									</div>
-								</div>
-							</a>
-						</div>
+								</a>
+							</div>
+						</HasPermission>
 					</div>
 				</div>
 			</div>

+ 39 - 2
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<User>();
 	const columns = useMemo(
@@ -153,6 +163,24 @@ export default function Table({
 											<IconPower size={16} />
 											<T id={info.row.original.isDisabled ? "action.enable" : "action.disable"} />
 										</a>
+										{info.row.original.isDisabled ? (
+											<div className="dropdown-item text-muted">
+												<IconLogin2 size={16} />
+												<T id="user.login-as" data={{ name: info.row.original.name }} />
+											</div>
+										) : (
+											<a
+												className="dropdown-item"
+												href="#"
+												onClick={(e) => {
+													e.preventDefault();
+													onLoginAs?.(info.row.original.id);
+												}}
+											>
+												<IconLogin2 size={16} />
+												<T id="user.login-as" data={{ name: info.row.original.name }} />
+											</a>
+										)}
 										<div className="dropdown-divider" />
 										<a
 											className="dropdown-item"
@@ -176,7 +204,16 @@ export default function Table({
 				},
 			}),
 		],
-		[columnHelper, currentUserId, onEditUser, onDisableToggle, onDeleteUser, onEditPermissions, onSetPassword],
+		[
+			columnHelper,
+			currentUserId,
+			onEditUser,
+			onDisableToggle,
+			onDeleteUser,
+			onEditPermissions,
+			onSetPassword,
+			onLoginAs,
+		],
 	);
 
 	const tableInstance = useReactTable<User>({

+ 14 - 1
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 <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
+	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}
 				/>
 			</div>
 		</div>