diff --git a/config.py b/config.py
index 3f2ba86faee5130cfcec1f4479609a45d9dd7484..3ea36edff4b39a234de6fa25472ff092928465ed 100644
--- a/config.py
+++ b/config.py
@@ -1,7 +1,28 @@
-# Various constants and parameters
+# Global configuration parameters
+# Some are filled during initialization by load_config() in main.py
 
-NIFI_CONTAINERS = [
-    'soctools-nifi-1',
-    'soctools-nifi-2',
-    'soctools-nifi-3',
-]
+import os.path
+
+# *** Configuration of file paths ***
+SOCTOOLS_BASE = ".." # path to the root of soctools files
+VARIABLES_FILE = os.path.join(SOCTOOLS_BASE, "group_vars/all/variables.yml")
+CA_CERT_FILE = os.path.join(SOCTOOLS_BASE, "secrets/CA/ca.crt")
+KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/keykloak_admin") # Note: should be keycloak, not keykloak
+
+# Credentials of the special user for account management
+# Cert and key should be in .pem format, unencrypted
+MGMT_USER_NAME = "soctools-user-mgmt"
+MGMT_USER_CERT_PATH = os.path.join(SOCTOOLS_BASE, "secrets/CA/issued/soctools-user-mgmt.crt")
+MGMT_USER_KEY_PATH = os.path.join(SOCTOOLS_BASE, "secrets/CA/private/soctools-user-mgmt.key")
+# TODO FIXME "SOC_Admin" used instead for initial testing
+# MGMT_USER_NAME = "SOC_Admin"
+# MGMT_USER_CERT_PATH = os.path.join(SOCTOOLS_BASE, "secrets/certificates/SOC_Admin.crt.pem")
+# MGMT_USER_KEY_PATH = os.path.join(SOCTOOLS_BASE, "secrets/certificates/SOC_Admin.key.pem")
+
+
+# Following parameters are set up  dynamically by load_config() in main.py
+SOCTOOLSPROXY = None
+
+KEYCLOAK_BASE_URL = None
+KEYCLOAK_USERS_URL = None
+KEYCLOAK_ADMIN_PASSWORD = None
diff --git a/main.py b/main.py
index f9ec613b227fa18baf30c1a3f1e591a0f9f9656b..523fba00830657bd64fd43832e4ad5d978c486d8 100644
--- a/main.py
+++ b/main.py
@@ -15,32 +15,26 @@ import yaml
 from dataclasses import dataclass, field # external package - backport of Py3.7 dataclasses to Py3.6
 
 from nifi import *
-
+import config
 
 app = Flask(__name__)
 app.secret_key = "ASDF1234 - CHANGE ME!"
 
-# *** Configuration of file paths ***
-SOCTOOLS_BASE = ".." # path to the root of soctools files
-VARIABLES_FILE = os.path.join(SOCTOOLS_BASE, "group_vars/all/variables.yml")
-CA_CERT_FILE = os.path.join(SOCTOOLS_BASE, "secrets/CA/ca.crt")
-KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/keykloak_admin") # Note: should be keycloak, not keykloak
 
 @app.before_first_request
 def load_config():
     """Load various variables, api keys, etc. and set configuration parameters"""
-    global SOCTOOLSPROXY, KEYCLOAK_BASE_URL, KEYCLOAK_USERS_URL, KEYCLOAK_ADMIN_PASSWORD
-    variables = yaml.safe_load(open(VARIABLES_FILE, "r"))
+    variables = yaml.safe_load(open(config.VARIABLES_FILE, "r"))
     # Get FQDN of the main server
-    SOCTOOLSPROXY = variables["soctoolsproxy"]
-    assert re.match('[a-zA-Z0-9.-]+', SOCTOOLSPROXY), f"ERROR: The 'soctoolsproxy' variable loaded from '{VARIABLES_FILE}' is not a valid domain name."
+    config.SOCTOOLSPROXY = variables["soctoolsproxy"]
+    assert re.match('[a-zA-Z0-9.-]+', config.SOCTOOLSPROXY), f"ERROR: The 'soctoolsproxy' variable loaded from '{config.VARIABLES_FILE}' is not a valid domain name."
     # Set base URL to Keycloak
