Browse Source

bugfix, profile picture WIP, profile frontend refactored

Roman 2 weeks ago
parent
commit
29c72d13e7

+ 16 - 0
.idea/php.xml

@@ -1,6 +1,22 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
+  <component name="MessDetectorOptionsConfiguration">
+    <option name="transferred" value="true" />
+  </component>
+  <component name="PHPCSFixerOptionsConfiguration">
+    <option name="transferred" value="true" />
+  </component>
+  <component name="PHPCodeSnifferOptionsConfiguration">
+    <option name="highlightLevel" value="WARNING" />
+    <option name="transferred" value="true" />
+  </component>
   <component name="PhpProjectSharedConfiguration" php_language_level="8.1">
     <option name="suggestChangeDefaultLanguageLevel" value="false" />
   </component>
+  <component name="PhpStanOptionsConfiguration">
+    <option name="transferred" value="true" />
+  </component>
+  <component name="PsalmOptionsConfiguration">
+    <option name="transferred" value="true" />
+  </component>
 </project>

+ 2 - 7
Core/API/TfaAPI.class.php

@@ -18,11 +18,7 @@ namespace Core\API {
     }
 
     protected function verifyAuthData(AuthenticationData $authData): bool {
-      $settings = $this->context->getSettings();
-      // $relyingParty = $settings->getSiteName();
-      $domain = parse_url($settings->getBaseUrl(),  PHP_URL_HOST);
-      // $domain = "localhost";
-
+      $domain = getCurrentHostName();
       if (!$authData->verifyIntegrity($domain)) {
         return $this->createError("mismatched rpIDHash. expected: " . hash("sha256", $domain) . " got: " . bin2hex($authData->getHash()));
       } else if (!$authData->isUserPresent()) {
@@ -264,7 +260,7 @@ namespace Core\API\TFA {
       $settings = $this->context->getSettings();
       $relyingParty = $settings->getSiteName();
       $sql = $this->context->getSQL();
-      $domain = parse_url($settings->getBaseUrl(),  PHP_URL_HOST);
+      $domain = getCurrentHostName();
 
       if (!$clientDataJSON || !$attestationObjectRaw) {
         $challenge = null;
@@ -297,7 +293,6 @@ namespace Core\API\TFA {
 
         $this->result["data"] = [
           "challenge" => $challenge,
-          "id" => $currentUser->getId() . "@" . $domain, // <userId>@<domain>
           "relyingParty" => [
             "name" => $relyingParty,
             "id" => $domain

+ 36 - 30
Core/API/UserAPI.class.php

@@ -192,7 +192,7 @@ namespace Core\API\User {
         foreach ($requestedGroups as $groupId) {
           if (!isset($availableGroups[$groupId])) {
             return $this->createError("Group with id=$groupId does not exist.");
-          } else if ($this->externalCall && $groupId === Group::ADMIN && !$currentUser->hasGroup(Group::ADMIN)) {
+          } else if ($this->isExternalCall() && $groupId === Group::ADMIN && !$currentUser->hasGroup(Group::ADMIN)) {
             return $this->createError("You cannot create users with administrator groups.");
           } else {
             $groups[] = $groupId;
@@ -1311,38 +1311,43 @@ namespace Core\API\User {
         return $this->createError("Error saving user token: " . $sql->getLastError());
       }
 
-      $name = htmlspecialchars($currentUser->getFullName());
-      if (!$name) {
-        $name = htmlspecialchars($currentUser->getUsername());
-      }
-
+      $validHours = 1;
       $settings = $this->context->getSettings();
-      $siteName = htmlspecialchars($settings->getSiteName());
-      $baseUrl = htmlspecialchars($settings->getBaseUrl());
-      $token = htmlspecialchars(urlencode($token));
-      $url = "$baseUrl/confirmGPG?token=$token";
-      $mailBody = "Hello $name,<br><br>" .
-        "you imported a GPG public key for end-to-end encrypted mail communication. " .
-        "To confirm the key and verify, you own the corresponding private key, please click on the following link. " .
-        "The link is active for one hour.<br><br>" .
-        "<a href='$url'>$url</a><br>Best Regards<br>$siteName Administration";
-
-      $sendMail = new \Core\API\Mail\Send($this->context);
-      $this->success = $sendMail->execute(array(
-        "to" => $currentUser->getEmail(),
-        "subject" => "[$siteName] Confirm GPG-Key",
-        "body" => $mailBody,
-        "gpgFingerprint" => $gpgKey->getFingerprint()
-      ));
+      $baseUrl = $settings->getBaseUrl();
+      $siteName = $settings->getSiteName();
+      $req = new Render($this->context);
+      $this->success = $req->execute([
+        "file" => "mail/gpg_import.twig",
+        "parameters" => [
+          "link" => "$baseUrl/resetPassword?token=$token",
+          "site_name" => $siteName,
+          "base_url" => $baseUrl,
+          "username" => $currentUser->getDisplayName(),
+          "valid_time" => $this->formatDuration($validHours, "hour")
+        ]
+      ]);
 
-      $this->lastError = $sendMail->getLastError();
+      $this->lastError = $req->getLastError();
 
       if ($this->success) {
-        $currentUser->gpgKey = $gpgKey;
-        if ($currentUser->save($sql, ["gpgKey"])) {
-          $this->result["gpgKey"] = $gpgKey->jsonSerialize();
-        } else {
-          return $this->createError("Error updating user details: " . $sql->getLastError());
+        $messageBody = $req->getResult()["html"];
+        $sendMail = new \Core\API\Mail\Send($this->context);
+        $this->success = $sendMail->execute(array(
+          "to" => $currentUser->getEmail(),
+          "subject" => "[$siteName] Confirm GPG-Key",
+          "body" => $messageBody,
+          "gpgFingerprint" => $gpgKey->getFingerprint()
+        ));
+
+        $this->lastError = $sendMail->getLastError();
+
+        if ($this->success) {
+          $currentUser->gpgKey = $gpgKey;
+          if ($currentUser->save($sql, ["gpgKey"])) {
+            $this->result["gpgKey"] = $gpgKey->jsonSerialize();
+          } else {
+            return $this->createError("Error updating user details: " . $sql->getLastError());
+          }
         }
       }
 
@@ -1505,6 +1510,7 @@ namespace Core\API\User {
 
   class UploadPicture extends UserAPI {
     public function __construct(Context $context, bool $externalCall = false) {
+      // TODO: we should optimize the process here, we need an offset and size parameter to get a quadratic crop of the uploaded image
       parent::__construct($context, $externalCall, [
         "scale" => new Parameter("scale", Parameter::TYPE_FLOAT, true, NULL),
       ]);
@@ -1515,7 +1521,7 @@ namespace Core\API\User {
     /**
      * @throws ImagickException
      */
-    protected function onTransform(\Imagick $im, $uploadDir) {
+    protected function onTransform(\Imagick $im, $uploadDir): bool|string {
 
       $minSize = 75;
       $maxSize = 500;

+ 18 - 12
Core/Driver/SQL/MySQL.class.php

@@ -52,22 +52,28 @@ class MySQL extends SQL {
       return true;
     }
 
-    $this->connection = @mysqli_connect(
-      $this->connectionData->getHost(),
-      $this->connectionData->getLogin(),
-      $this->connectionData->getPassword(),
-      $this->connectionData->getProperty('database'),
-      $this->connectionData->getPort()
-    );
-
-    if (mysqli_connect_errno()) {
+    try {
+      $this->connection = @mysqli_connect(
+        $this->connectionData->getHost(),
+        $this->connectionData->getLogin(),
+        $this->connectionData->getPassword(),
+        $this->connectionData->getProperty('database'),
+        $this->connectionData->getPort()
+      );
+
+      if (mysqli_connect_errno()) {
+        $this->lastError = $this->logger->severe("Failed to connect to MySQL: " . mysqli_connect_error());
+        $this->connection = NULL;
+        return false;
+      }
+
+      mysqli_set_charset($this->connection, $this->connectionData->getProperty('encoding', 'UTF8'));
+      return true;
+    } catch (\Exception $ex) {
       $this->lastError = $this->logger->severe("Failed to connect to MySQL: " . mysqli_connect_error());
       $this->connection = NULL;
       return false;
     }
-
-    mysqli_set_charset($this->connection, $this->connectionData->getProperty('encoding', 'UTF8'));
-    return true;
   }
 
   public function disconnect() {

+ 26 - 15
Core/Localization/de_DE/account.php

@@ -26,33 +26,18 @@ return [
   "username" => "Benutzername",
   "username_or_email" => "Benutzername oder E-Mail",
   "email" => "E-Mail Adresse",
-  "password" => "Passwort",
-  "password_confirm" => "Passwort bestätigen",
-  "password_old" => "Altes Passwort",
-  "password_new" => "Neues Passwort",
   "full_name" => "Voller Name",
   "remember_me" => "Eingeloggt bleiben",
   "signing_in" => "Einloggen",
   "sign_in" => "Einloggen",
   "confirmed" => "Bestätigt",
-  "forgot_password" => "Passwort vergessen?",
-  "change_password" => "Passwort ändern",
-  "passwords_do_not_match" => "Die Passwörter stimmen nicht überein",
   "back_to_login" => "Zurück zum Login",
   "register_text" => "Noch keinen Account? Jetzt registrieren",
-  "6_digit_code" => "6-stelliger Code",
-  "2fa_title" => "Es werden weitere Informationen zum Einloggen benötigt",
-  "2fa_text" => "Stecke dein 2FA-Gerät ein. Möglicherweise wird noch eine Interaktion benötigt, z.B. durch Eingabe einer PIN oder durch Berühren des Geräts",
   "confirming_email" => "Bestätige E-Mail Adresse",
   "proceed_to_login" => "Weiter zum Login",
   "invalid_link" => "Den Link den Sie besucht haben ist nicht länger gültig",
   "confirm_success" => "Ihre E-Mail Adresse wurde erfolgreich bestätigt, Sie können sich jetzt einloggen",
   "confirm_error" => "Fehler beim Bestätigen der E-Mail Adresse",
-  "gpg_key" => "GPG-Schlüssel",
-  "no_gpg_key_added" => "Kein GPG-Schlüssel hinzugefügt",
-  "download_gpg_key" => "GPG-Schlüssel herunterladen",
-  "2fa_token" => "Zwei-Faktor Authentifizierung (2FA)",
-  "profile_picture_of" => "Profilbild von",
   "profile_of" => "Profil von",
   "language" => "Sprache",
   "profile" => "Benutzerprofil",
@@ -71,6 +56,10 @@ return [
   "group" => "Gruppe",
   "no_members" => "Keine Mitglieder in dieser Gruppe",
 
+  # profile picture
+  "change_picture" => "Profilbild ändern",
+  "profile_picture_of" => "Profilbild von",
+
   # dialogs
   "fetch_group_members_error" => "Fehler beim Holen der Gruppenmitglieder",
   "remove_group_member_error" => "Fehler beim Entfernen des Gruppenmitglieds",
@@ -85,11 +74,18 @@ return [
   "remove_group_member_text" => "Möchten Sie wirklich den Benutzer '%s' von dieser Gruppe entfernen?",
   "add_group_member_title" => "Mitglied hinzufügen",
   "add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen",
+  "edit_profile" => "Profil bearbeiten",
 
   # GPG Key
   "gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
+  "gpg_key" => "GPG-Schlüssel",
+  "no_gpg_key_added" => "Kein GPG-Schlüssel hinzugefügt",
+  "download_gpg_key" => "GPG-Schlüssel herunterladen",
 
   # 2fa
+  "2fa_title" => "Es werden weitere Informationen zum Einloggen benötigt",
+  "2fa_text" => "Stecke dein 2FA-Gerät ein. Möglicherweise wird noch eine Interaktion benötigt, z.B. durch Eingabe einer PIN oder durch Berühren des Geräts",
+  "2fa_token" => "Zwei-Faktor Authentifizierung (2FA)",
   "2fa_type_totp" => "Zeitbasiertes 2FA (TOTP)",
   "2fa_type_fido" => "Schlüsselbasiertes 2FA",
   "register_2fa_device" => "Ein 2FA-Gerät registrieren",
@@ -98,4 +94,19 @@ return [
   "register_2fa_fido_text" => "Möglicherweise musst du mit dem Gerät interagieren, zum Beispiel durch Eingeben einer PIN oder durch Berühren des Geräts",
   "remove_2fa" => "2FA-Token entfernen",
   "remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen",
+  "6_digit_code" => "6-stelliger Code",
+
+  # password
+  "password" => "Passwort",
+  "password_confirm" => "Passwort bestätigen",
+  "password_old" => "Altes Passwort",
+  "password_new" => "Neues Passwort",
+  "forgot_password" => "Passwort vergessen?",
+  "change_password" => "Passwort ändern",
+  "passwords_do_not_match" => "Die Passwörter stimmen nicht überein",
+  "password_very_strong" => "Sehr stark",
+  "password_strong" => "Stark",
+  "password_ok" => "OK",
+  "password_weak" => "Schwach",
+  "password_very_weak" => "Sehr schwach",
 ];

+ 25 - 14
Core/Localization/en_US/account.php

@@ -26,33 +26,19 @@ return [
   "username" => "Username",
   "username_or_email" => "Username or E-Mail",
   "email" => "E-Mail Address",
-  "password" => "Password",
-  "password_confirm" => "Confirm Password",
-  "password_old" => "Old Password",
-  "password_new" => "New Password",
   "full_name" => "Full Name",
   "remember_me" => "Remember Me",
   "signing_in" => "Signing in",
   "sign_in" => "Sign In",
   "confirmed" => "Confirmed",
-  "forgot_password" => "Forgot password?",
-  "change_password" => "Change password",
   "register_text" => "Don't have an account? Sign Up",
   "passwords_do_not_match" => "Your passwords did not match",
   "back_to_login" => "Back to Login",
-  "6_digit_code" => "6-Digit Code",
-  "2fa_title" => "Additional information is required for logging in",
-  "2fa_text" => "Plugin your 2FA-Device. Interaction might be required, e.g. typing in a PIN or touching it.",
   "confirming_email" => "Confirming email",
   "proceed_to_login" => "Proceed to Login",
   "invalid_link" => "The link you visited is no longer valid",
   "confirm_success" => "Your e-mail address was successfully confirmed, you may now log in",
   "confirm_error" => "Error confirming e-mail address",
-  "gpg_key" => "GPG Key",
-  "no_gpg_key_added" => "No GPG key added",
-  "download_gpg_key" => "Download GPG Key",
-  "2fa_token" => "Two-Factor Authentication (2FA)",
-  "profile_picture_of" => "Profile Picture of",
   "profile_of" => "Profile of",
   "language" => "Language",
   "profile" => "User Profile",
@@ -70,6 +56,11 @@ return [
   "active" => "Active",
   "group" => "Group",
   "no_members" => "No members in this group",
+  "edit_profile" => "Edit Profile",
+
+  # profile picture
+  "change_picture" => "Change profile picture",
+  "profile_picture_of" => "Profile Picture of",
 
   # dialogs
   "fetch_group_members_error" => "Error fetching group members",
@@ -87,9 +78,15 @@ return [
   "add_group_member_text" => "Search a user to add to the group",
 
   # GPG Key
+  "gpg_key" => "GPG Key",
+  "no_gpg_key_added" => "No GPG key added",
+  "download_gpg_key" => "Download GPG Key",
   "gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
 
   # 2fa
+  "2fa_title" => "Additional information is required for logging in",
+  "2fa_text" => "Plugin your 2FA-Device. Interaction might be required, e.g. typing in a PIN or touching it.",
+  "2fa_token" => "Two-Factor Authentication (2FA)",
   "2fa_type_totp" => "Time-Based 2FA (TOTP)",
   "2fa_type_fido" => "Key-Based 2FA",
   "register_2fa_device" => "Register a 2FA-Device",
@@ -98,4 +95,18 @@ return [
   "register_2fa_fido_text" => "You may need to interact with your Device, e.g. typing in your PIN or touching to confirm the registration.",
   "remove_2fa" => "Remove 2FA Token",
   "remove_2fa_text" => "Enter your current password to confirm the removal of your 2FA Token",
+  "6_digit_code" => "6-Digit Code",
+
+  # password
+  "password" => "Password",
+  "password_confirm" => "Confirm Password",
+  "password_old" => "Old Password",
+  "password_new" => "New Password",
+  "forgot_password" => "Forgot password?",
+  "change_password" => "Change password",
+  "password_very_strong" => "Very strong",
+  "password_strong" => "Strong",
+  "password_ok" => "OK",
+  "password_weak" => "Weak",
+  "password_very_weak" => "Very weak",
 ];

+ 3 - 2
Core/Objects/Context.class.php

@@ -4,6 +4,7 @@ namespace Core\Objects;
 
 use Core\Configuration\Configuration;
 use Core\Configuration\Settings;
+use Core\Driver\SQL\Column\Column;
 use Core\Driver\SQL\Condition\Compare;
 use Core\Driver\SQL\Condition\CondLike;
 use Core\Driver\SQL\Condition\CondOr;
@@ -125,9 +126,9 @@ class Context {
     if ($this->sql) {
       $language = Language::findBy(Language::createBuilder($this->sql, true)
         ->where(new CondOr(
-            new CondLike("name", "%$lang%"), // english
+            new CondLike(new Column("name"), "%$lang%"), // english
             new Compare("code", $lang), // de_DE
-            new CondLike("code", "{$lang}_%") // de -> de_%
+            new CondLike(new Column("code"), "{$lang}_%") // de -> de_%
         ))
       );
       if ($language) {

+ 2 - 1
Core/Objects/TwoFactor/AuthenticationData.class.php

@@ -38,8 +38,9 @@ class AuthenticationData extends ApiObject {
       $credentialIdLength = unpack("n",  substr($buffer, $offset, 4))[1]; $offset += 2;
       $this->credentialID = substr($buffer, $offset, $credentialIdLength); $offset += $credentialIdLength;
 
-      if ($offset < $bufferLength) {
+      if ($bufferLength > $offset) {
         $publicKeyData = $this->decode(substr($buffer, $offset));
+        var_dump($publicKeyData);
         $this->publicKey = new PublicKey($publicKeyData);
         // TODO: we should add $publicKeyData->length to $offset, but it's not implemented yet?;
       }

+ 9 - 0
Core/Templates/mail/gpg_import.twig

@@ -0,0 +1,9 @@
+Hello {{ username }},<br>
+you imported a GPG public key for end-to-end encrypted mail communication on {{ site_name }}. <br>
+To confirm the key and verify, you own the corresponding private key, please click on the following link.
+The link is active for one hour:<br><br>
+
+<a href="{{ link }}">{{ link }}</a><br><br>
+
+Best Regards<br>
+{{ site_name }} Administration

+ 21 - 5
cli.php

@@ -268,12 +268,15 @@ function onMaintenance(array $argv): void {
   $action = $argv[2] ?? "status";
   $maintenanceFile = "MAINTENANCE";
   $isMaintenanceEnabled = file_exists($maintenanceFile);
+  $sql = connectSQL();
+  $logger = new \Core\Driver\Logger\Logger("CLI", $sql);
 
   if ($action === "status") {
     _exit("Maintenance: " . ($isMaintenanceEnabled ? "on" : "off"));
   } else if ($action === "on") {
     $file = fopen($maintenanceFile, 'w') or _exit("Unable to create maintenance file");
     fclose($file);
+    $logger->info("Maintenance mode enabled");
     _exit("Maintenance enabled");
   } else if ($action === "off") {
     if (file_exists($maintenanceFile)) {
@@ -281,13 +284,15 @@ function onMaintenance(array $argv): void {
         _exit("Unable to delete maintenance file");
       }
     }
+    $logger->info("Maintenance mode disabled");
     _exit("Maintenance disabled");
   } else if ($action === "update") {
-
+    $logger->info("Update started");
     $oldPatchFiles = glob('Core/Configuration/Patch/*.php');
     printLine("$ git remote -v");
     exec("git remote -v", $gitRemote, $ret);
     if ($ret !== 0) {
+      $logger->warning("Update stopped. git remote returned:\n" . implode("\n", $gitRemote));
       die();
     }
 
@@ -304,12 +309,14 @@ function onMaintenance(array $argv): void {
     printLine("$ git fetch " . str_replace("/", " ", $pullBranch));
     exec("git fetch " . str_replace("/", " ", $pullBranch), $gitFetch, $ret);
     if ($ret !== 0) {
+      $logger->warning("Update stopped. git fetch returned:\n" . implode("\n", $gitFetch));
       die();
     }
 
     printLine("$ git log HEAD..$pullBranch --oneline");
     exec("git log HEAD..$pullBranch --oneline", $gitLog, $ret);
     if ($ret !== 0) {
+      $logger->warning("Update stopped. git log returned:\n" . implode("\n", $gitLog));
       die();
     } else if (count($gitLog) === 0) {
       _exit("Already up to date.");
@@ -319,12 +326,14 @@ function onMaintenance(array $argv): void {
     printLine("$ git diff-index --quiet HEAD --"); // check for any uncommitted changes
     exec("git diff-index --quiet HEAD --", $gitDiff, $ret);
     if ($ret !== 0) {
+      $logger->warning("Update stopped due to uncommitted changes");
       _exit("You have uncommitted changes. Please commit them before updating.");
     }
 
     // enable maintenance mode if it wasn't turned on before
     if (!$isMaintenanceEnabled) {
       printLine("Turning on maintenance mode");
+      $logger->info("Maintenance mode enabled");
       $file = fopen($maintenanceFile, 'w') or _exit("Unable to create maintenance file");
       fclose($file);
     }
@@ -338,6 +347,7 @@ function onMaintenance(array $argv): void {
       printLine("Follow the instructions and afterwards turn off the maintenance mode again using:");
       printLine("cli.php maintenance off");
       printLine("Also don't forget to apply new database patches using: cli.php db migrate");
+      $logger->error("Update stopped. git pull returned:\n" . implode("\n", $gitPull));
       die();
     }
 
@@ -345,15 +355,17 @@ function onMaintenance(array $argv): void {
     $newPatchFiles = glob('Core/Configuration/Patch/*.php');
     $newPatchFiles = array_diff($newPatchFiles, $oldPatchFiles);
     if (count($newPatchFiles) > 0) {
-      printLine("Applying new database patches");
-      $sql = connectSQL();
       if ($sql) {
+        printLine("Applying new database patches");
         foreach ($newPatchFiles as $patchFile) {
           if (preg_match("/Core\/Configuration\/(Patch\/.*)\.class\.php/", $patchFile, $match)) {
             $patchName = $match[1];
             applyPatch($sql, $patchName);
           }
         }
+      } else {
+        printLine("Cannot apply database patches, since the database connection failed.");
+        $logger->warning("Cannot apply database patches, since the database connection failed.");
       }
     }
 
@@ -363,9 +375,13 @@ function onMaintenance(array $argv): void {
       if (file_exists($maintenanceFile)) {
         if (!unlink($maintenanceFile)) {
           _exit("Unable to delete maintenance file");
+        } else {
+          $logger->info("Maintenance mode disabled");
         }
       }
     }
+
+    $logger->info("Update completed.");
   } else {
     _exit("Usage: cli.php maintenance <status|on|off|update>");
   }
@@ -953,8 +969,8 @@ $argv = $_SERVER['argv'];
 $registeredCommands = [
   "help" => ["handler" => "printHelp", "description" => "prints this help page"],
   "db" => ["handler" => "handleDatabase", "description" => "database actions like importing, exporting and shell"],
-  "routes" => ["handler" => "onRoutes", "description" => "view and modify routes"],
-  "maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode"],
+  "routes" => ["handler" => "onRoutes", "description" => "view and modify routes", "requiresDocker" => true],
+  "maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode", "requiresDocker" => true],
   "test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true],
   "mail" => ["handler" => "onMail", "description" => "send mails and process the pipeline", "requiresDocker" => true],
   "settings" => ["handler" => "onSettings", "description" => "change and view settings"],

+ 1 - 1
docker/php/Dockerfile

@@ -11,7 +11,7 @@ RUN mkdir -p /application/Core/Configuration /var/www/.gnupg && \
 
 # YAML + dev dependencies + additional packages
 RUN apt-get update -y &&  \
-    apt-get install -y libyaml-dev libzip-dev libgmp-dev libpng-dev libmagickwand-dev gnupg2 $ADDITIONAL_PACKAGES && \
+    apt-get install -y libyaml-dev libzip-dev libgmp-dev libpng-dev libmagickwand-dev gnupg2 git $ADDITIONAL_PACKAGES && \
     printf "\n" | pecl install yaml imagick && docker-php-ext-enable yaml imagick && \
     docker-php-ext-install gd
 

+ 0 - 0
img/.gitkeep


+ 3 - 2
react/admin-panel/src/elements/sidebar.js

@@ -2,7 +2,8 @@ import React, {useCallback, useContext} from 'react';
 import {Link, NavLink} from "react-router-dom";
 import Icon from "shared/elements/icon";
 import {LocaleContext} from "shared/locale";
-import {Avatar, styled} from "@mui/material";
+import {styled} from "@mui/material";
+import ProfilePicture from "shared/elements/profile-picture";
 
 const ProfileLink = styled(Link)((props) => ({
     "& > div": {
@@ -114,7 +115,7 @@ export default function Sidebar(props) {
                                 <div className="info">
                                     <div className={"d-block text-light"}>{L("account.logged_in_as")}:</div>
                                     <ProfileLink to={"/admin/profile"}>
-                                        <Avatar fontSize={"small"} />
+                                        <ProfilePicture user={api.user} />
                                         <span>{api.user?.name || L("account.not_logged_in")}</span>
                                     </ProfileLink>
                                 </div>

+ 8 - 5
react/admin-panel/src/views/log-view.js

@@ -78,16 +78,19 @@ export default function LogView(props) {
         let column = new DataColumn(L("logs.message"), "message");
         column.sortable = false;
         column.renderData = (L, entry) => {
+            let lines = entry.message.trim().split("\n");
             return <Box display={"grid"} gridTemplateColumns={"40px auto"}>
                     <Box alignSelf={"top"} textAlign={"center"}>
-                        <IconButton size={"small"} onClick={() => onToggleDetails(entry)}
-                                    title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}>
-                            {entry.showDetails ? <ExpandLess /> : <ExpandMore />}
-                        </IconButton>
+                        {lines.length > 1 &&
+                            <IconButton size={"small"} onClick={() => onToggleDetails(entry)}
+                                        title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}>
+                                {entry.showDetails ? <ExpandLess /> : <ExpandMore />}
+                            </IconButton>
+                        }
                     </Box>
                     <Box alignSelf={"center"}>
                         <pre>
-                            {entry.showDetails ? entry.message : entry.message.split("\n")[0]}
+                            {entry.showDetails ? entry.message : lines[0]}
                         </pre>
                     </Box>
                 </Box>

+ 53 - 0
react/admin-panel/src/views/profile/change-password-box.js

@@ -0,0 +1,53 @@
+import {Password} from "@mui/icons-material";
+import SpacedFormGroup from "../../elements/form-group";
+import {Box, FormControl, FormLabel, TextField} from "@mui/material";
+import PasswordStrength from "shared/elements/password-strength";
+import CollapseBox from "./collapse-box";
+import React, {useContext} from "react";
+import {LocaleContext} from "shared/locale";
+
+export default function ChangePasswordBox(props) {
+
+    // meta
+    const {changePassword, setChangePassword, ...other} = props;
+    const {translate: L} = useContext(LocaleContext);
+
+    return <CollapseBox title={L("account.change_password")}
+                        icon={<Password />}
+                        {...other} >
+        <SpacedFormGroup>
+            <FormLabel>{L("account.password_old")}</FormLabel>
+            <FormControl>
+                <TextField variant={"outlined"}
+                           size="small"
+                           type={"password"}
+                           placeholder={L("general.unchanged")}
+                           value={changePassword.old}
+                           onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
+            </FormControl>
+        </SpacedFormGroup>
+        <SpacedFormGroup>
+            <FormLabel>{L("account.password_new")}</FormLabel>
+            <FormControl>
+                <TextField variant={"outlined"}
+                           size="small"
+                           type={"password"}
+                           value={changePassword.new}
+                           onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
+            </FormControl>
+        </SpacedFormGroup>
+        <SpacedFormGroup>
+            <FormLabel>{L("account.password_confirm")}</FormLabel>
+            <FormControl>
+                <TextField variant={"outlined"}
+                           size="small"
+                           type={"password"}
+                           value={changePassword.confirm}
+                           onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
+            </FormControl>
+        </SpacedFormGroup>
+        <Box className={"w-50"}>
+            <PasswordStrength password={changePassword.new} minLength={6} />
+        </Box>
+    </CollapseBox>
+}

+ 131 - 0
react/admin-panel/src/views/profile/edit-picture.js

@@ -0,0 +1,131 @@
+import {Box, Button, CircularProgress, Slider, styled} from "@mui/material";
+import {useCallback, useContext, useRef, useState} from "react";
+import {LocaleContext} from "shared/locale";
+import PreviewProfilePicture from "./preview-picture";
+import {Delete, Edit} from "@mui/icons-material";
+import ProfilePicture from "shared/elements/profile-picture";
+
+const ProfilePictureBox = styled(Box)((props) => ({
+    padding: props.theme.spacing(2),
+    display: "grid",
+    gridTemplateRows: "auto 60px",
+    gridGap: props.theme.spacing(2),
+    textAlign: "center",
+}));
+
+const VerticalButtonBar = styled(Box)((props) => ({
+    "& > button": {
+        width: "100%",
+        marginBottom: props.theme.spacing(1),
+    }
+}));
+
+export default function EditProfilePicture(props) {
+
+    // meta
+    const {translate: L} = useContext(LocaleContext);
+    // const [scale, setScale] = useState(100);
+    const scale = useRef(100);
+    const {api, showDialog, setProfile, profile, setDialogData, ...other} = props
+
+    const onUploadPicture = useCallback((data) => {
+        api.uploadPicture(data, scale.current / 100.0).then((res) => {
+            if (!res.success) {
+                showDialog(res.msg, L("Error uploading profile picture"));
+            } else {
+                setProfile({...profile, profilePicture: res.profilePicture});
+            }
+        })
+    }, [api, scale.current, showDialog, profile]);
+
+    const onRemoveImage = useCallback(() => {
+        api.removePicture().then((res) => {
+            if (!res.success) {
+                showDialog(res.msg, L("Error removing profile picture"));
+            } else {
+                setProfile({...profile, profilePicture: null});
+            }
+        });
+    }, [api, showDialog, profile]);
+
+    const onOpenDialog = useCallback((file = null, data = null) => {
+
+        let img = null;
+        if (data !== null) {
+            img = new Image();
+            img.src = data;
+        }
+
+        setDialogData({
+            show: true,
+            title: L("account.change_picture_title"),
+            text: L("account.change_picture_text"),
+            options: data === null ? [L("general.cancel")] : [L("general.apply"), L("general.cancel")],
+            inputs: data === null ? [{
+                key: "pfp-loading",
+                type: "custom",
+                element: CircularProgress,
+            }] : [
+                {
+                    key: "pfp-preview",
+                    type: "custom",
+                    element: PreviewProfilePicture,
+                    img: img,
+                    scale: scale.current,
+                    setScale: (v) => scale.current = v,
+                },
+            ],
+            onOption: (option) => {
+                if (option === 0 && file) {
+                    onUploadPicture(file)
+                }
+
+                // scale.current = 100;
+            }
+        })
+    }, [setDialogData, onUploadPicture]);
+
+    const onSelectImage = useCallback(() => {
+        let fileInput = document.createElement("input");
+        fileInput.type = "file";
+        fileInput.accept = "image/jpeg,image/jpg,image/png";
+        fileInput.onchange = () => {
+            let file = fileInput.files[0];
+            if (file) {
+                let reader = new FileReader();
+                reader.onload = function (e) {
+                    onOpenDialog(file, e.target.result);
+                }
+
+                onOpenDialog();
+                reader.readAsDataURL(file);
+            }
+        };
+        fileInput.click();
+    }, [onOpenDialog]);
+
+
+    return <ProfilePictureBox {...other}>
+        <ProfilePicture user={profile} onClick={onSelectImage} />
+        <VerticalButtonBar>
+            <Button variant="outlined" size="small"
+                    startIcon={<Edit />}
+                    onClick={onSelectImage}>
+                {L("account.change_picture")}
+            </Button>
+            {profile.profilePicture &&
+                <Button variant="outlined" size="small"
+                    startIcon={<Delete />} color="secondary"
+                    onClick={() => setDialogData({
+                        show: true,
+                        title: L("account.picture_remove_title"),
+                        message: L("account.picture_remove_text"),
+                        options: [L("general.confirm"), L("general.cancel")],
+                        onOption: (option) => option === 0 ? onRemoveImage() : true
+                    })}>
+                    {L("account.remove_picture")}
+                </Button>
+            }
+        </VerticalButtonBar>
+    </ProfilePictureBox>
+}

+ 172 - 0
react/admin-panel/src/views/profile/gpg-box.js

@@ -0,0 +1,172 @@
+import React, {useCallback, useContext, useState} from "react";
+import {LocaleContext} from "shared/locale";
+import {Box, Button, CircularProgress, FormControl, FormLabel, styled, TextField} from "@mui/material";
+import {CheckCircle, CloudUpload, ErrorOutline, Remove, Upload, VpnKey} from "@mui/icons-material";
+import SpacedFormGroup from "../../elements/form-group";
+import ButtonBar from "../../elements/button-bar";
+import CollapseBox from "./collapse-box";
+
+const GpgKeyField = styled(TextField)((props) => ({
+    "& > div": {
+        fontFamily: "monospace",
+        padding: props.theme.spacing(1),
+        fontSize: '0.8rem',
+    },
+    marginBottom: props.theme.spacing(1)
+}));
+
+const GpgFingerprintBox = styled(Box)((props) => ({
+    "& > svg": {
+        marginRight: props.theme.spacing(1),
+    },
+    "& > code": {
+        cursor: "pointer"
+    }
+}));
+
+const VisuallyHiddenInput = styled('input')({
+    clip: 'rect(0 0 0 0)',
+    clipPath: 'inset(50%)',
+    height: 1,
+    overflow: 'hidden',
+    position: 'absolute',
+    bottom: 0,
+    left: 0,
+    whiteSpace: 'nowrap',
+    width: 1,
+});
+
+export default function GpgBox(props) {
+
+    // meta
+    const {profile, setProfile, api, showDialog, ...other} = props;
+    const {translate: L} = useContext(LocaleContext);
+
+    // data
+    const [gpgKey, setGpgKey] = useState("");
+    const [gpgKeyPassword, setGpgKeyPassword] = useState("");
+
+    // ui
+    const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
+    const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
+
+    const onUploadGPG = useCallback(() => {
+        if (!isGpgKeyUploading) {
+            setGpgKeyUploading(true);
+            api.uploadGPG(gpgKey).then(data => {
+                setGpgKeyUploading(false);
+                if (!data.success) {
+                    showDialog(data.msg, L("account.upload_gpg_error"));
+                } else {
+                    setProfile({...profile, gpgKey: data.gpgKey});
+                    setGpgKey("");
+                }
+            });
+        }
+    }, [api, showDialog, isGpgKeyUploading, profile, gpgKey]);
+
+    const onRemoveGpgKey = useCallback(() => {
+        if (!isGpgKeyRemoving) {
+            setGpgKeyRemoving(true);
+            api.removeGPG(gpgKeyPassword).then(data => {
+                setGpgKeyRemoving(false);
+                setGpgKeyPassword("");
+                if (!data.success) {
+                    showDialog(data.msg, L("account.remove_gpg_error"));
+                } else {
+                    setProfile({...profile, gpgKey: null});
+                }
+            });
+        }
+    }, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword, profile]);
+
+    const getFileContents = useCallback((file, callback) => {
+        let reader = new FileReader();
+        let data = "";
+        reader.onload = function(event) {
+            data += event.target.result;
+            if (reader.readyState === 2) {
+                if (!data.match(/^-+\s*BEGIN/m)) {
+                    showDialog(L("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file"));
+                    return false;
+                } else {
+                    callback(data);
+                }
+            }
+        };
+        setGpgKey("");
+        reader.readAsText(file);
+    }, [showDialog]);
+
+    return <CollapseBox title={L("account.gpg_key")} {...other}
+
+                        icon={<VpnKey />}>
+        {
+            profile.gpgKey ? <Box>
+                    <GpgFingerprintBox mb={2}>
+                        { profile.gpgKey.confirmed ?
+                            <CheckCircle color="info" title={L("account.gpg_key_confirmed")} /> :
+                            <ErrorOutline color="secondary" title={L("account.gpg_key_pending")}  />
+                        }
+                        GPG-Fingerprint: <code title={L("general.click_to_copy")}
+                                               onClick={() => navigator.clipboard.writeText(profile.gpgKey.fingerprint)}>
+                            {profile.gpgKey.fingerprint}
+                        </code>
+                    </GpgFingerprintBox>
+                    <SpacedFormGroup>
+                        <FormLabel>{L("account.password")}</FormLabel>
+                        <FormControl>
+                            <TextField variant={"outlined"} size="small"
+                                       value={gpgKeyPassword} type={"password"}
+                                       onChange={e => setGpgKeyPassword(e.target.value)}
+                                       placeholder={L("account.password")}
+                            />
+                        </FormControl>
+                    </SpacedFormGroup>
+                    <Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />}
+                            color="secondary" onClick={onRemoveGpgKey}
+                            variant="outlined" size="small"
+                            disabled={isGpgKeyRemoving || !api.hasPermission("user/removeGPG")}>
+                        {isGpgKeyRemoving ? L("general.removing") + "…" : L("general.remove")}
+                    </Button>
+                </Box> :
+                <Box>
+                    <SpacedFormGroup>
+                        <FormLabel>{L("account.gpg_key")}</FormLabel>
+                        <GpgKeyField value={gpgKey} multiline={true} rows={8}
+                                     disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}
+                                     placeholder={L("account.gpg_key_placeholder_text")}
+                                     onChange={e => setGpgKey(e.target.value)}
+                                     onDrop={e => {
+                                         let file = e.dataTransfer.files[0];
+                                         getFileContents(file, (data) => {
+                                             setGpgKey(data);
+                                         });
+                                         return false;
+                                     }}/>
+                    </SpacedFormGroup>
+                    <ButtonBar>
+                        <Button size="small"
+                                variant="outlined"
+                                startIcon={<CloudUpload />}
+                                component={"label"}>
+                            Upload file
+                            <VisuallyHiddenInput type={"file"}  onChange={e => {
+                                let file = e.target.files[0];
+                                getFileContents(file, (data) => {
+                                    setGpgKey(data);
+                                });
+                                return false;
+                            }} />
+                        </Button>
+                        <Button startIcon={isGpgKeyUploading ? <CircularProgress size={12} /> : <Upload />}
+                                color="primary" onClick={onUploadGPG}
+                                variant="outlined" size="small"
+                                disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}>
+                            {isGpgKeyUploading ? L("general.uploading") + "…" : L("general.upload")}
+                        </Button>
+                    </ButtonBar>
+                </Box>
+        }
+    </CollapseBox>
+}

+ 99 - 0
react/admin-panel/src/views/profile/mfa-box.js

@@ -0,0 +1,99 @@
+import React, {useCallback, useContext, useState} from "react";
+import {LocaleContext} from "shared/locale";
+import {CheckCircle, ErrorOutline, Fingerprint, Remove} from "@mui/icons-material";
+import {Box, Button, CircularProgress, FormControl, FormLabel, styled, TextField} from "@mui/material";
+import SpacedFormGroup from "../../elements/form-group";
+import MfaTotp from "./mfa-totp";
+import MfaFido from "./mfa-fido";
+import CollapseBox from "./collapse-box";
+
+const MfaStatusBox = styled(Box)((props) => ({
+    "& > svg": {
+        marginRight: props.theme.spacing(1),
+    },
+}));
+
+const MFAOptions = styled(Box)((props) => ({
+    "& > div": {
+        borderColor: props.theme.palette.divider,
+        borderStyle: "solid",
+        borderWidth: 1,
+        borderRadius: 5,
+        maxWidth: 150,
+        cursor: "pointer",
+        textAlign: "center",
+        display: "inline-grid",
+        gridTemplateRows: "130px 50px",
+        alignItems: "center",
+        padding: props.theme.spacing(1),
+        marginRight: props.theme.spacing(1),
+        "&:hover": {
+            backgroundColor: "lightgray",
+        },
+        "& img": {
+            width: "100%",
+        },
+    }
+}));
+
+export default function MultiFactorBox(props) {
+
+    // meta
+    const {profile, setProfile, setDialogData, api, showDialog, ...other} = props;
+    const {translate: L} = useContext(LocaleContext);
+
+    // data
+    const [mfaPassword, set2FAPassword] = useState("");
+
+    // ui
+    const [is2FARemoving, set2FARemoving] = useState(false);
+
+    const onRemove2FA = useCallback(() => {
+        if (!is2FARemoving) {
+            set2FARemoving(true);
+            api.remove2FA(mfaPassword).then(data => {
+                set2FARemoving(false);
+                set2FAPassword("");
+                if (!data.success) {
+                    showDialog(data.msg, L("account.remove_2fa_error"));
+                } else {
+                    setProfile({...profile, twoFactorToken: null});
+                }
+            });
+        }
+    }, [api, showDialog, is2FARemoving, mfaPassword, profile]);
+
+    return <CollapseBox title={L("account.2fa_token")}
+                        icon={<Fingerprint />} {...other}>
+        {profile.twoFactorToken && profile.twoFactorToken.confirmed ?
+            <Box>
+                <MfaStatusBox mb={2}>
+                    <CheckCircle color="info" title={L("account.two_factor_confirmed")} />
+                    {L("account.2fa_type_" + profile.twoFactorToken.type)}
+                </MfaStatusBox>
+                <SpacedFormGroup>
+                    <FormLabel>{L("account.password")}</FormLabel>
+                    <FormControl>
+                        <TextField variant={"outlined"} size="small"
+                                   value={mfaPassword} type={"password"}
+                                   onChange={e => set2FAPassword(e.target.value)}
+                                   placeholder={L("account.password")}
+                        />
+                    </FormControl>
+                </SpacedFormGroup>
+                <Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
+                        color="secondary" onClick={onRemove2FA}
+                        variant="outlined" size="small"
+                        disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
+                    {is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
+                </Button>
+            </Box> :
+            <MFAOptions>
+                <MfaTotp api={api} showDialog={showDialog} setDialogData={setDialogData}
+                         set2FA={token => setProfile({...profile, twoFactorToken: token })} />
+                <MfaFido api={api} showDialog={showDialog} setDialogData={setDialogData}
+                         set2FA={token => setProfile({...profile, twoFactorToken: token })} />
+            </MFAOptions>
+        }
+    </CollapseBox>
+}

+ 6 - 2
react/admin-panel/src/views/profile/mfa-fido.js

@@ -38,11 +38,15 @@ export default function MfaFido(props) {
                     challenge: encodeText(window.atob(res.data.challenge)),
                     rp: res.data.relyingParty,
                     user: {
-                        id: encodeText(res.data.id),
+                        id: encodeText(api.user.id),
                         name: api.user.name,
                         displayName: api.user.fullName
                     },
-                    userVerification: "discouraged",
+                    authenticatorSelection: {
+                        authenticatorAttachment: "cross-platform",
+                        requireResidentKey: false,
+                        userVerification: "discouraged"
+                    },
                     attestation: "direct",
                     pubKeyCredParams: [{
                         type: "public-key",

+ 2 - 3
react/admin-panel/src/views/profile/mfa-totp.js

@@ -24,9 +24,8 @@ export default function MfaTotp(props) {
         if (api.hasPermission("tfa/generateQR")) {
             setDialogData({
                 show: true,
-                title: L("Register a 2FA-Device"),
-                message: L("Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " +
-                    "On Android, you can use the Google Authenticator."),
+                title: L("account.register_2fa_device"),
+                message: L("account.register_2fa_totp_text"),
                 inputs: [
                     {
                         type: "custom", element: Box, textAlign: "center", key: "qr-code",

+ 53 - 0
react/admin-panel/src/views/profile/preview-picture.js

@@ -0,0 +1,53 @@
+import {Box, Slider, styled} from "@mui/material";
+import {useContext, useState} from "react";
+import {LocaleContext} from "shared/locale";
+
+const PreviewProfilePictureBox = styled(Box)((props) => ({
+    position: "relative",
+}));
+
+const PictureBox = styled(Box)((props) => ({
+    backgroundRepeat: "no-repeat",
+    backgroundSize: "contain"
+}));
+
+const SelectionBox = styled(Box)((props) => ({
+    position: "absolute",
+    border: "1px solid black",
+    borderRadius: "50%",
+}));
+
+export default function PreviewProfilePicture(props) {
+
+    const {translate: L} = useContext(LocaleContext);
+    const {img, scale, setScale, ...other} = props;
+
+    let size = "auto";
+    let displaySize = ["auto", "auto"];
+    let offsetY = 0;
+    let offsetX = 0;
+
+    if (img) {
+        displaySize[0] = Math.min(img.naturalWidth, 400);
+        displaySize[1] = img.naturalHeight * (displaySize[0] / img.naturalWidth);
+        size = Math.min(...displaySize) * (scale / 100.0);
+        offsetX = displaySize[0] / 2 - size / 2;
+        offsetY = displaySize[1] / 2 - size / 2;
+    }
+
+    return <PreviewProfilePictureBox {...other} textAlign={"center"}>
+        <PictureBox width={displaySize[0]} height={displaySize[1]}
+            sx={{backgroundImage: `url("${img.src}")`, width: displaySize[0], height: displaySize[1], filter: "blur(5px)"}}
+            title={L("account.profile_picture_preview")} />
+        <PictureBox width={displaySize[0]} height={displaySize[1]}
+                    position={"absolute"} top={0} left={0}
+                    sx={{backgroundImage: `url("${img.src}")`, width: displaySize[0], height: displaySize[1],
+                    clipPath: `circle(${scale*0.50}%)`}}
+                    title={L("account.profile_picture_preview")} />
+        <SelectionBox width={size} height={size} top={offsetY} left={offsetX} />
+        <Box mt={1}>
+            <label>{L("account.profile_picture_scale")}: {scale}%</label>
+            <Slider value={scale} min={50} max={100} onChange={e => setScale(e.target.value)} />
+        </Box>
+    </PreviewProfilePictureBox>
+}

+ 53 - 302
react/admin-panel/src/views/profile/profile.js

@@ -6,81 +6,18 @@ import {
     Button,
     CircularProgress,
     FormControl,
-    FormGroup,
-    FormLabel, styled,
+    FormLabel,
     TextField
 } from "@mui/material";
 import {
-    CheckCircle,
-    CloudUpload,
-    ErrorOutline,
-    Fingerprint,
-    Password,
-    Remove,
     Save,
-    Upload,
-    VpnKey
 } from "@mui/icons-material";
-import CollapseBox from "./collapse-box";
-import ButtonBar from "../../elements/button-bar";
-import MfaTotp from "./mfa-totp";
-import MfaFido from "./mfa-fido";
 import Dialog from "shared/elements/dialog";
-import PasswordStrength from "shared/elements/password-strength";
 import SpacedFormGroup from "../../elements/form-group";
-
-const GpgKeyField = styled(TextField)((props) => ({
-    "& > div": {
-        fontFamily: "monospace",
-        padding: props.theme.spacing(1),
-        fontSize: '0.8rem',
-    },
-    marginBottom: props.theme.spacing(1)
-}));
-
-const GpgFingerprintBox = styled(Box)((props) => ({
-    "& > svg": {
-        marginRight: props.theme.spacing(1),
-    },
-    "& > code": {
-        cursor: "pointer"
-    }
-}));
-
-const MFAOptions = styled(Box)((props) => ({
-    "& > div": {
-        borderColor: props.theme.palette.divider,
-        borderStyle: "solid",
-        borderWidth: 1,
-        borderRadius: 5,
-        maxWidth: 150,
-        cursor: "pointer",
-        textAlign: "center",
-        display: "inline-grid",
-        gridTemplateRows: "130px 50px",
-        alignItems: "center",
-        padding: props.theme.spacing(1),
-        marginRight: props.theme.spacing(1),
-        "&:hover": {
-            backgroundColor: "lightgray",
-        },
-        "& img": {
-            width: "100%",
-        },
-    }
-}));
-
-const VisuallyHiddenInput = styled('input')({
-    clip: 'rect(0 0 0 0)',
-    clipPath: 'inset(50%)',
-    height: 1,
-    overflow: 'hidden',
-    position: 'absolute',
-    bottom: 0,
-    left: 0,
-    whiteSpace: 'nowrap',
-    width: 1,
-});
+import ChangePasswordBox from "./change-password-box";
+import GpgBox from "./gpg-box";
+import MultiFactorBox from "./mfa-box";
+import EditProfilePicture from "./edit-picture";
 
 export default function ProfileView(props) {
 
@@ -100,16 +37,10 @@ export default function ProfileView(props) {
     // data
     const [profile, setProfile] = useState({...api.user});
     const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
-    const [gpgKey, setGpgKey] = useState("");
-    const [gpgKeyPassword, setGpgKeyPassword] = useState("");
-    const [mfaPassword, set2FAPassword] = useState("");
 
     // ui
     const [openedTab, setOpenedTab] = useState(null);
     const [isSaving, setSaving] = useState(false);
-    const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
-    const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
-    const [is2FARemoving, set2FARemoving] = useState(false);
     const [dialogData, setDialogData] = useState({show: false});
 
     const onUpdateProfile = useCallback(() => {
@@ -146,69 +77,6 @@ export default function ProfileView(props) {
 
     }, [profile, changePassword, api, showDialog, isSaving]);
 
-    const onUploadGPG = useCallback(() => {
-        if (!isGpgKeyUploading) {
-            setGpgKeyUploading(true);
-            api.uploadGPG(gpgKey).then(data => {
-                setGpgKeyUploading(false);
-                if (!data.success) {
-                    showDialog(data.msg, L("account.upload_gpg_error"));
-                } else {
-                    setProfile({...profile, gpgKey: data.gpgKey});
-                    setGpgKey("");
-                }
-            });
-        }
-    }, [api, showDialog, isGpgKeyUploading, profile, gpgKey]);
-
-    const onRemoveGpgKey = useCallback(() => {
-        if (!isGpgKeyRemoving) {
-            setGpgKeyRemoving(true);
-            api.removeGPG(gpgKeyPassword).then(data => {
-                setGpgKeyRemoving(false);
-                setGpgKeyPassword("");
-                if (!data.success) {
-                    showDialog(data.msg, L("account.remove_gpg_error"));
-                } else {
-                    setProfile({...profile, gpgKey: null});
-                }
-            });
-        }
-    }, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword, profile]);
-
-    const onRemove2FA = useCallback(() => {
-        if (!is2FARemoving) {
-            set2FARemoving(true);
-            api.remove2FA(mfaPassword).then(data => {
-                set2FARemoving(false);
-                set2FAPassword("");
-                if (!data.success) {
-                    showDialog(data.msg, L("account.remove_2fa_error"));
-                } else {
-                    setProfile({...profile, twoFactorToken: null});
-                }
-            });
-        }
-    }, [api, showDialog, is2FARemoving, mfaPassword, profile]);
-
-    const getFileContents = useCallback((file, callback) => {
-        let reader = new FileReader();
-        let data = "";
-        reader.onload = function(event) {
-            data += event.target.result;
-            if (reader.readyState === 2) {
-                if (!data.match(/^-+\s*BEGIN/m)) {
-                    showDialog(L("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file"));
-                    return false;
-                } else {
-                    callback(data);
-                }
-            }
-        };
-        setGpgKey("");
-        reader.readAsText(file);
-    }, [showDialog]);
-
     return <>
         <div className={"content-header"}>
             <div className={"container-fluid"}>
@@ -227,173 +95,56 @@ export default function ProfileView(props) {
                 </div>
             </div>
         </div>
-        <div className={"content"}>
-            <SpacedFormGroup>
-                <FormLabel>{L("account.username")}</FormLabel>
-                <FormControl>
-                    <TextField variant={"outlined"}
-                        size={"small"}
-                        value={profile.name}
-                        onChange={e => setProfile({...profile, name: e.target.value })} />
-                </FormControl>
-            </SpacedFormGroup>
-            <SpacedFormGroup>
-                <FormLabel>{L("account.full_name")}</FormLabel>
-                <FormControl>
-                    <TextField variant={"outlined"}
-                               size={"small"}
-                               value={profile.fullName ?? ""}
-                               onChange={e => setProfile({...profile, fullName: e.target.value })} />
-                </FormControl>
-            </SpacedFormGroup>
-
-            <CollapseBox title={L("account.change_password")} open={openedTab === "password"}
-                         onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
-                         icon={<Password />}>
-                <SpacedFormGroup>
-                    <FormLabel>{L("account.password_old")}</FormLabel>
-                    <FormControl>
-                        <TextField variant={"outlined"}
-                                   size={"small"}
-                                   type={"password"}
-                                   placeholder={L("general.unchanged")}
-                                   value={changePassword.old}
-                                   onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
-                    </FormControl>
-                </SpacedFormGroup>
-                <SpacedFormGroup>
-                    <FormLabel>{L("account.password_new")}</FormLabel>
-                    <FormControl>
-                        <TextField variant={"outlined"}
-                                   size={"small"}
-                                   type={"password"}
-                                   value={changePassword.new}
-                                   onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
-                    </FormControl>
-                </SpacedFormGroup>
-                <SpacedFormGroup>
-                    <FormLabel>{L("account.password_confirm")}</FormLabel>
-                    <FormControl>
-                        <TextField variant={"outlined"}
-                                   size={"small"}
-                                   type={"password"}
-                                   value={changePassword.confirm}
-                                   onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
-                    </FormControl>
-                </SpacedFormGroup>
-                <Box className={"w-50"}>
-                    <PasswordStrength password={changePassword.new} minLength={6} />
+        <Box>
+            <Box display={"grid"} gridTemplateColumns={"300px auto"}>
+                <EditProfilePicture api={api} showDialog={showDialog} setProfile={setProfile}
+                                    profile={profile} setDialogData={setDialogData} />
+                <Box p={2}>
+                    <SpacedFormGroup>
+                        <FormLabel>{L("account.username")}</FormLabel>
+                        <FormControl>
+                            <TextField variant={"outlined"}
+                                size={"small"}
+                                value={profile.name}
+                                onChange={e => setProfile({...profile, name: e.target.value })} />
+                        </FormControl>
+                    </SpacedFormGroup>
+                    <SpacedFormGroup>
+                        <FormLabel>{L("account.full_name")}</FormLabel>
+                        <FormControl>
+                            <TextField variant={"outlined"}
+                                       size={"small"}
+                                       value={profile.fullName ?? ""}
+                                       onChange={e => setProfile({...profile, fullName: e.target.value })} />
+                        </FormControl>
+                    </SpacedFormGroup>
+                    <SpacedFormGroup>
+                        <FormLabel>{L("account.email")}</FormLabel>
+                        <FormControl>
+                            <TextField variant={"outlined"}
+                                       size={"small"}
+                                       value={profile.email ?? ""}
+                                       disabled={true}/>
+                        </FormControl>
+                    </SpacedFormGroup>
                 </Box>
-            </CollapseBox>
+            </Box>
 
-            <CollapseBox title={L("account.gpg_key")} open={openedTab === "gpg"}
-                         onToggle={() => setOpenedTab(openedTab === "gpg" ? "" : "gpg")}
-                         icon={<VpnKey />}>
-                {
-                    profile.gpgKey ? <Box>
-                            <GpgFingerprintBox mb={2}>
-                                { profile.gpgKey.confirmed ?
-                                    <CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
-                                    <ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")}  />
-                                }
-                                GPG-Fingerprint: <code title={L("general.click_to_copy")} onClick={() => navigator.clipboard.writeText(profile.gpgKey.fingerprint)}>
-                                    {profile.gpgKey.fingerprint}
-                                </code>
-                            </GpgFingerprintBox>
-                            <SpacedFormGroup>
-                                <FormLabel>{L("account.password")}</FormLabel>
-                                <FormControl>
-                                    <TextField variant={"outlined"} size={"small"}
-                                               value={gpgKeyPassword} type={"password"}
-                                               onChange={e => setGpgKeyPassword(e.target.value)}
-                                               placeholder={L("account.password")}
-                                    />
-                                </FormControl>
-                            </SpacedFormGroup>
-                            <Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />}
-                                    color={"secondary"} onClick={onRemoveGpgKey}
-                                    variant={"outlined"} size={"small"}
-                                    disabled={isGpgKeyRemoving || !api.hasPermission("user/removeGPG")}>
-                                {isGpgKeyRemoving ? L("general.removing") + "…" : L("general.remove")}
-                            </Button>
-                    </Box> :
-                    <Box>
-                        <SpacedFormGroup>
-                            <FormLabel>{L("account.gpg_key")}</FormLabel>
-                            <GpgKeyField value={gpgKey} multiline={true} rows={8}
-                                         disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}
-                                         placeholder={L("account.gpg_key_placeholder_text")}
-                                         onChange={e => setGpgKey(e.target.value)}
-                                         onDrop={e => {
-                                             let file = e.dataTransfer.files[0];
-                                             getFileContents(file, (data) => {
-                                                 setGpgKey(data);
-                                             });
-                                             return false;
-                                         }}/>
-                        </SpacedFormGroup>
-                        <ButtonBar>
-                            <Button size={"small"}
-                                variant={"outlined"}
-                                startIcon={<CloudUpload />}
-                                component={"label"}>
-                                    Upload file
-                                    <VisuallyHiddenInput type={"file"}  onChange={e => {
-                                        let file = e.target.files[0];
-                                        getFileContents(file, (data) => {
-                                            setGpgKey(data);
-                                        });
-                                        return false;
-                                    }} />
-                            </Button>
-                            <Button startIcon={isGpgKeyUploading ? <CircularProgress size={12} /> : <Upload />}
-                                    color={"primary"} onClick={onUploadGPG}
-                                    variant={"outlined"} size={"small"}
-                                    disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}>
-                                {isGpgKeyUploading ? L("general.uploading") + "…" : L("general.upload")}
-                            </Button>
-                        </ButtonBar>
-                    </Box>
-                }
-            </CollapseBox>
+            <ChangePasswordBox open={openedTab === "password"}
+                               onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
+                               changePassword={changePassword}
+                               setChangePassword={setChangePassword} />
 
-            <CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
-                         onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
-                         icon={<Fingerprint />}>
-                {profile.twoFactorToken && profile.twoFactorToken.confirmed ?
-                    <Box>
-                        <GpgFingerprintBox mb={2}>
-                            { profile.twoFactorToken.confirmed ?
-                                <CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
-                                <ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")}  />
-                            }
-                            {L("account.2fa_type_" + profile.twoFactorToken.type)}
-                        </GpgFingerprintBox>
-                        <SpacedFormGroup>
-                            <FormLabel>{L("account.password")}</FormLabel>
-                            <FormControl>
-                                <TextField variant={"outlined"} size={"small"}
-                                           value={mfaPassword} type={"password"}
-                                           onChange={e => set2FAPassword(e.target.value)}
-                                           placeholder={L("account.password")}
-                                />
-                            </FormControl>
-                        </SpacedFormGroup>
-                        <Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
-                                color={"secondary"} onClick={onRemove2FA}
-                                variant={"outlined"} size={"small"}
-                                disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
-                            {is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
-                        </Button>
-                    </Box> :
-                    <MFAOptions>
-                        <MfaTotp api={api} showDialog={showDialog} setDialogData={setDialogData}
-                            set2FA={token => setProfile({...profile, twoFactorToken: token })} />
-                        <MfaFido api={api} showDialog={showDialog} setDialogData={setDialogData}
-                             set2FA={token => setProfile({...profile, twoFactorToken: token })} />
-                    </MFAOptions>
-                }
-            </CollapseBox>
+            <GpgBox open={openedTab === "gpg"}
+                    onToggle={() => setOpenedTab(openedTab === "gpg" ? "" : "gpg")}
+                    profile={profile} setProfile={setProfile}
+                    api={api} showDialog={showDialog} />
+
+            <MultiFactorBox open={openedTab === "2fa"}
+                            onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
+                            profile={profile} setProfile={setProfile}
+                            setDialogData={setDialogData}
+                            api={api} showDialog={showDialog} />
 
             <Box mt={2}>
                 <Button variant={"outlined"} color={"primary"}
@@ -403,7 +154,7 @@ export default function ProfileView(props) {
                         {isSaving ? L("general.saving") + "…" : L("general.save")}
                 </Button>
             </Box>
-        </div>
+        </Box>
 
         <Dialog show={dialogData.show}
                 title={dialogData.title}

+ 1 - 1
react/shared/api.js

@@ -18,7 +18,7 @@ export default class API {
         return this.loggedIn ? this.session.csrfToken : null;
     }
 
-    async apiCall(method, params, expectBinary=false) {
+    async apiCall(method, params = {}, expectBinary=false) {
         params = params || { };
         const csrfToken = this.csrfToken();
         const config = {method: 'post'};

+ 45 - 0
react/shared/elements/profile-picture.js

@@ -0,0 +1,45 @@
+import {Box, styled} from "@mui/material";
+import {useContext} from "react";
+import {LocaleContext} from "../locale";
+
+const PictureBox = styled("img")({
+    width: "100%",
+    clipPath: "circle(50%)",
+});
+
+const PicturePlaceholderBox = styled(Box)((props) => ({
+    display: "flex",
+    justifyContent: "center",
+    alignItems: "center",
+    background: "radial-gradient(circle closest-side, gray 98%, transparent 100%);",
+    containerType: "inline-size",
+    "& > span": {
+        textAlign: "center",
+        fontSize: "30cqw",
+        color: "black",
+    }
+}));
+
+export default function ProfilePicture(props) {
+
+    const {user, ...other} = props;
+    const {translate: L} = useContext(LocaleContext);
+
+    const initials = (user.fullName || user.name)
+        .split(" ")
+        .map(n => n.charAt(0).toUpperCase())
+        .join("");
+
+    const isClickable = !!other.onClick;
+    const sx = isClickable ? {cursor: "pointer"} : {};
+
+    if (user.profilePicture) {
+        return <PictureBox src={`/img/uploads/user/${user.id}/${user.profilePicture}`} sx={sx}
+                    alt={L("account.profile_picture_of") + " " + (user.fullName || user.name)}
+                    {...other} />;
+    } else {
+        return <PicturePlaceholderBox sx={sx} {...other}>
+            <span>{initials}</span>
+        </PicturePlaceholderBox>;
+    }
+}

+ 0 - 1
react/shared/views/login.jsx

@@ -139,7 +139,6 @@ export default function LoginForm(props) {
                     type: "public-key",
                 }],
                 userVerification: "discouraged",
-                attestation: "direct",
             },
             signal: abortSignal
         }).then((res) => {