diff --git a/Core/API/PermissionAPI.class.php b/Core/API/PermissionAPI.class.php
index 995312b..04856a3 100644
--- a/Core/API/PermissionAPI.class.php
+++ b/Core/API/PermissionAPI.class.php
@@ -76,13 +76,9 @@ namespace Core\API\Permission {
         }
 
         // user would have required groups, check for 2fa-state
-        if ($currentUser) {
-          $tfaToken = $currentUser->getTwoFactorToken();
-          if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
-            $this->lastError = '2FA-Authorization is required';
-            http_response_code(401);
-            return false;
-          }
+        if ($currentUser && !$this->check2FA()) {
+          http_response_code(401);
+          return false;
         }
       }
 
diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php
index 894e1d3..ceafd2a 100644
--- a/Core/API/Request.class.php
+++ b/Core/API/Request.class.php
@@ -5,6 +5,8 @@ namespace Core\API;
 use Core\Driver\Logger\Logger;
 use Core\Driver\SQL\Query\Insert;
 use Core\Objects\Context;
+use Core\Objects\DatabaseEntity\TwoFactorToken;
+use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
 use PhpMqtt\Client\MqttClient;
 
 abstract class Request {
@@ -126,6 +128,33 @@ abstract class Request {
   protected abstract function _execute(): bool;
   public static function getDefaultACL(Insert $insert): void { }
 
+  protected function check2FA(?TwoFactorToken $tfaToken = null): bool {
+
+    // do not require 2FA for verifying endpoints
+    if ($this instanceof \Core\API\Tfa\VerifyTotp || $this instanceof \Core\API\Tfa\VerifyKey) {
+      return true;
+    }
+
+    if ($tfaToken === null) {
+      $tfaToken = $this->context->getUser()?->getTwoFactorToken();
+    }
+
+    if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
+
+      if ($tfaToken instanceof KeyBasedTwoFactorToken && !$tfaToken->hasChallenge()) {
+        $tfaToken->generateChallenge();
+      }
+
+      $this->lastError = '2FA-Authorization is required';
+      $this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
+        "type", "challenge", "authenticated", "confirmed", "credentialID"
+      ]);
+      return false;
+    }
+
+    return true;
+  }
+
   public final function execute($values = array()): bool {
 
     $this->params = array_merge([], $this->defaultParams);
@@ -196,15 +225,9 @@ abstract class Request {
           $this->lastError = 'You are not logged in.';
           http_response_code(401);
           return false;
-        } else if ($session) {
-          $tfaToken = $session->getUser()->getTwoFactorToken();
-          if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
-            if (!($this instanceof \Core\API\Tfa\VerifyTotp) && !($this instanceof \Core\API\Tfa\VerifyKey)) {
-              $this->lastError = '2FA-Authorization is required';
-              http_response_code(401);
-              return false;
-            }
-          }
+        } else if ($session && !$this->check2FA()) {
+          http_response_code(401);
+          return false;
         }
       }
 
diff --git a/Core/API/Stats.class.php b/Core/API/Stats.class.php
index c4993df..ade02dd 100644
--- a/Core/API/Stats.class.php
+++ b/Core/API/Stats.class.php
@@ -6,6 +6,8 @@ use Core\Driver\SQL\Expression\Count;
 use Core\Driver\SQL\Expression\Distinct;
 use Core\Driver\SQL\Query\Insert;
 use Core\Objects\DatabaseEntity\Group;
+use Core\Objects\DatabaseEntity\Route;
+use Core\Objects\DatabaseEntity\User;
 use DateTime;
 use Core\Driver\SQL\Condition\Compare;
 use Core\Driver\SQL\Condition\CondBool;
@@ -20,26 +22,6 @@ class Stats extends Request {
     parent::__construct($context, $externalCall, array());
   }
 