-    KEYCLOAK_BASE_URL = f"https://{SOCTOOLSPROXY}:12443"
-    KEYCLOAK_USERS_URL = KEYCLOAK_BASE_URL + "/auth/admin/realms/SOCTOOLS1/users"
+    config.KEYCLOAK_BASE_URL = f"https://{config.SOCTOOLSPROXY}:12443"
+    config.KEYCLOAK_USERS_URL = config.KEYCLOAK_BASE_URL + "/auth/admin/realms/SOCTOOLS1/users"
     # Load API key for Keycloak
-    KEYCLOAK_ADMIN_PASSWORD = open(KEYCLOAK_ADMIN_PASSWORD_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long
-    print(f"Config loaded:\nSOCTOOLSPROXY={SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={KEYCLOAK_BASE_URL}\n"
-          f"KEYCLOAK_ADMIN_PASSWORD={KEYCLOAK_ADMIN_PASSWORD[:3]}...{KEYCLOAK_ADMIN_PASSWORD[-4:]}")
+    config.KEYCLOAK_ADMIN_PASSWORD = open(config.KEYCLOAK_ADMIN_PASSWORD_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long
+    print(f"Config loaded:\nSOCTOOLSPROXY={config.SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={config.KEYCLOAK_BASE_URL}\n"
+          f"KEYCLOAK_ADMIN_PASSWORD={config.KEYCLOAK_ADMIN_PASSWORD[:3]}...{config.KEYCLOAK_ADMIN_PASSWORD[-4:]}")
 
 
 # *** Custom Jinja filters ***
@@ -63,6 +57,7 @@ class UserAccount:
     kcid: Optional[str] = field(default=None) # keycloak ID
     ts_created: Optional[datetime] = field(default=None) # timezone-aware datetime in UTC
     components: Optional[Dict[str, bool]] = field(default_factory=dict) # Presence of the account in SOCtools components that don't use Keycloak directly (NiFi, MISP, TheHive, ...)
+    internal: bool = False
 
     def to_keycloak_representation(self) -> Dict:
         """
@@ -85,9 +80,9 @@ class UserAccount:
     @classmethod
     def from_keycloak_representation(cls, kc_user: dict) -> "UserAccount":
         try:
-            return cls(kc_user['username'], kc_user['email'], kc_user['firstName'], kc_user['lastName'],
-                       kc_user['attributes'].get('CN',[''])[0], kc_user['attributes'].get('DN',[''])[0],
-                       kc_user['id'],
+            return cls(kc_user['username'], kc_user.get('email', ''), kc_user.get('firstName', ''),
+                       kc_user.get('lastName', ''), kc_user.get('attributes', {}).get('CN',[''])[0],
+                       kc_user.get('attributes', {}).get('DN',[''])[0], kc_user['id'],
                        datetime.utcfromtimestamp(int(kc_user['createdTimestamp']/1000)).replace(tzinfo=timezone.utc))
         except KeyError as e:
             raise KeycloakError(f"User representation received from Keycloak is missing attribute '{e}'")
@@ -104,15 +99,15 @@ def kc_get_token() -> str:
     
     Return the token or raise KeycloakError
     """
-    url = KEYCLOAK_BASE_URL + "/auth/realms/master/protocol/openid-connect/token"
+    url = config.KEYCLOAK_BASE_URL + "/auth/realms/master/protocol/openid-connect/token"
     data = {
         "client_id": "admin-cli",
         "username": "admin",
-        "password": KEYCLOAK_ADMIN_PASSWORD,
+        "password": config.KEYCLOAK_ADMIN_PASSWORD,
         "grant_type": "password"
     }
     try:
-        resp = requests.post(url, data, verify=CA_CERT_FILE)
+        resp = requests.post(url, data, verify=config.CA_CERT_FILE)
         if resp.status_code != 200:
             raise KeycloakError(f"Can't get OIDC token for API access: ({resp.status_code}) {resp.text[:200]}")
         return str(resp.json()['access_token'])
@@ -127,7 +122,7 @@ def kc_get_users() -> List[UserAccount]:
     :raise KeycloakError
     """
     token = kc_get_token()
-    resp = requests.get(KEYCLOAK_USERS_URL, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
+    resp = requests.get(config.KEYCLOAK_USERS_URL, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE)
     if not resp.ok:
         raise KeycloakError(f"Can't get list of users: ({resp.status_code}) {resp.text[:200]}")
     try:
@@ -147,8 +142,8 @@ def kc_get_user_by_id(userid: str) -> UserAccount:
     """
     assert re.match(r'[0-9a-z-]*', userid), "Invalid user ID"
     token = kc_get_token()
-    url = KEYCLOAK_USERS_URL + "/" + userid
-    resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
+    url = config.KEYCLOAK_USERS_URL + "/" + userid
+    resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE)
     if not resp.ok:
         raise KeycloakError(f"Can't get user info: ({resp.status_code}) {resp.text[:200]}")
     try:
@@ -167,8 +162,8 @@ def kc_get_user_by_name(username: str) -> Optional[UserAccount]:
     :raise KeycloakError
     """
     token = kc_get_token()
-    url = KEYCLOAK_USERS_URL
-    resp = requests.get(url, params={'username': username, 'exact': 'true'}, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
+    url = config.KEYCLOAK_USERS_URL
+    resp = requests.get(url, params={'username': username, 'exact': 'true'}, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE)
     if not resp.ok:
         raise KeycloakError(f"Can't get user info: ({resp.status_code}) {resp.text[:200]}")
     print(resp.text)
@@ -192,8 +187,8 @@ def kc_add_user(user: UserAccount) -> None:
 
     user_data = user.to_keycloak_representation()
     user_data["enabled"] = True # add "enable" key, since a new user must be explicitly enabled (default is False)
-    resp = requests.post(KEYCLOAK_USERS_URL, json=user_data,
-                         headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
+    resp = requests.post(config.KEYCLOAK_USERS_URL, json=user_data,
+                         headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE)
     if not resp.ok:
         raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}")
 
@@ -208,9 +203,9 @@ def kc_update_user(user: UserAccount) -> None:
     token = kc_get_token()
 
     user_data = user.to_keycloak_representation()
-    url = KEYCLOAK_USERS_URL + "/" + user.kcid
+    url = config.KEYCLOAK_USERS_URL + "/" + user.kcid
     resp = requests.put(url, json=user_data,
-                        headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
+                        headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE)
     if not resp.ok:
         raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}")
 
@@ -223,8 +218,8 @@ def kc_delete_user(userid: str) -> None:
     """
     assert re.match(r'[0-9a-z-]*', userid), "Invalid user ID"
     token = kc_get_token()
-    url = KEYCLOAK_USERS_URL + "/" + userid
-    resp = requests.delete(url, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
+    url = config.KEYCLOAK_USERS_URL + "/" + userid
+    resp = requests.delete(url, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE)
     if not resp.ok:
         raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}")
 
@@ -251,6 +246,10 @@ def main():
     except KeycloakError as e:
         flash(f"ERROR: {e}", "error")
         users = []
+    # Mark "internal" users
+    for u in users:
+        if u.username == config.MGMT_USER_NAME:
+            u.internal = True
     #print(users)
 
     # Load NiFi users
@@ -259,6 +258,11 @@ def main():
     except NifiError as e:
         flash(f"ERROR: {e}", "error")
         nifi_users = []
+    # Mark "internal" users
+    for u in nifi_users:
+        if u["name"].startswith("CN=soctools-nifi-") or u["name"] == config.MGMT_USER_NAME:
+            u["internal"] = True
+    # List of usernames only (for easier cross-check with Keycloak users)
     nifi_usernames = set(nu["name"] for nu in nifi_users)
 
     return render_template("main.html", **locals())
diff --git a/nifi.py b/nifi.py
index e9e31c6e1bf892bec20e6b472dec888fcc0e4097..d1dda6b9d421bf34c1f8118edfa03854a97bee11 100644
--- a/nifi.py
+++ b/nifi.py
@@ -1,20 +1,19 @@
 """Functions to manage user accounts in NiFi"""
 
 from typing import List, Dict, Optional
-import subprocess
-import xml.etree.ElementTree as ET
+import requests
+import re
+from operator import itemgetter
 
-from config import *
+import config
+config.SOCTOOLSPROXY = "gn4soctools3.liberouter.org"
 
-# Path to user configuration in NiFi containers
-NIFI_USER_CONFIG_PATH = "/opt/nifi/nifi-current/conf/users.xml"
-
-# Shell command to restart NiFi in the container (simple "supervisorctl restart" doesn't work, since there is another
-# nifi process which supervisord doesn't see and which stops only after some time after the main one; so we need to
-# wait until it stops as well by calling "ps" in a loop)
-#NIFI_RESTART_COMMAND = "supervisorctl stop nifi ; while (ps aux | grep '^nifi' >/dev/null); do sleep 1; done; supervisorctl start nifi"
-NIFI_RESTART_COMMAND = "bin/nifi.sh stop" # stop properly by sending a stop command. It is then restarted automatically by supervisord.
+# URL to initial login process
+NIFI_LOGIN_URL = "https://{soctools_proxy}:9443/nifi/login"
+# Base URL to NiFi API endpoints
+NIFI_API_BASE_URL = "https://{soctools_proxy}:9443/nifi-api"
 
+# NiFi API documentation: https://nifi.apache.org/docs/nifi-docs/rest-api/index.html
 
 class NifiError(Exception):
     pass
@@ -25,229 +24,113 @@ class NifiUserNotFoundError(NifiError):
 class NifiUserExistsError(NifiError):
     pass
 
-# For reference, an example NiFi user-config file looks like this:
-"""
-<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<tenants>
-    <groups>
-      <group identifier="c78caf19-016f-1000-0000-000000000001" name="NiFi nodes">
-            <user identifier="c78caf19-016f-1000-0001-000000000001"/>
-            <user identifier="c78caf19-016f-1000-0001-000000000002"/>
-            <user identifier="c78caf19-016f-1000-0001-000000000003"/>
-      </group>
-      <group identifier="c78caf19-016f-1000-0000-000000000002" name="Administrators">
-            <user identifier="c78caf19-016f-1000-0002-000000000001"/>
-            <user identifier="c78caf19-016f-1000-0002-000000000002"/>
-      </group>
-    </groups>
-    <users>
-        <user identifier="c78caf19-016f-1000-0001-000000000001" identity="CN=soctools-nifi-1"/>
-        <user identifier="c78caf19-016f-1000-0001-000000000002" identity="CN=soctools-nifi-2"/>
-        <user identifier="c78caf19-016f-1000-0001-000000000003" identity="CN=soctools-nifi-3"/>
-        <user identifier="c78caf19-016f-1000-0002-000000000001" identity="user1"/>
-        <user identifier="c78caf19-016f-1000-0002-000000000002" identity="user2"/>
-    </users>
-</tenants>
-"""
-
-
-def _nifi_xml_get_users(config_xml: str) -> List[Dict]:
-    """Parse the XML file and return list of users.
-
-    Return: list of dicts with keys 'name', 'id', 'group'
-    """
-    config_root = ET.fromstring(config_xml)
-    # Load groups and store group name for each user ID
-    user_to_group = {} # dict: user_id -> group_name
-    for group_elem in config_root.find("groups").findall("group"):
-        g_name = group_elem.get("name")
-        for user_elem in group_elem.findall("user"):
-            user_to_group[user_elem.get("identifier")] = g_name
-    # Load users, find the group for each one
-    users = [] # list of dicts with keys 'name' (identity), 'id' (identifier), 'group'
-    for user_elem in config_root.findall("./users/user"):
-        users.append({
-            "name": user_elem.get("identity"),
-            "id": user_elem.get("identifier"),
-            "group": user_to_group[user_elem.get("identifier")],
-        })
-    return users
-
+class NifiUnexpectedReplyError(NifiError):
+    pass
 
-def _nifi_xml_add_user(config_xml: str, user_name: str, user_group: str) -> str:
-    """Add given user to the XML config file.
+def _nifi_get_jwt() -> str:
+    """
+    Get OIDC token (JWT) for authenticating API requests
 
-    Assumes that "user_group" already exists in the file.
+    Simulate standard login process like a user would do via browser.
+    It would be better to use "Resource Owner Password Credentials Grant" flow (or "Direct Access Grant" as Keycloak
+    calls it), which is more suitable for machine clients. However, when such flow is used, Keycloak generates
+    a differently formatted JWT token (don't know why), which NiFi doesn't accept.
+    I tried everything to make it work, but unsuccessfully. Therefore, the standard flow is used, which is more
+    complicated, but works whenever the normal user login works.
 
-    :return updated xml string
+    :return JWT token
+    :raise NifiUnexpectedReplyError
     """
-    config_root = ET.fromstring(config_xml)
-    # Get info about the given group
-    group_node = config_root.find(f"./groups/group[@name='{user_group}']")
-    group_user_nodes = group_node.findall("user")
-    # Get list of users
-    users_node = config_root.find("./users")
-    # check that there is not a user with the same username
-    if any(u.get("name") == user_name for u in users_node):
-        raise NifiUserExistsError(f"Username '{user_name}' already exists!")
-
-    # Generate new user identifier as the max id of the group plus one
-    #   ids look like: abcd1234-01ab-1000-0002-000000000001 (the last part seems to be the index of the user within the group)
-    user_ids = [u.get("identifier") for u in group_user_nodes]
-    max_id = max(user_ids)
-    group_id,last_user_id = max_id.rsplit("-", 1)
-    new_id = f"{group_id}-{int(last_user_id)+1:012d}"
-
-    # Add a new element the list of users in the group and the list of users
-    group_node.append(ET.Element("user", identifier=new_id))
-    users_node.append(ET.Element("user", identifier=new_id, identity=user_name))
-
-    return ET.tostring(config_root, encoding='utf-8')
-
-def _nifi_xml_delete_user(config_xml: str, user_name: str) -> str:
-    """Remove given user from the XML config file.
-
-    Assumes that "user_group" already exists in the file.
-
-    :return updated xml string
-    """
-    config_root = ET.fromstring(config_xml)
-    # Find user with given name
-    user_node = config_root.find(f"./users/user[@identity='{user_name}']")
-    if user_node is None:
-        raise NifiUserNotFoundError(f"Can't delete user '{user_name}' from NiFi: User with such a name doesn't exist.")
-    # Get user's numerical id
-    identifier = user_node.get('identifier')
-
-    # Remove the user from <users> and from any groups
-    print("identifier:", identifier)
-    print("user_node:", user_node)
-    config_root.find("./users").remove(user_node)
-    for group_node in config_root.findall("./groups/"):
-        print("group_node:", group_node)
-        user_node = group_node.find(f"user[@identifier='{identifier}']")
-        print("  user_node:", user_node)
-        if user_node is not None:
-            group_node.remove(user_node)
-
-    return ET.tostring(config_root, encoding='utf-8')
-
-
-def _nifi_load_user_config(cont_name: str) -> str:
-    """Get the current user-config file from a NiFi docker container"""
-    #print("Getting NiFi config...")
-    result = subprocess.run(["docker", "exec", cont_name, "cat", NIFI_USER_CONFIG_PATH],
-                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-    if result.returncode == 0:
-        config_xml = result.stdout.decode('utf-8')
-        #print(config_xml)
-        return config_xml
-    else:
-        raise NifiError(f'Error when trying to get the current config of NiFi users from container "{cont_name}": {result.stderr.decode()}')
-
-
-def _nifi_write_user_config(new_xml: str):
-    """Write given XML string to the user-config file in all NiFi containers, restart NiFi in all containers."""
-    # Write new file into all containers
-    for i,cont_name in enumerate(NIFI_CONTAINERS):
-        # Run a command to write the new user-config file inside the NiFi docker container
-        # The file contents are passed via stdin ("docker exec -i" must be used for stdin to work), "tee" is used to
-        # store it to a file (stdout is ignored)
-        print(f'Writing new NiFi user config in container "{cont_name}"')
-        result = subprocess.run(["docker", "exec", "-i", cont_name, "tee", NIFI_USER_CONFIG_PATH],
-                                input=new_xml, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
-        if result.returncode != 0:
-            raise NifiError(f'Error when trying to write the updated list of NiFi users into container "{cont_name}": {result.stderr.decode()}')
-    # Restart all containers (may take a long time!)
-    for i,cont_name in enumerate(NIFI_CONTAINERS):
-        print(f'Restarting NiFi in container "{cont_name}"')
-        result = subprocess.run(["docker", "exec", cont_name, "bash", "-c", NIFI_RESTART_COMMAND],
-                                stderr=subprocess.PIPE)
-        if result.returncode != 0:
-            raise NifiError(f'Error when trying to restart NiFi after config update in container "{cont_name}": {result.stderr.decode()}')
+    # We will need to store some cookies - create a session which will automatically handle it
+    # Also, set path to certificates
+    session = requests.Session()
+    session.verify = config.CA_CERT_FILE
+    session.cert = (config.MGMT_USER_CERT_PATH, config.MGMT_USER_KEY_PATH)
+
+    # Initiate login process by querying the NiFi login page.
+    # NiFi should set the 'nifi-oidc-request-identifier' cookie (stored into session, will be needed later) and
+    # redirect us to Keycloak. The redirection is automatically followed by requests.get().
+    # Keycloak should authenticate us using the provided certificate and present a web page with confirmation form.
+    url = NIFI_LOGIN_URL.format(soctools_proxy=config.SOCTOOLSPROXY)
+    resp = session.get(url, allow_redirects=True)
+    if not resp.ok:
+        raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP status code ({resp.status_code}) from URL '{url}'.")
+    # Parse the returned web page, find the form with id="kc-x509-login-info" and takes URL from its "action" attribute.
+    re_get_from_url = r'<form [^>]*id="kc-x509-login-info"[^>]* action="([^"]*)"'
+    match = re.search(re_get_from_url, resp.text)
+    if not match:
+        # try to get error message
+        match2 = re.search(r'<span class="kc-feedback-text">(.*?)</span>', resp.text)
+        if match2:
+            raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP reply content (can't find the x509 login form). It contains the following, probably an error message: {match2.group(1)}.")
+        else:
+            raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP reply content (can't find the x509 login from). Queried URL: {resp.url}")
+    url = match.group(1)
+    url = url.replace("&amp;", "&")
+    # Send POST request to the URL to simulate clicking the "Continue" button
+    # Keycloak should redirect us to NiFi's callback URL. Requests automatically follow this redirection, so we should
+    # receive 200 OK from NiFi, whose content is the JWT
+    data = "login=Continue"
+    headers = {"Content-Type": "application/x-www-form-urlencoded"}
+    resp = session.post(url, data=data, headers=headers, allow_redirects=True)
+    if not resp.ok:
+        raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP status code ({resp.status_code}) from URL '{url}'.")
+
+    # Now, we are authenticated to NiFi, identified by a cookie (stored within our session object).
+    # Use the cookie and ask for the JWT token we need for API requests
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/access/oidc/exchange"
+    print(f"_nifi_get_jwt: POST request to: {url}")
+    resp = session.post(url) # POST must be used even though no data are being sent
+    if not resp.ok:
+        raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP status code ({resp.status_code}) from URL '{url}'.")
+    return resp.text
+
 
 
 def nifi_get_users() -> List[Dict]:
     """
     List users defined in NiFi
+
+    :return List of dicts with keys 'id', 'name', 'groups' (list of group names)
+    :raise NifiUnexpectedReplyError
     """
-    prev_users = None
-    for i,cont_name in enumerate(NIFI_CONTAINERS):
-        # Get the current user-config file
-        config_xml = _nifi_load_user_config(cont_name)
-        if config_xml is False:
-            return []
-
-        # Parse the list of users from the config file
-        try:
-            users = _nifi_xml_get_users(config_xml)
-        except Exception as e:
-            raise NifiError(f'Can\'t parse NiFi user config file ("{NIFI_USER_CONFIG_PATH}" in container "{cont_name}"): {e}')
-
-        # Remove "internal" users from the "NiFi nodes" group
-        users = [u for u in users if u["group"] != "NiFi nodes"]
-        # TODO this way, the comparison forces the order of users to be the same, which is not needed (although
-        #  it is always the same in normal situation)
-        # Check that the list is the same as in the previous container (all should be the same)
-        if prev_users is not None and users != prev_users:
-            raise NifiError('Error when trying to get the list of NiFi users: The lists of users differ in at least '
-                            f'two NiFi nodes ({NIFI_CONTAINERS[i-1]},{NIFI_CONTAINERS[i]}). Check the file '
-                            f'"{NIFI_USER_CONFIG_PATH}" in each NiFi container, they all must be the same.')
-        prev_users = users
-    return prev_users
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/users"
+    token = _nifi_get_jwt()
+    resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE)
+    if not resp.ok:
+        raise NifiUnexpectedReplyError(f"Can't get list of users from NiFi: Unexpected reply {resp.status_code}")
+    users = []
+    try:
+        raw_users = resp.json()['users']
+        #print(raw_users)
+        for user in raw_users:
+            users.append({
+                'id': user["component"]["id"],
+                'name': user["component"]["identity"],
+                'groups': [g["component"]["identity"] for g in user["component"]["userGroups"]],
+            })
+        users.sort(key=itemgetter('name'))
+        #print(users)
+    except (ValueError, TypeError, KeyError):
+        raise NifiUnexpectedReplyError(f"Can't get list of users from NiFi: Unexpected content received")
+    return users
 
 
 def nifi_add_user(user: 'UserAccount'):
     """Add a new user to NiFi
 
-    Read user config from a NiFi container (the first one), add a new user it, and write it in all containers (overwrite!).
-
     :raises NifiError
     """
     user_name = user.username
     user_group = "Administrators" # no support for other groups in NiFi, yet
 
-    # Get the current user-config file (use the first container, the content should be the same in all)
-    cont_name = NIFI_CONTAINERS[0]
-    config_xml = _nifi_load_user_config(cont_name)
-
-    #Add new user to the XML
-    try:
-        new_xml = _nifi_xml_add_user(config_xml, user_name, user_group)
-    except NifiUserExistsError:
-        raise
-    except Exception as e:
-        raise NifiError(f'Can\'t add user to the config file ("{NIFI_USER_CONFIG_PATH}" in container "{cont_name}"): {e}')
-
-    #print("XML with added user:")
-    #print(new_xml)
-
-    # Write the updated config and restart NiFi nodes
-    _nifi_write_user_config(new_xml)
+    # TODO
 
 
 def nifi_delete_user(user_name: str):
-    """Add a new user to NiFi
-
-    Read user config from a NiFi container (the first one), remove the specified user, and write new version in all containers (overwrite!).
+    """Delete a user from NiFi
 
     :raises NifiError
     """
-    # Get the current user-config file (use the first container, the content should be the same in all)
-    cont_name = NIFI_CONTAINERS[0]
-    config_xml = _nifi_load_user_config(cont_name)
-
-    # Remove user from the XML
-    try:
-        new_xml = _nifi_xml_delete_user(config_xml, user_name)
-    except NifiUserNotFoundError:
-        raise
-    except Exception as e:
-        raise NifiError(f'Can\'t remove user from config file ("{NIFI_USER_CONFIG_PATH}" in container "{cont_name}"): {e}')
-
-    #print("XML with deleted user:")
-    #print(new_xml)
-
-    # Write the updated config and restart NiFi nodes
-    _nifi_write_user_config(new_xml)
 
+    # TODO
diff --git a/static/style.css b/static/style.css
index 583edb1454183b76ceea78e0250ddf742dd55494..fc7ab0e6a56153250dde8a2fb43d326fb098b873 100644
--- a/static/style.css
+++ b/static/style.css
@@ -42,4 +42,8 @@ li.flash-success {
 
 input[readonly] {
   background-color: #ddd;
-}
\ No newline at end of file
+}
+
+.internal-user {
+  color: #ccc;
+}
diff --git a/templates/main.html b/templates/main.html
index 1a95b2ade677ed0c6be094153fab1edd7d83170b..ec7de66efc0b9ab8691850a799e104e5759a889c 100644
--- a/templates/main.html
+++ b/templates/main.html
@@ -7,7 +7,7 @@
 <tr><th>Username</th><th>First name</th><th>Last name</th><th>email</th><th>CN</th><th>DN</th><th>Time created (UTC)</th><th>NiFi</th><th></th>
 
 {% for user in users %}
-<tr>
+<tr{% if user.internal %} class="internal-user"{% endif %}>
 <td>{{ user.username }}</td>
 <td>{{ user.firstname }}</td>
 <td>{{ user.lastname }}</td>
@@ -17,9 +17,11 @@
 <td>{{ user.ts_created.isoformat() }}</td>
 <td>{{ icon('check' if user.username in nifi_usernames else 'close') }}</td>
 <td>
+{% if not user.internal -%}
 <a href="{{ url_for('edit_user', username=user.username) }}" title="Edit user">{{ icon('pencil') }}</a>
 <a href="{{ url_for('delete_user', username=user.username) }}" title="Delete user"
  onclick="return confirm('Are you sure you want to permanently delete user account &quot;{{user.username}}&quot; ({{user.cn}}, {{user.email}})?')">{{ icon('trash') }}</a>
+{%- endif %}
 </td>
 </tr>
 {#<tr><td colspan=8>{{ user }}</td></tr>#}
@@ -33,9 +35,9 @@
 <tr><th>Username</th><th>Group</th><th>ID</th>
 
 {% for user in nifi_users %}
-<tr>
+<tr{% if user.internal %} class="internal-user"{% endif %}>
 <td>{{ user.name }}</td>
-<td>{{ user.group }}</td>
+<td>{{ user.groups|join(',') }}</td>
 <td>{{ user.id }}</td>
 </tr>
 {% endfor %}