diff --git a/.idea/php.xml b/.idea/php.xml index 97a38d7..23db715 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -1,6 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php index a746611..d9cf156 100644 --- a/Core/API/TfaAPI.class.php +++ b/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, // @ "relyingParty" => [ "name" => $relyingParty, "id" => $domain diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index e9a1613..47e4404 100644 --- a/Core/API/UserAPI.class.php +++ b/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,

" . - "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.

" . - "$url
Best Regards
$siteName Administration"; + $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") + ] + ]); - $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() - )); - - $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; diff --git a/Core/Driver/SQL/MySQL.class.php b/Core/Driver/SQL/MySQL.class.php index 5036949..adf5f8b 100644 --- a/Core/Driver/SQL/MySQL.class.php +++ b/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() - ); + 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()) { + 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() { diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index 6af0dcd..998c4d5 100644 --- a/Core/Localization/de_DE/account.php +++ b/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", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index 19a10dd..a3ba6e6 100644 --- a/Core/Localization/en_US/account.php +++ b/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", ]; \ No newline at end of file diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php index 36ce2dd..4975275 100644 --- a/Core/Objects/Context.class.php +++ b/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) { diff --git a/Core/Objects/TwoFactor/AuthenticationData.class.php b/Core/Objects/TwoFactor/AuthenticationData.class.php index a9af149..9230ca3 100644 --- a/Core/Objects/TwoFactor/AuthenticationData.class.php +++ b/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?; } diff --git a/Core/Templates/mail/gpg_import.twig b/Core/Templates/mail/gpg_import.twig new file mode 100644 index 0000000..f2b758f --- /dev/null +++ b/Core/Templates/mail/gpg_import.twig @@ -0,0 +1,9 @@ +Hello {{ username }},
+you imported a GPG public key for end-to-end encrypted mail communication on {{ site_name }}.
+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:

+ +{{ link }}

+ +Best Regards
+{{ site_name }} Administration \ No newline at end of file diff --git a/cli.php b/cli.php index dc97aed..b74beea 100755 --- a/cli.php +++ b/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 "); } @@ -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"], diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 685f197..db45757 100644 --- a/docker/php/Dockerfile +++ b/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 diff --git a/img/.gitkeep b/img/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/react/admin-panel/src/elements/sidebar.js b/react/admin-panel/src/elements/sidebar.js index 0afb672..2cac387 100644 --- a/react/admin-panel/src/elements/sidebar.js +++ b/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) {
{L("account.logged_in_as")}:
- + {api.user?.name || L("account.not_logged_in")}
diff --git a/react/admin-panel/src/views/log-view.js b/react/admin-panel/src/views/log-view.js index 186cbe7..f7a2922 100644 --- a/react/admin-panel/src/views/log-view.js +++ b/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 - onToggleDetails(entry)} - title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}> - {entry.showDetails ? : } - + {lines.length > 1 && + onToggleDetails(entry)} + title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}> + {entry.showDetails ? : } + + }
-                            {entry.showDetails ? entry.message : entry.message.split("\n")[0]}
+                            {entry.showDetails ? entry.message : lines[0]}
                         
