Updated authentication token format.
This commit is contained in:
parent
5d602bd56f
commit
0bb1303413
12 changed files with 337 additions and 64 deletions
53
assets/css/misuzu/impersonate.css
Normal file
53
assets/css/misuzu/impersonate.css
Normal file
|
@ -0,0 +1,53 @@
|
|||
.impersonate {
|
||||
--start-colour: var(--accent-colour);
|
||||
--end-colour: var(--background-colour);
|
||||
background-image: repeating-linear-gradient(-45deg, var(--start-colour), var(--start-colour) 10px, var(--end-colour) 10px, var(--end-colour) 20px);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.impersonate-content {
|
||||
max-width: var(--site-max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.impersonate-user {
|
||||
padding: 4px 10px;
|
||||
background-color: #222d;
|
||||
}
|
||||
.impersonate-user-link {
|
||||
color: var(--user-colour);
|
||||
text-decoration: none;
|
||||
}
|
||||
.impersonate-user-link:hover,
|
||||
.impersonate-user-link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.impersonate-user-avatar {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.impersonate-options {
|
||||
display: flex;
|
||||
}
|
||||
.impersonate-options-link {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 29px;
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
background-color: #222d;
|
||||
display: block;
|
||||
color: var(--text-colour);
|
||||
text-decoration: none;
|
||||
transition: background-color .1s;
|
||||
}
|
||||
.impersonate-options-link:focus,
|
||||
.impersonate-options-link:hover {
|
||||
background-color: #555d;
|
||||
}
|
||||
.impersonate-options-link:active {
|
||||
background-color: #333d;
|
||||
}
|
42
misuzu.php
42
misuzu.php
|
@ -161,24 +161,27 @@ Template::set('globals', [
|
|||
|
||||
Template::addPath(MSZ_TEMPLATES);
|
||||
|
||||
AuthToken::setSecretKey($cfg->getValue('auth.secret', IConfig::T_STR, 'meow'));
|
||||
|
||||
if(isset($_COOKIE['msz_uid']) && isset($_COOKIE['msz_sid'])) {
|
||||
$authToken = (new AuthToken)
|
||||
->setUserId(filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT) ?? 0)
|
||||
->setSessionToken(filter_input(INPUT_COOKIE, 'msz_sid') ?? '');
|
||||
$authToken = new AuthToken;
|
||||
$authToken->setUserId(filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT) ?? 0);
|
||||
$authToken->setSessionToken(filter_input(INPUT_COOKIE, 'msz_sid') ?? '');
|
||||
|
||||
if($authToken->isValid())
|
||||
setcookie('msz_auth', $authToken->pack(), strtotime('1 year'), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
$authToken->applyCookie(strtotime('1 year'));
|
||||
|
||||
setcookie('msz_uid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true);
|
||||
setcookie('msz_sid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true);
|
||||
AuthToken::nukeCookieLegacy();
|
||||
}
|
||||
|
||||
if(!isset($authToken))
|
||||
$authToken = AuthToken::unpack(filter_input(INPUT_COOKIE, 'msz_auth') ?? '');
|
||||
|
||||
if($authToken->isValid()) {
|
||||
$authToken->setCurrent();
|
||||
|
||||
try {
|
||||
$sessionInfo = $authToken->getSession();
|
||||
$sessionInfo = UserSession::byToken($authToken->getSessionToken());
|
||||
if($sessionInfo->hasExpired()) {
|
||||
$sessionInfo->delete();
|
||||
} elseif($sessionInfo->getUserId() === $authToken->getUserId()) {
|
||||
|
@ -189,7 +192,22 @@ if($authToken->isValid()) {
|
|||
$sessionInfo->bump($_SERVER['REMOTE_ADDR']);
|
||||
|
||||
if($sessionInfo->shouldBumpExpire())
|
||||
setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
$authToken->applyCookie($sessionInfo->getExpiresTime());
|
||||
|
||||
// only allow impersonation when super user
|
||||
if($authToken->hasImpersonatedUserId() && $userInfo->isSuper()) {
|
||||
$userInfoReal = $userInfo;
|
||||
|
||||
try {
|
||||
$userInfo = User::byId($authToken->getImpersonatedUserId());
|
||||
} catch(UserNotFoundException $ex) {
|
||||
$userInfo = $userInfoReal;
|
||||
$authToken->removeImpersonatedUserId();
|
||||
$authToken->applyCookie();
|
||||
}
|
||||
|
||||
$userInfo->setCurrent();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(UserNotFoundException $ex) {
|
||||
|
@ -202,10 +220,8 @@ if($authToken->isValid()) {
|
|||
|
||||
if(UserSession::hasCurrent()) {
|
||||
$userInfo->bumpActivity($_SERVER['REMOTE_ADDR']);
|
||||
} else {
|
||||
setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true);
|
||||
}
|
||||
} else
|
||||
AuthToken::nukeCookie();
|
||||
}
|
||||
|
||||
CSRF::setGlobalSecretKey($cfg->getValue('csrf.secret', IConfig::T_STR, 'soup'));
|
||||
|
@ -248,6 +264,8 @@ if(parse_url($_SERVER['PHP_SELF'], PHP_URL_PATH) !== '/index.php')
|
|||
|
||||
if(!empty($userInfo))
|
||||
Template::set('current_user', $userInfo);
|
||||
if(!empty($userInfoReal))
|
||||
Template::set('current_user_real', $userInfoReal);
|
||||
|
||||
$inManageMode = str_starts_with($_SERVER['REQUEST_URI'], '/manage');
|
||||
$hasManageAccess = User::hasCurrent()
|
||||
|
|
|
@ -119,7 +119,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
|
|||
}
|
||||
|
||||
$authToken = AuthToken::create($userInfo, $sessionInfo);
|
||||
setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
$authToken->applyCookie($sessionInfo->getExpiresTime());
|
||||
|
||||
if(!is_local_url($loginRedirect))
|
||||
$loginRedirect = url('index');
|
||||
|
|
|
@ -12,8 +12,7 @@ if(!UserSession::hasCurrent()) {
|
|||
}
|
||||
|
||||
if(CSRF::validateRequest()) {
|
||||
setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true);
|
||||
AuthToken::nukeCookie();
|
||||
UserSession::getCurrent()->delete();
|
||||
UserSession::unsetCurrent();
|
||||
User::unsetCurrent();
|
||||
|
|
21
public/auth/revert.php
Normal file
21
public/auth/revert.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
namespace Misuzu;
|
||||
|
||||
use Misuzu\Users\User;
|
||||
|
||||
require_once '../../misuzu.php';
|
||||
|
||||
if(!isset($userInfoReal) || !$authToken->hasImpersonatedUserId() || !CSRF::validateRequest()) {
|
||||
url_redirect('index');
|
||||
return;
|
||||
}
|
||||
|
||||
$authToken->removeImpersonatedUserId();
|
||||
$authToken->applyCookie();
|
||||
|
||||
$impUserId = User::hasCurrent() ? User::getCurrent()->getId() : 0;
|
||||
|
||||
url_redirect(
|
||||
$impUserId > 0 ? 'manage-user' : 'index',
|
||||
['user' => $impUserId]
|
||||
);
|
|
@ -83,7 +83,7 @@ while(!empty($twofactor)) {
|
|||
}
|
||||
|
||||
$authToken = AuthToken::create($userInfo, $sessionInfo);
|
||||
setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
$authToken->applyCookie($sessionInfo->getExpiresTime());
|
||||
|
||||
if(!is_local_url($redirect)) {
|
||||
$redirect = url('index');
|
||||
|
|
|
@ -31,6 +31,19 @@ $canEditPerms = $canEdit && perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ
|
|||
$permissions = manage_perms_list(perms_get_user_raw($userId));
|
||||
|
||||
if(CSRF::validateRequest() && $canEdit) {
|
||||
if(!empty($_POST['impersonate_user'])) {
|
||||
if(!$currentUser->isSuper()) {
|
||||
$notices[] = 'You must be a super user to do this.';
|
||||
} elseif(!is_string($_POST['impersonate_user']) || $_POST['impersonate_user'] !== 'meow') {
|
||||
$notices[] = 'You didn\'t say the magic word.';
|
||||
} else {
|
||||
$authToken->setImpersonatedUserId($userInfo->getId());
|
||||
$authToken->applyCookie();
|
||||
url_redirect('index');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if(!empty($_POST['send_test_email'])) {
|
||||
if(!$currentUser->isSuper()) {
|
||||
$notices[] = 'You must be a super user to do this.';
|
||||
|
|
|
@ -3,66 +3,103 @@ namespace Misuzu;
|
|||
|
||||
use Misuzu\Users\User;
|
||||
use Misuzu\Users\UserSession;
|
||||
use Index\IO\MemoryStream;
|
||||
use Index\Serialisation\Serialiser;
|
||||
|
||||
class AuthToken {
|
||||
public const VERSION = 1;
|
||||
public const WIDTH = 37;
|
||||
private const EPOCH = 1682985600;
|
||||
|
||||
private $userId = -1;
|
||||
private $sessionToken = '';
|
||||
private int $timestamp = 0;
|
||||
private int $cookieExpires = 0;
|
||||
private array $props = [];
|
||||
|
||||
private $user = null;
|
||||
private $session = null;
|
||||
private static string $secretKey = '';
|
||||
|
||||
public static function setSecretKey(string $secretKey): void {
|
||||
self::$secretKey = $secretKey;
|
||||
}
|
||||
|
||||
public function getTimestamp(): int {
|
||||
return $this->timestamp;
|
||||
}
|
||||
public function updateTimestamp(): void {
|
||||
$this->timestamp = self::timestamp();
|
||||
}
|
||||
|
||||
public function hasProperty(string $name): bool {
|
||||
return isset($this->props[$name]);
|
||||
}
|
||||
public function getProperty(string $name): string {
|
||||
return $this->props[$name] ?? '';
|
||||
}
|
||||
public function setProperty(string $name, string $value): void {
|
||||
$this->props[$name] = $value;
|
||||
$this->updateTimestamp();
|
||||
}
|
||||
public function removeProperty(string $name): void {
|
||||
unset($this->props[$name]);
|
||||
$this->updateTimestamp();
|
||||
}
|
||||
|
||||
public function isValid(): bool {
|
||||
return $this->getUserId() > 0
|
||||
&& !empty($this->getSessionToken());
|
||||
if($this->getUserId() < 1 || empty($this->getSessionToken()))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getUserId(): int {
|
||||
return $this->userId < 1 ? -1 : $this->userId;
|
||||
$value = (int)$this->getProperty('u');
|
||||
return $value < 1 ? -1 : $value;
|
||||
}
|
||||
public function setUserId(int $userId): self {
|
||||
$this->user = null;
|
||||
$this->userId = $userId;
|
||||
return $this;
|
||||
}
|
||||
public function getUser(): User {
|
||||
if($this->user === null)
|
||||
$this->user = User::byId($this->getUserId());
|
||||
return $this->user;
|
||||
}
|
||||
public function setUser(User $user): self {
|
||||
$this->user = $user;
|
||||
$this->userId = $user->getId();
|
||||
$this->setProperty('u', (string)$userId);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSessionToken(): string {
|
||||
return $this->sessionToken ?? '';
|
||||
if(!$this->hasProperty('t'))
|
||||
return '';
|
||||
return bin2hex($this->getProperty('t'));
|
||||
}
|
||||
public function setSessionToken(string $token): self {
|
||||
$this->session = null;
|
||||
$this->sessionToken = $token;
|
||||
return $this;
|
||||
}
|
||||
public function getSession(): UserSession {
|
||||
if($this->session === null)
|
||||
$this->session = UserSession::byToken($this->getSessionToken());
|
||||
return $this->session;
|
||||
}
|
||||
public function setSession(UserSession $session): self {
|
||||
$this->session = $session;
|
||||
$this->sessionToken = $session->getToken();
|
||||
$this->setProperty('t', hex2bin($token));
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasImpersonatedUserId(): bool {
|
||||
return $this->hasProperty('i');
|
||||
}
|
||||
public function getImpersonatedUserId(): int {
|
||||
$value = (int)$this->getProperty('i');
|
||||
return $value < 1 ? -1 : $value;
|
||||
}
|
||||
public function setImpersonatedUserId(int $userId): void {
|
||||
$this->setProperty('i', (string)$userId);
|
||||
}
|
||||
public function removeImpersonatedUserId(): void {
|
||||
$this->removeProperty('i');
|
||||
}
|
||||
|
||||
public function pack(bool $base64 = true): string {
|
||||
$packed = pack('CNH*', self::VERSION, $this->getUserId(), $this->getSessionToken());
|
||||
$data = '';
|
||||
|
||||
foreach($this->props as $name => $value) {
|
||||
// very smart solution for this issue, you definitely won't be confused by this later
|
||||
// down the line when a variable suddenly despawns from the token
|
||||
$nameLength = strlen($name);
|
||||
$valueLength = strlen($value);
|
||||
if($nameLength > 255 || $valueLength > 255)
|
||||
continue;
|
||||
|
||||
$data .= chr($nameLength) . $name . chr($valueLength) . $value;
|
||||
}
|
||||
|
||||
$prefix = pack('CN', 2, $this->getTimestamp());
|
||||
$data = $prefix . hash_hmac('sha3-256', $prefix . $data, self::$secretKey, true) . $data;
|
||||
if($base64)
|
||||
$packed = Serialiser::uriBase64()->serialise($packed);
|
||||
return $packed;
|
||||
$data = Serialiser::uriBase64()->serialise($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function unpack(string $data, bool $base64 = true): self {
|
||||
|
@ -72,20 +109,105 @@ class AuthToken {
|
|||
return $obj;
|
||||
if($base64)
|
||||
$data = Serialiser::uriBase64()->deserialise($data);
|
||||
if(empty($data))
|
||||
return $obj;
|
||||
|
||||
$data = str_pad($data, self::WIDTH, "\x00");
|
||||
$data = unpack('Cversion/Nuser/H*token', $data);
|
||||
$version = ord($data[0]);
|
||||
$data = substr($data, 1);
|
||||
|
||||
if($data['version'] >= 1)
|
||||
$obj->setUserId($data['user'])
|
||||
->setSessionToken($data['token']);
|
||||
if($version === 1) {
|
||||
$data = str_pad($data, 36, "\x00");
|
||||
$data = unpack('Nuser/H*token', $data);
|
||||
|
||||
$obj->props['u'] = (string)$data['user'];
|
||||
$obj->props['t'] = hex2bin($data['token']);
|
||||
$obj->updateTimestamp();
|
||||
} elseif($version === 2) {
|
||||
$timestamp = substr($data, 0, 4);
|
||||
$userHash = substr($data, 4, 32);
|
||||
$data = substr($data, 36);
|
||||
$realHash = hash_hmac('sha3-256', chr($version) . $timestamp . $data, self::$secretKey, true);
|
||||
|
||||
if(!hash_equals($realHash, $userHash))
|
||||
return $obj;
|
||||
|
||||
extract(unpack('Ntimestamp', $timestamp));
|
||||
$obj->timestamp = $timestamp;
|
||||
|
||||
$stream = MemoryStream::fromString($data);
|
||||
$stream->seek(0);
|
||||
|
||||
for(;;) {
|
||||
$length = $stream->readChar();
|
||||
if($length === null)
|
||||
break;
|
||||
|
||||
$length = ord($length);
|
||||
if($length < 1)
|
||||
break;
|
||||
|
||||
$name = $stream->read($length);
|
||||
$value = null;
|
||||
$length = $stream->readChar();
|
||||
if($length !== null) {
|
||||
$length = ord($length);
|
||||
if($length > 0)
|
||||
$value = $stream->read($length);
|
||||
}
|
||||
|
||||
$obj->props[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public static function timestamp(): int {
|
||||
return time() - self::EPOCH;
|
||||
}
|
||||
|
||||
public static function create(User $user, UserSession $session): self {
|
||||
return (new AuthToken)
|
||||
->setUser($user)
|
||||
->setSession($session);
|
||||
$token = new AuthToken;
|
||||
$token->setUserId($user->getId());
|
||||
$token->setSessionToken($session->getToken());
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function applyCookie(int $expires = 0): void {
|
||||
if($expires > 0)
|
||||
$this->cookieExpires = $expires;
|
||||
else
|
||||
$expires = $this->cookieExpires;
|
||||
|
||||
setcookie('msz_auth', $this->pack(), $expires, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
}
|
||||
|
||||
public static function nukeCookie(): void {
|
||||
setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true);
|
||||
setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true);
|
||||
}
|
||||
|
||||
public static function nukeCookieLegacy(): void {
|
||||
setcookie('msz_uid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true);
|
||||
setcookie('msz_sid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true);
|
||||
}
|
||||
|
||||
// please never use the below functions beyond the scope of the sharpchat auth stuff
|
||||
// a better mechanism for keeping a global instance of this available
|
||||
// that isn't a $GLOBAL variable or static instance needs to be established, for User and UserSession as well
|
||||
|
||||
private static $localToken = null;
|
||||
|
||||
public function setCurrent(): void {
|
||||
self::$localToken = $this;
|
||||
}
|
||||
public static function unsetCurrent(): void {
|
||||
self::$localToken = null;
|
||||
}
|
||||
public static function getCurrent(): ?self {
|
||||
return self::$localToken;
|
||||
}
|
||||
public static function hasCurrent(): bool {
|
||||
return self::$localToken !== null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -126,16 +126,22 @@ final class SharpChatRoutes {
|
|||
if($request->getMethod() === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
if(!UserSession::hasCurrent())
|
||||
if(!UserSession::hasCurrent() || !AuthToken::hasCurrent())
|
||||
return ['ok' => false];
|
||||
|
||||
$token = AuthToken::getCurrent();
|
||||
$session = UserSession::getCurrent();
|
||||
if($session->getToken() !== $token->getSessionToken())
|
||||
return ['ok' => false];
|
||||
|
||||
$user = $session->getUser();
|
||||
$token = AuthToken::create($user, $session);
|
||||
$userId = $token->hasImpersonatedUserId() && $user->isSuper()
|
||||
? $token->getImpersonatedUserId()
|
||||
: $user->getId();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'usr' => $user->getId(),
|
||||
'usr' => $userId,
|
||||
'tkn' => $token->pack(),
|
||||
];
|
||||
}
|
||||
|
@ -281,6 +287,15 @@ final class SharpChatRoutes {
|
|||
$sessionInfo->bump($ipAddress);
|
||||
|
||||
$userInfo = $sessionInfo->getUser();
|
||||
if($authTokenInfo->hasImpersonatedUserId() && $userInfo->isSuper()) {
|
||||
$userInfoReal = $userInfo;
|
||||
|
||||
try {
|
||||
$userInfo = User::byId($authTokenInfo->getImpersonatedUserId());
|
||||
} catch(UserNotFoundException $ex) {
|
||||
$userInfo = $userInfoReal;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return ['success' => false, 'reason' => 'unsupported'];
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ define('MSZ_URLS', [
|
|||
'auth-logout' => ['/auth/logout.php', ['csrf' => '{csrf}']],
|
||||
'auth-resolve-user' => ['/auth/login.php', ['resolve' => '1', 'name' => '<username>']],
|
||||
'auth-two-factor' => ['/auth/twofactor.php', ['token' => '<token>']],
|
||||
'auth-revert' => ['/auth/revert.php', ['csrf' => '{csrf}']],
|
||||
|
||||
'changelog-index' => ['/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>']],
|
||||
'changelog-feed-rss' => ['/changelog.rss'],
|
||||
|
|
|
@ -1,6 +1,22 @@
|
|||
{% from 'macros.twig' import avatar %}
|
||||
{% from '_layout/input.twig' import input_checkbox_raw %}
|
||||
|
||||
{% if current_user_real is defined %}
|
||||
<div class="impersonate">
|
||||
<div class="impersonate-content">
|
||||
<div class="impersonate-user" style="--user-colour: {{ current_user_real.colour }}">
|
||||
You are <a href="{{ url('user-profile', {'user': current_user_real.id}) }}" class="impersonate-user-link">
|
||||
<div class="avatar impersonate-user-avatar">{{ avatar(current_user_real.id, 20, current_user_real.username) }}</div>
|
||||
{{ current_user_real.username }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="impersonate-options">
|
||||
<a href="{{ url('auth-revert') }}" class="impersonate-options-link" title="Revert"><i class="fas fa-backward"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<nav class="header">
|
||||
<div class="header__background"></div>
|
||||
|
||||
|
|
|
@ -145,6 +145,21 @@
|
|||
<button class="input__button manage__user__button">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container">
|
||||
{{ container_title('Impersonate ' ~ user_info.username ~ ' (' ~ user_info.id ~ ')') }}
|
||||
|
||||
<div class="container__content">
|
||||
<p>Uses a special token to completely impersonate this user for testing.</p>
|
||||
</div>
|
||||
|
||||
{{ input_csrf() }}
|
||||
{{ input_hidden('impersonate_user', 'meow') }}
|
||||
|
||||
<div class="manage__user__buttons">
|
||||
<button class="input__button manage__user__button">Impersonate</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container">
|
||||
|
|
Loading…
Reference in a new issue