-  private function getUserCount(): int {
-    $sql = $this->context->getSQL();
-    $res = $sql->select(new Count())->from("User")->execute();
-    $this->success = $this->success && ($res !== FALSE);
-    $this->lastError = $sql->getLastError();
-
-    return ($this->success ? intval($res[0]["count"]) : 0);
-  }
-
-  private function getPageCount(): int {
-    $sql = $this->context->getSQL();
-    $res = $sql->select(new Count())->from("Route")
-      ->where(new CondBool("active"))
-      ->execute();
-    $this->success = $this->success && ($res !== FALSE);
-    $this->lastError = $sql->getLastError();
-
-    return ($this->success ? intval($res[0]["count"]) : 0);
-  }
-
   private function checkSettings(): bool {
     $req = new \Core\API\Settings\Get($this->context);
     $this->success = $req->execute(array("key" => "^(mail_enabled|recaptcha_enabled)$"));
@@ -72,8 +54,9 @@ class Stats extends Request {
   }
 
   public function _execute(): bool {
-    $userCount = $this->getUserCount();
-    $pageCount = $this->getPageCount();
+    $sql = $this->context->getSQL();
+    $userCount = User::count($sql);
+    $pageCount = Route::count($sql, new CondBool("active"));
     $req = new \Core\API\Visitors\Stats($this->context);
     $this->success = $req->execute(array("type"=>"monthly"));
     $this->lastError = $req->getLastError();
diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php
index c297284..76d83b6 100644
--- a/Core/API/TfaAPI.class.php
+++ b/Core/API/TfaAPI.class.php
@@ -189,6 +189,11 @@ namespace Core\API\TFA {
       $sql = $this->context->getSQL();
       $this->success = $twoFactorToken->confirm($sql) !== false;
       $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        $this->context->invalidateSessions(true);
+      }
+
       return $this->success;
     }
   }
@@ -315,6 +320,7 @@ namespace Core\API\TFA {
 
         if ($this->success) {
           $this->result["twoFactorToken"] = $twoFactorToken->jsonSerialize();
+          $this->context->invalidateSessions();
         }
       }
 
diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php
index c2db253..aa95faf 100644
--- a/Core/API/UserAPI.class.php
+++ b/Core/API/UserAPI.class.php
@@ -222,7 +222,8 @@ namespace Core\API\User {
 
     public function __construct(Context $context, $externalCall = false) {
       parent::__construct($context, $externalCall,
-        self::getPaginationParameters(['id', 'name', 'email', 'groups', 'registeredAt'])
+        self::getPaginationParameters(['id', 'name', 'email', 'groups', 'registeredAt'],
+          'id', 'asc')
       );
     }
 
@@ -253,8 +254,8 @@ namespace Core\API\User {
 
       $groupNames = new Alias(
         $sql->select(new JsonArrayAgg("name"))->from("Group")
-          ->leftJoin("NM_Group_User", "NM_Group_User.group_id", "Group.id")
-          ->whereEq("NM_Group_User.user_id", new Column("User.id")),
+          ->leftJoin("NM_User_groups", "NM_User_groups.group_id", "Group.id")
+          ->whereEq("NM_User_groups.user_id", new Column("User.id")),
         "groups"
       );
 
@@ -588,15 +589,7 @@ namespace Core\API\User {
               $this->result["user"] = $user->jsonSerialize();
               $this->result["session"] = $session->jsonSerialize();
               $this->result["logoutIn"] = $session->getExpiresSeconds();
-              if ($tfaToken && $tfaToken->isConfirmed()) {
-                if ($tfaToken instanceof KeyBasedTwoFactorToken) {
-                  $tfaToken->generateChallenge();
-                }
-
-                $this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
-                  "type", "challenge", "authenticated", "confirmed", "credentialID"
-                ]);
-              }
+              $this->check2FA($tfaToken);
               $this->success = true;
             }
           } else {
@@ -1116,6 +1109,7 @@ namespace Core\API\User {
         if ($user->save($sql)) {
           $this->logger->info("Issued password reset for user id=" . $user->getId());
           $userToken->invalidate($sql);
+          $this->context->invalidateSessions(false);
           return true;
         } else {
           return $this->createError("Error updating user details: " . $sql->getLastError());
@@ -1152,6 +1146,7 @@ namespace Core\API\User {
       }
 
       $sql = $this->context->getSQL();
+      $updateFields = [];
 
       $currentUser = $this->context->getUser();
       if ($newUsername !== null) {
@@ -1159,11 +1154,13 @@ namespace Core\API\User {
           return false;
         } else {
           $currentUser->name = $newUsername;
+          $updateFields[] = "name";
         }
       }
 
       if ($newFullName !== null) {
         $currentUser->fullName = $newFullName;
+        $updateFields[] = "fullName";
       }
 
       if ($newPassword !== null || $newPasswordConfirm !== null) {
@@ -1175,11 +1172,17 @@ namespace Core\API\User {
           }
 
           $currentUser->password = $this->hashPassword($newPassword);
+          $updateFields[] = "password";
         }
       }
 
-      $this->success = $currentUser->save($sql) !== false;
-      $this->lastError = $sql->getLastError();
+      if (!empty($updateFields)) {
+        $this->success = $currentUser->save($sql, $updateFields) !== false;
+        $this->lastError = $sql->getLastError();
+        if ($this->success && in_array("password", $updateFields)) {
+          $this->context->invalidateSessions(true);
+        }
+      }
       return $this->success;
     }
   }