diff --git a/react/admin-panel/src/views/profile/change-password-box.js b/react/admin-panel/src/views/profile/change-password-box.js new file mode 100644 index 0000000..e4fdefe --- /dev/null +++ b/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 } + {...other} > + + {L("account.password_old")} + + setChangePassword({...changePassword, old: e.target.value })} /> + + + + {L("account.password_new")} + + setChangePassword({...changePassword, new: e.target.value })} /> + + + + {L("account.password_confirm")} + + setChangePassword({...changePassword, confirm: e.target.value })} /> + + + + + + +} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/edit-picture.js b/react/admin-panel/src/views/profile/edit-picture.js new file mode 100644 index 0000000..06f302d --- /dev/null +++ b/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 + + + + {profile.profilePicture && + + } + + +} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/gpg-box.js b/react/admin-panel/src/views/profile/gpg-box.js new file mode 100644 index 0000000..607f90e --- /dev/null +++ b/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 }> + { + profile.gpgKey ? + + { profile.gpgKey.confirmed ? + : + + } + GPG-Fingerprint: navigator.clipboard.writeText(profile.gpgKey.fingerprint)}> + {profile.gpgKey.fingerprint} + + + + {L("account.password")} + + setGpgKeyPassword(e.target.value)} + placeholder={L("account.password")} + /> + + + + : + + + {L("account.gpg_key")} + setGpgKey(e.target.value)} + onDrop={e => { + let file = e.dataTransfer.files[0]; + getFileContents(file, (data) => { + setGpgKey(data); + }); + return false; + }}/> + + + + + + + } + +} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/mfa-box.js b/react/admin-panel/src/views/profile/mfa-box.js new file mode 100644 index 0000000..5b6ea2a --- /dev/null +++ b/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 } {...other}> + {profile.twoFactorToken && profile.twoFactorToken.confirmed ? + + + + {L("account.2fa_type_" + profile.twoFactorToken.type)} + + + {L("account.password")} + + set2FAPassword(e.target.value)} + placeholder={L("account.password")} + /> + + + + : + + setProfile({...profile, twoFactorToken: token })} /> + setProfile({...profile, twoFactorToken: token })} /> + + } + +} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/mfa-fido.js b/react/admin-panel/src/views/profile/mfa-fido.js index 9275d39..80a0d81 100644 --- a/react/admin-panel/src/views/profile/mfa-fido.js +++ b/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", diff --git a/react/admin-panel/src/views/profile/mfa-totp.js b/react/admin-panel/src/views/profile/mfa-totp.js index c7296df..0bc7861 100644 --- a/react/admin-panel/src/views/profile/mfa-totp.js +++ b/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", diff --git a/react/admin-panel/src/views/profile/preview-picture.js b/react/admin-panel/src/views/profile/preview-picture.js new file mode 100644 index 0000000..078ef80 --- /dev/null +++ b/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 + + + + + + setScale(e.target.value)} /> + + +} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/profile.js b/react/admin-panel/src/views/profile/profile.js index 163c373..52cb111 100644 --- a/react/admin-panel/src/views/profile/profile.js +++ b/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 <>
@@ -227,173 +95,56 @@ export default function ProfileView(props) {
-
- - {L("account.username")} - - setProfile({...profile, name: e.target.value })} /> - - - - {L("account.full_name")} - - setProfile({...profile, fullName: e.target.value })} /> - - - - setOpenedTab(openedTab === "password" ? "" : "password")} - icon={}> - - {L("account.password_old")} - - setChangePassword({...changePassword, old: e.target.value })} /> - - - - {L("account.password_new")} - - setChangePassword({...changePassword, new: e.target.value })} /> - - - - {L("account.password_confirm")} - - setChangePassword({...changePassword, confirm: e.target.value })} /> - - - - + + + + + + {L("account.username")} + + setProfile({...profile, name: e.target.value })} /> + + + + {L("account.full_name")} + + setProfile({...profile, fullName: e.target.value })} /> + + + + {L("account.email")} + + + + - + - setOpenedTab(openedTab === "gpg" ? "" : "gpg")} - icon={}> - { - profile.gpgKey ? - - { profile.gpgKey.confirmed ? - : - - } - GPG-Fingerprint: navigator.clipboard.writeText(profile.gpgKey.fingerprint)}> - {profile.gpgKey.fingerprint} - - - - {L("account.password")} - - setGpgKeyPassword(e.target.value)} - placeholder={L("account.password")} - /> - - - - : - - - {L("account.gpg_key")} - setGpgKey(e.target.value)} - onDrop={e => { - let file = e.dataTransfer.files[0]; - getFileContents(file, (data) => { - setGpgKey(data); - }); - return false; - }}/> - - - - - - - } - + setOpenedTab(openedTab === "password" ? "" : "password")} + changePassword={changePassword} + setChangePassword={setChangePassword} /> - setOpenedTab(openedTab === "2fa" ? "" : "2fa")} - icon={}> - {profile.twoFactorToken && profile.twoFactorToken.confirmed ? - - - { profile.twoFactorToken.confirmed ? - : - - } - {L("account.2fa_type_" + profile.twoFactorToken.type)} - - - {L("account.password")} - - set2FAPassword(e.target.value)} - placeholder={L("account.password")} - /> - - - - : - - setProfile({...profile, twoFactorToken: token })} /> - setProfile({...profile, twoFactorToken: token })} /> - - } - + setOpenedTab(openedTab === "gpg" ? "" : "gpg")} + profile={profile} setProfile={setProfile} + api={api} showDialog={showDialog} /> + + setOpenedTab(openedTab === "2fa" ? "" : "2fa")} + profile={profile} setProfile={setProfile} + setDialogData={setDialogData} + api={api} showDialog={showDialog} />
+ ({ + 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 ; + } else { + return + {initials} + ; + } +} \ No newline at end of file diff --git a/react/shared/views/login.jsx b/react/shared/views/login.jsx index a3b5b10..accd73b 100644 --- a/react/shared/views/login.jsx +++ b/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) => {