1
0
mirror of https://github.com/NginxProxyManager/nginx-proxy-manager.git synced 2025-11-02 16:53:15 +03:00

Log in as user support

This commit is contained in:
Jamie Curnow
2025-10-29 21:07:00 +10:00
parent 95957a192c
commit 82a1a86c3a
11 changed files with 206 additions and 107 deletions

View File

@@ -37,6 +37,7 @@ export * from "./getToken";
export * from "./getUser"; export * from "./getUser";
export * from "./getUsers"; export * from "./getUsers";
export * from "./helpers"; export * from "./helpers";
export * from "./loginAsUser";
export * from "./models"; export * from "./models";
export * from "./refreshToken"; export * from "./refreshToken";
export * from "./renewCertificate"; export * from "./renewCertificate";

View File

@@ -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`,
});
}

View File

@@ -1,4 +1,4 @@
import type { AppVersion } from "./models"; import type { AppVersion, User } from "./models";
export interface HealthResponse { export interface HealthResponse {
status: string; status: string;
@@ -15,3 +15,7 @@ export interface ValidatedCertificateResponse {
certificate: Record<string, any>; certificate: Record<string, any>;
certificateKey: boolean; certificateKey: boolean;
} }
export interface LoginAsTokenResponse extends TokenResponse {
user: User;
}

View File

@@ -1,5 +1,5 @@
import { IconLock, IconLogout, IconUser } from "@tabler/icons-react"; 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 { useAuthState } from "src/context";
import { useUser } from "src/hooks"; import { useUser } from "src/hooks";
import { T } from "src/locale"; import { T } from "src/locale";
@@ -26,18 +26,18 @@ export function SiteHeader() {
<span className="navbar-toggler-icon" /> <span className="navbar-toggler-icon" />
</button> </button>
<div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3"> <div className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<NavLink to="/"> <NavLink to="/">
<div className={styles.logo}> <div className={styles.logo}>
<img <img
src="/images/logo-no-text.svg" src="/images/logo-no-text.svg"
width={40} width={40}
height={40} height={40}
className="navbar-brand-image" className="navbar-brand-image"
alt="Logo" alt="Logo"
/> />
</div> </div>
Nginx Proxy Manager Nginx Proxy Manager
</NavLink> </NavLink>
</div> </div>
<div className="navbar-nav flex-row order-md-last"> <div className="navbar-nav flex-row order-md-last">
<div className="d-none d-md-flex"> <div className="d-none d-md-flex">

View File

@@ -1,13 +1,14 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useContext, useState } from "react"; import { createContext, type ReactNode, useContext, useState } from "react";
import { useIntervalWhen } from "rooks"; 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"; import AuthStore from "src/modules/AuthStore";
// Context // Context
export interface AuthContextType { export interface AuthContextType {
authenticated: boolean; authenticated: boolean;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
loginAs: (id: number) => Promise<void>;
logout: () => void; logout: () => void;
token?: string; token?: string;
} }
@@ -34,7 +35,20 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
handleTokenUpdate(response); handleTokenUpdate(response);
}; };
const loginAs = async (id: number) => {
const response = await loginAsUser(id);
AuthStore.add(response);
queryClient.clear();
window.location.reload();
};
const logout = () => { const logout = () => {
if (AuthStore.count() >= 2) {
AuthStore.drop();
queryClient.clear();
window.location.reload();
return;
}
AuthStore.clear(); AuthStore.clear();
setAuthenticated(false); setAuthenticated(false);
queryClient.clear(); queryClient.clear();
@@ -55,7 +69,7 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
true, true,
); );
const value = { authenticated, login, logout }; const value = { authenticated, login, logout, loginAs };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }

View File

@@ -201,6 +201,7 @@
"user.current-password": "Current Password", "user.current-password": "Current Password",
"user.edit-profile": "Edit Profile", "user.edit-profile": "Edit Profile",
"user.full-name": "Full Name", "user.full-name": "Full Name",
"user.login-as": "Sign in as {name}",
"user.logout": "Logout", "user.logout": "Logout",
"user.new-password": "New Password", "user.new-password": "New Password",
"user.nickname": "Nickname", "user.nickname": "Nickname",

View File

@@ -605,6 +605,9 @@
"user.full-name": { "user.full-name": {
"defaultMessage": "Full Name" "defaultMessage": "Full Name"
}, },
"user.login-as": {
"defaultMessage": "Sign in as {name}"
},
"user.logout": { "user.logout": {
"defaultMessage": "Logout" "defaultMessage": "Logout"
}, },

View File

@@ -44,6 +44,7 @@ export class AuthStore {
// const t = this.tokens; // const t = this.tokens;
// return t.length > 0; // return t.length > 0;
// } // }
// Start from the END of the stack and work backwards
hasActiveToken() { hasActiveToken() {
const t = this.tokens; const t = this.tokens;
if (!t.length) { if (!t.length) {
@@ -68,22 +69,27 @@ export class AuthStore {
localStorage.setItem(TOKEN_KEY, JSON.stringify([{ token, expires }])); 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) { add({ token, expires }: TokenResponse) {
const t = this.tokens; const t = this.tokens;
t.push({ token, expires }); t.push({ token, expires });
localStorage.setItem(TOKEN_KEY, JSON.stringify(t)); localStorage.setItem(TOKEN_KEY, JSON.stringify(t));
} }
// Drop a token from the stack // Drop a token from the END of the stack
drop() { drop() {
const t = this.tokens; 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() { clear() {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
} }
count() {
return this.tokens.length;
}
} }
export default new AuthStore(); export default new AuthStore();

View File

@@ -1,5 +1,6 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react"; import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { HasPermission } from "src/components";
import { useHostReport } from "src/hooks"; import { useHostReport } from "src/hooks";
import { T } from "src/locale"; import { T } from "src/locale";
@@ -15,100 +16,111 @@ const Dashboard = () => {
<div className="row row-deck row-cards"> <div className="row row-deck row-cards">
<div className="col-12 my-4"> <div className="col-12 my-4">
<div className="row row-cards"> <div className="row row-cards">
<div className="col-sm-6 col-lg-3"> <HasPermission permission="proxyHosts" type="view" hideError>
<a <div className="col-sm-6 col-lg-3">
href="/nginx/proxy" <a
className="card card-sm card-link card-link-pop" href="/nginx/proxy"
onClick={(e) => { className="card card-sm card-link card-link-pop"
e.preventDefault(); onClick={(e) => {
navigate("/nginx/proxy"); e.preventDefault();
}} navigate("/nginx/proxy");
> }}
<div className="card-body"> >
<div className="row align-items-center"> <div className="card-body">
<div className="col-auto"> <div className="row align-items-center">
<span className="bg-green text-white avatar"> <div className="col-auto">
<IconBolt /> <span className="bg-green text-white avatar">
</span> <IconBolt />
</div> </span>
<div className="col"> </div>
<div className="font-weight-medium"> <div className="col">
<T id="proxy-hosts.count" data={{ count: hostReport?.proxy }} /> <div className="font-weight-medium">
<T id="proxy-hosts.count" data={{ count: hostReport?.proxy }} />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </a>
</a> </div>
</div> </HasPermission>
<div className="col-sm-6 col-lg-3"> <HasPermission permission="redirectionHosts" type="view" hideError>
<a <div className="col-sm-6 col-lg-3">
href="/nginx/redirection" <a
className="card card-sm card-link card-link-pop" href="/nginx/redirection"
onClick={(e) => { className="card card-sm card-link card-link-pop"
e.preventDefault(); onClick={(e) => {
navigate("/nginx/redirection"); e.preventDefault();
}} navigate("/nginx/redirection");
> }}
<div className="card-body"> >
<div className="row align-items-center"> <div className="card-body">
<div className="col-auto"> <div className="row align-items-center">
<span className="bg-yellow text-white avatar"> <div className="col-auto">
<IconArrowsCross /> <span className="bg-yellow text-white avatar">
</span> <IconArrowsCross />
</div> </span>
<div className="col"> </div>
<T id="redirection-hosts.count" data={{ count: hostReport?.redirection }} /> <div className="col">
<T
id="redirection-hosts.count"
data={{ count: hostReport?.redirection }}
/>
</div>
</div> </div>
</div> </div>
</div> </a>
</a> </div>
</div> </HasPermission>
<div className="col-sm-6 col-lg-3"> <HasPermission permission="streams" type="view" hideError>
<a <div className="col-sm-6 col-lg-3">
href="/nginx/stream" <a
className="card card-sm card-link card-link-pop" href="/nginx/stream"
onClick={(e) => { className="card card-sm card-link card-link-pop"
e.preventDefault(); onClick={(e) => {
navigate("/nginx/stream"); e.preventDefault();
}} navigate("/nginx/stream");
> }}
<div className="card-body"> >
<div className="row align-items-center"> <div className="card-body">
<div className="col-auto"> <div className="row align-items-center">
<span className="bg-blue text-white avatar"> <div className="col-auto">
<IconDisc /> <span className="bg-blue text-white avatar">
</span> <IconDisc />
</div> </span>
<div className="col"> </div>
<T id="streams.count" data={{ count: hostReport?.stream }} /> <div className="col">
<T id="streams.count" data={{ count: hostReport?.stream }} />
</div>
</div> </div>
</div> </div>
</div> </a>
</a> </div>
</div> </HasPermission>
<div className="col-sm-6 col-lg-3"> <HasPermission permission="deadHosts" type="view" hideError>
<a <div className="col-sm-6 col-lg-3">
href="/nginx/404" <a
className="card card-sm card-link card-link-pop" href="/nginx/404"
onClick={(e) => { className="card card-sm card-link card-link-pop"
e.preventDefault(); onClick={(e) => {
navigate("/nginx/404"); e.preventDefault();
}} navigate("/nginx/404");
> }}
<div className="card-body"> >
<div className="row align-items-center"> <div className="card-body">
<div className="col-auto"> <div className="row align-items-center">
<span className="bg-red text-white avatar"> <div className="col-auto">
<IconBoltOff /> <span className="bg-red text-white avatar">
</span> <IconBoltOff />
</div> </span>
<div className="col"> </div>
<T id="dead-hosts.count" data={{ count: hostReport?.dead }} /> <div className="col">
<T id="dead-hosts.count" data={{ count: hostReport?.dead }} />
</div>
</div> </div>
</div> </div>
</div> </a>
</a> </div>
</div> </HasPermission>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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 { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import type { User } from "src/api/backend"; import type { User } from "src/api/backend";
@@ -24,6 +32,7 @@ interface Props {
onDeleteUser?: (id: number) => void; onDeleteUser?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void; onDisableToggle?: (id: number, enabled: boolean) => void;
onNewUser?: () => void; onNewUser?: () => void;
onLoginAs?: (id: number) => void;
} }
export default function Table({ export default function Table({
data, data,
@@ -36,6 +45,7 @@ export default function Table({
onDeleteUser, onDeleteUser,
onDisableToggle, onDisableToggle,
onNewUser, onNewUser,
onLoginAs,
}: Props) { }: Props) {
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
const columns = useMemo( const columns = useMemo(
@@ -153,6 +163,24 @@ export default function Table({
<IconPower size={16} /> <IconPower size={16} />
<T id={info.row.original.isDisabled ? "action.enable" : "action.disable"} /> <T id={info.row.original.isDisabled ? "action.enable" : "action.disable"} />
</a> </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" /> <div className="dropdown-divider" />
<a <a
className="dropdown-item" 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>({ const tableInstance = useReactTable<User>({

View File

@@ -4,14 +4,16 @@ import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteUser, toggleUser } from "src/api/backend"; import { deleteUser, toggleUser } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useAuthState } from "src/context";
import { useUser, useUsers } from "src/hooks"; import { useUser, useUsers } from "src/hooks";
import { T } from "src/locale"; import { T } from "src/locale";
import { showDeleteConfirmModal, showPermissionsModal, showSetPasswordModal, showUserModal } from "src/modals"; import { showDeleteConfirmModal, showPermissionsModal, showSetPasswordModal, showUserModal } from "src/modals";
import { showObjectSuccess } from "src/notifications"; import { showError, showObjectSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { loginAs } = useAuthState();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]); const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]);
const { data: currentUser } = useUser("me"); const { data: currentUser } = useUser("me");
@@ -24,6 +26,16 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; 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) => { const handleDelete = async (id: number) => {
await deleteUser(id); await deleteUser(id);
showObjectSuccess("user", "deleted"); showObjectSuccess("user", "deleted");
@@ -103,6 +115,7 @@ export default function TableWrapper() {
} }
onDisableToggle={handleDisableToggle} onDisableToggle={handleDisableToggle}
onNewUser={() => showUserModal("new")} onNewUser={() => showUserModal("new")}
onLoginAs={handleLoginAs}
/> />
</div> </div>
</div> </div>