diff --git a/Core/Configuration/CreateDatabase.class.php b/Core/Configuration/CreateDatabase.class.php
index 63c6203..766a027 100644
--- a/Core/Configuration/CreateDatabase.class.php
+++ b/Core/Configuration/CreateDatabase.class.php
@@ -128,7 +128,6 @@ class CreateDatabase extends DatabaseScript {
       $method = $reflectionClass->getName() . "::getDefaultACL";
       $method($query);
     }
-
     if ($query->hasRows()) {
       $queries[] = $query;
     }
diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php
index 89d4dab..6b15685 100644
--- a/Core/Configuration/Settings.class.php
+++ b/Core/Configuration/Settings.class.php
@@ -107,7 +107,10 @@ class Settings {
   public static function loadDefaults(): Settings {
     $hostname = $_SERVER["SERVER_NAME"] ?? null;
     if (empty($hostname)) {
-      $hostname = "localhost";
+      $hostname = $_SERVER["HTTP_HOST"];
+      if (empty($hostname)) {
+        $hostname = "localhost";
+      }
     }
 
     $protocol = getProtocol();
diff --git a/Core/Driver/SQL/Expression/JsonObjectAgg.class.php b/Core/Driver/SQL/Expression/JsonObjectAgg.class.php
new file mode 100644
index 0000000..de6afd3
--- /dev/null
+++ b/Core/Driver/SQL/Expression/JsonObjectAgg.class.php
@@ -0,0 +1,34 @@
+key = $key;
+    $this->value = $value;
+  }
+
+  public function getExpression(SQL $sql, array &$params): string {
+    $value = is_string($this->value) ? new Column($this->value) : $this->value;
+    $value = $sql->addValue($value, $params);
+    $key = is_string($this->key) ? new Column($this->key) : $this->key;
+    $key = $sql->addValue($key, $params);
+    if ($sql instanceof MySQL) {
+      return "JSON_OBJECTAGG($key, $value)";
+    } else if ($sql instanceof PostgreSQL) {
+      return "JSON_OBJECT_AGG($value)";
+    } else {
+      throw new Exception("JsonObjectAgg not implemented for driver type: " . get_class($sql));
+    }
+  }
+}
\ No newline at end of file
diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php
index cf159d9..5348c4e 100644
--- a/Core/Objects/Context.class.php
+++ b/Core/Objects/Context.class.php
@@ -211,4 +211,16 @@ class Context {
   public function getLanguage(): Language {
     return $this->language;
   }
+
+  public function invalidateSessions(bool $keepCurrent = true): bool {
+    $query = $this->sql->update("Session")
+      ->set("active", false)
+      ->whereEq("user_id", $this->user->getId());
+
+    if (!$keepCurrent && $this->session !== null) {
+      $query->whereNeq("id", $this->session->getId());
+    }
+
+    return $query->execute();
+  }
 }
\ No newline at end of file
diff --git a/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
index b7a4b70..a2f7390 100644
--- a/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
+++ b/Core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
@@ -36,6 +36,10 @@ class KeyBasedTwoFactorToken extends TwoFactorToken {
     return $token;
   }
 
+  public function hasChallenge(): bool {
+    return isset($this->challenge);
+  }
+
   public function getChallenge(): string {
     return $this->challenge;
   }
diff --git a/react/admin-panel/src/views/user-list.js b/react/admin-panel/src/views/user-list.js
index e1a2beb..49ce52a 100644
--- a/react/admin-panel/src/views/user-list.js
+++ b/react/admin-panel/src/views/user-list.js
@@ -1,17 +1,22 @@
 import {Link, Navigate, useNavigate} from "react-router-dom";
-import {useCallback, useContext, useEffect} from "react";
+import {useCallback, useContext, useEffect, useState} from "react";
 import {LocaleContext} from "shared/locale";
 import {DataColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
 import {Button, IconButton} from "@material-ui/core";
 import EditIcon from '@mui/icons-material/Edit';
 import {Chip} from "@mui/material";
 import AddIcon from "@mui/icons-material/Add";
+import usePagination from "shared/hooks/pagination";
 
 
 export default function UserListView(props) {
 
+    const api = props.api;
+    const showDialog = props.showDialog;
     const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
     const navigate = useNavigate();
+    const pagination = usePagination();
+    const [users, setUsers] = useState([]);
 
     useEffect(() => {
         requestModules(props.api, ["general", "account"], currentLocale).then(data => {
@@ -21,15 +26,17 @@ export default function UserListView(props) {
         });
     }, [currentLocale]);
 
-    const onFetchUsers = useCallback(async (page, count, orderBy, sortOrder) => {
-        let res = await props.api.fetchUsers(page, count, orderBy, sortOrder);
-        if (res.success) {
-            return Promise.resolve([res.users, res.pagination]);
-        } else {
-            props.showDialog(res.msg, "Error fetching users");
-            return null;
-        }
-    }, []);
+    const onFetchUsers = useCallback((page, count, orderBy, sortOrder) => {
+        api.fetchUsers(page, count, orderBy, sortOrder).then((res) => {
+            if (res.success) {
+                setUsers(res.users);
+                pagination.update(res.pagination);
+            } else {
+                showDialog(res.msg, "Error fetching users");
+                return null;
+            }
+        });
+    }, [api, showDialog]);
 
     const groupColumn = (() => {
        let column = new DataColumn(L("account.groups"), "groups");
@@ -80,9 +87,13 @@ export default function UserListView(props) {
                             {L("general.create_new")}
                         
                     
-                    
+                    
                 
             
         
diff --git a/react/shared/api.js b/react/shared/api.js
index 7f00c71..c95ffb5 100644
--- a/react/shared/api.js
+++ b/react/shared/api.js
@@ -49,6 +49,7 @@ export default class API {
         let res = await response.json();
         if (!res.success && res.msg === "You are not logged in.") {
             this.loggedIn = false;
+            this.user = null;
         }
 
         return res;
diff --git a/react/shared/elements/data-table.js b/react/shared/elements/data-table.js
index 5c51e40..8c96cca 100644
--- a/react/shared/elements/data-table.js
+++ b/react/shared/elements/data-table.js
@@ -5,7 +5,7 @@ import React, {useCallback, useContext, useEffect, useState} from "react";
 import "./data-table.css";
 import {LocaleContext} from "../locale";
 import clsx from "clsx";
-import {Box, IconButton, TextField} from "@mui/material";
+import {Box, IconButton, Select, TextField} from "@mui/material";
 import {formatDate, formatDateTime} from "../util";
 import CachedIcon from "@material-ui/icons/Cached";
 
@@ -195,7 +195,7 @@ export class NumericColumn extends DataColumn {
     }
 
     renderData(L, entry, index) {
-        let number = super.renderData(L, entry).toString();
+        let number = super.renderData(L, entry, index).toString();
 
         if (this.decimalDigits !== null) {
             number = number.toFixed(this.decimalDigits);
@@ -223,8 +223,8 @@ export class DateTimeColumn extends DataColumn {
     }
 
     renderData(L, entry, index) {
-        let date = super.renderData(L, entry);
-        return formatDateTime(L, date, this.precise);
+        let date = super.renderData(L, entry, index);
+        return date ? formatDateTime(L, date, this.precise) : "";
     }
 }
 
@@ -234,8 +234,8 @@ export class DateColumn extends DataColumn {
     }
 
     renderData(L, entry, index) {
-        let date = super.renderData(L, entry);
-        return formatDate(L, date);
+        let date = super.renderData(L, entry, index);
+        return date ? formatDate(L, date) : "";
     }
 }
 
@@ -245,7 +245,7 @@ export class BoolColumn extends DataColumn {
     }
 
     renderData(L, entry, index) {
-        let data = super.renderData(L, entry);
+        let data = super.renderData(L, entry, index);
         return L(data ? "general.yes" : "general.no");
     }
 }
@@ -260,9 +260,17 @@ export class InputColumn extends DataColumn {
 
     renderData(L, entry, index) {
         let value = super.renderData(L, entry, index);
+        let inputProps = typeof this.props === 'function' ? this.props(entry, index) : this.props;
         if (this.type === 'text') {
-            return  this.onChange(entry, index, e.target.value)} />
+        } else if (this.type === "select") {
+            let options = Object.entries(this.params.options || {}).map(([value, label]) =>
+                );
+            return 
         }
 
         return <>[Invalid type: {this.type}]>
@@ -292,15 +300,19 @@ export class ControlsColumn extends DataColumn {
             let props = {
                 ...buttonProps,
                 key: "button-" + index,
-                onClick: (e) => { e.stopPropagation(); button.onClick(entry, index); },
             }
 
+            // TODO: icon button!
             if (button.hasOwnProperty("disabled")) {
                 props.disabled = typeof button.disabled === 'function'
                     ? button.disabled(entry, index)
                     : button.disabled;
             }
 
+            if (!props.disabled) {
+                props.onClick = (e) => { e.stopPropagation(); button.onClick(entry, index); }
+            }
+
             if ((!button.hasOwnProperty("hidden")) ||
                 (typeof button.hidden === 'function' && !button.hidden(entry, index)) ||
                 (!button.hidden)) {
diff --git a/react/shared/elements/dialog.jsx b/react/shared/elements/dialog.jsx
index d20db35..c2783a8 100644
--- a/react/shared/elements/dialog.jsx
+++ b/react/shared/elements/dialog.jsx
@@ -7,7 +7,7 @@ import {
     DialogContent,
     DialogContentText,
     DialogTitle,
-    Input, List, ListItem, TextField
+    Input, List, ListItem, Select, TextField
 } from "@mui/material";
 
 export default function Dialog(props) {
diff --git a/react/shared/hooks/async-search.js b/react/shared/hooks/async-search.js
index 4eb4d3d..b1584a9 100644
--- a/react/shared/hooks/async-search.js
+++ b/react/shared/hooks/async-search.js
@@ -7,7 +7,6 @@ export default function useAsyncSearch(callback, minLength = 1) {
     const [results, setResults] = useState(null);
 
     useEffect(() => {
-        console.log("searchString:", searchString);
         if (minLength > 0 && (!searchString || searchString.length < minLength)) {
             setResults([]);
             return;