bugfix, profile picture WIP, profile frontend refactored

This commit is contained in:
Roman 2024-04-14 20:31:16 +02:00
parent c892ef5b6e
commit 29c72d13e7
25 changed files with 784 additions and 402 deletions

@ -1,6 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <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"> <component name="PhpProjectSharedConfiguration" php_language_level="8.1">
<option name="suggestChangeDefaultLanguageLevel" value="false" /> <option name="suggestChangeDefaultLanguageLevel" value="false" />
</component> </component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project> </project>

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

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

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

@ -26,33 +26,18 @@ return [
"username" => "Benutzername", "username" => "Benutzername",
"username_or_email" => "Benutzername oder E-Mail", "username_or_email" => "Benutzername oder E-Mail",
"email" => "E-Mail Adresse", "email" => "E-Mail Adresse",
"password" => "Passwort",
"password_confirm" => "Passwort bestätigen",
"password_old" => "Altes Passwort",
"password_new" => "Neues Passwort",
"full_name" => "Voller Name", "full_name" => "Voller Name",
"remember_me" => "Eingeloggt bleiben", "remember_me" => "Eingeloggt bleiben",
"signing_in" => "Einloggen", "signing_in" => "Einloggen",
"sign_in" => "Einloggen", "sign_in" => "Einloggen",
"confirmed" => "Bestätigt", "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", "back_to_login" => "Zurück zum Login",
"register_text" => "Noch keinen Account? Jetzt registrieren", "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", "confirming_email" => "Bestätige E-Mail Adresse",
"proceed_to_login" => "Weiter zum Login", "proceed_to_login" => "Weiter zum Login",
"invalid_link" => "Den Link den Sie besucht haben ist nicht länger gültig", "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_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", "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", "profile_of" => "Profil von",
"language" => "Sprache", "language" => "Sprache",
"profile" => "Benutzerprofil", "profile" => "Benutzerprofil",
@ -71,6 +56,10 @@ return [
"group" => "Gruppe", "group" => "Gruppe",
"no_members" => "Keine Mitglieder in dieser Gruppe", "no_members" => "Keine Mitglieder in dieser Gruppe",
# profile picture
"change_picture" => "Profilbild ändern",
"profile_picture_of" => "Profilbild von",
# dialogs # dialogs
"fetch_group_members_error" => "Fehler beim Holen der Gruppenmitglieder", "fetch_group_members_error" => "Fehler beim Holen der Gruppenmitglieder",
"remove_group_member_error" => "Fehler beim Entfernen des Gruppenmitglieds", "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?", "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_title" => "Mitglied hinzufügen",
"add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen", "add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen",
"edit_profile" => "Profil bearbeiten",
# GPG Key # GPG Key
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...", "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
"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_totp" => "Zeitbasiertes 2FA (TOTP)",
"2fa_type_fido" => "Schlüsselbasiertes 2FA", "2fa_type_fido" => "Schlüsselbasiertes 2FA",
"register_2fa_device" => "Ein 2FA-Gerät registrieren", "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", "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" => "2FA-Token entfernen",
"remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen", "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",
]; ];

@ -26,33 +26,19 @@ return [
"username" => "Username", "username" => "Username",
"username_or_email" => "Username or E-Mail", "username_or_email" => "Username or E-Mail",
"email" => "E-Mail Address", "email" => "E-Mail Address",
"password" => "Password",
"password_confirm" => "Confirm Password",
"password_old" => "Old Password",
"password_new" => "New Password",
"full_name" => "Full Name", "full_name" => "Full Name",
"remember_me" => "Remember Me", "remember_me" => "Remember Me",
"signing_in" => "Signing in", "signing_in" => "Signing in",
"sign_in" => "Sign In", "sign_in" => "Sign In",
"confirmed" => "Confirmed", "confirmed" => "Confirmed",
"forgot_password" => "Forgot password?",
"change_password" => "Change password",
"register_text" => "Don't have an account? Sign Up", "register_text" => "Don't have an account? Sign Up",
"passwords_do_not_match" => "Your passwords did not match", "passwords_do_not_match" => "Your passwords did not match",
"back_to_login" => "Back to Login", "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", "confirming_email" => "Confirming email",
"proceed_to_login" => "Proceed to Login", "proceed_to_login" => "Proceed to Login",
"invalid_link" => "The link you visited is no longer valid", "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_success" => "Your e-mail address was successfully confirmed, you may now log in",
"confirm_error" => "Error confirming e-mail address", "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", "profile_of" => "Profile of",
"language" => "Language", "language" => "Language",
"profile" => "User Profile", "profile" => "User Profile",
@ -70,6 +56,11 @@ return [
"active" => "Active", "active" => "Active",
"group" => "Group", "group" => "Group",
"no_members" => "No members in this 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 # dialogs
"fetch_group_members_error" => "Error fetching group members", "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", "add_group_member_text" => "Search a user to add to the group",
# GPG Key # 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...", "gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
# 2fa # 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_totp" => "Time-Based 2FA (TOTP)",
"2fa_type_fido" => "Key-Based 2FA", "2fa_type_fido" => "Key-Based 2FA",
"register_2fa_device" => "Register a 2FA-Device", "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.", "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" => "Remove 2FA Token",
"remove_2fa_text" => "Enter your current password to confirm the removal of your 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",
]; ];

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

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

@ -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

26
cli.php

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

@ -11,7 +11,7 @@ RUN mkdir -p /application/Core/Configuration /var/www/.gnupg && \
# YAML + dev dependencies + additional packages # YAML + dev dependencies + additional packages
RUN apt-get update -y && \ 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 && \ printf "\n" | pecl install yaml imagick && docker-php-ext-enable yaml imagick && \
docker-php-ext-install gd docker-php-ext-install gd

0
img/.gitkeep Normal file

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

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

@ -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>
}

@ -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>
}

@ -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>
}

@ -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>
}

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

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

@ -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>
}

@ -6,81 +6,18 @@ import {
Button, Button,
CircularProgress, CircularProgress,
FormControl, FormControl,
FormGroup, FormLabel,
FormLabel, styled,
TextField TextField
} from "@mui/material"; } from "@mui/material";
import { import {
CheckCircle,
CloudUpload,
ErrorOutline,
Fingerprint,
Password,
Remove,
Save, Save,
Upload,
VpnKey
} from "@mui/icons-material"; } 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 Dialog from "shared/elements/dialog";
import PasswordStrength from "shared/elements/password-strength";
import SpacedFormGroup from "../../elements/form-group"; import SpacedFormGroup from "../../elements/form-group";
import ChangePasswordBox from "./change-password-box";
const GpgKeyField = styled(TextField)((props) => ({ import GpgBox from "./gpg-box";
"& > div": { import MultiFactorBox from "./mfa-box";
fontFamily: "monospace", import EditProfilePicture from "./edit-picture";
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,
});
export default function ProfileView(props) { export default function ProfileView(props) {
@ -100,16 +37,10 @@ export default function ProfileView(props) {
// data // data
const [profile, setProfile] = useState({...api.user}); const [profile, setProfile] = useState({...api.user});
const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" }); const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
const [gpgKey, setGpgKey] = useState("");
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
const [mfaPassword, set2FAPassword] = useState("");
// ui // ui
const [openedTab, setOpenedTab] = useState(null); const [openedTab, setOpenedTab] = useState(null);
const [isSaving, setSaving] = useState(false); 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 [dialogData, setDialogData] = useState({show: false});
const onUpdateProfile = useCallback(() => { const onUpdateProfile = useCallback(() => {
@ -146,69 +77,6 @@ export default function ProfileView(props) {
}, [profile, changePassword, api, showDialog, isSaving]); }, [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 <> return <>
<div className={"content-header"}> <div className={"content-header"}>
<div className={"container-fluid"}> <div className={"container-fluid"}>
@ -227,173 +95,56 @@ export default function ProfileView(props) {
</div> </div>
</div> </div>
</div> </div>
<div className={"content"}> <Box>
<SpacedFormGroup> <Box display={"grid"} gridTemplateColumns={"300px auto"}>
<FormLabel>{L("account.username")}</FormLabel> <EditProfilePicture api={api} showDialog={showDialog} setProfile={setProfile}
<FormControl> profile={profile} setDialogData={setDialogData} />
<TextField variant={"outlined"} <Box p={2}>
size={"small"} <SpacedFormGroup>
value={profile.name} <FormLabel>{L("account.username")}</FormLabel>
onChange={e => setProfile({...profile, name: e.target.value })} /> <FormControl>
</FormControl> <TextField variant={"outlined"}
</SpacedFormGroup> size={"small"}
<SpacedFormGroup> value={profile.name}
<FormLabel>{L("account.full_name")}</FormLabel> onChange={e => setProfile({...profile, name: e.target.value })} />
<FormControl> </FormControl>
<TextField variant={"outlined"} </SpacedFormGroup>
size={"small"} <SpacedFormGroup>
value={profile.fullName ?? ""} <FormLabel>{L("account.full_name")}</FormLabel>
onChange={e => setProfile({...profile, fullName: e.target.value })} /> <FormControl>
</FormControl> <TextField variant={"outlined"}
</SpacedFormGroup> size={"small"}
value={profile.fullName ?? ""}
<CollapseBox title={L("account.change_password")} open={openedTab === "password"} onChange={e => setProfile({...profile, fullName: e.target.value })} />
onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")} </FormControl>
icon={<Password />}> </SpacedFormGroup>
<SpacedFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.password_old")}</FormLabel> <FormLabel>{L("account.email")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
size={"small"} size={"small"}
type={"password"} value={profile.email ?? ""}
placeholder={L("general.unchanged")} disabled={true}/>
value={changePassword.old} </FormControl>
onChange={e => setChangePassword({...changePassword, old: e.target.value })} /> </SpacedFormGroup>
</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>
</CollapseBox> </Box>
<CollapseBox title={L("account.gpg_key")} open={openedTab === "gpg"} <ChangePasswordBox open={openedTab === "password"}
onToggle={() => setOpenedTab(openedTab === "gpg" ? "" : "gpg")} onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
icon={<VpnKey />}> changePassword={changePassword}
{ setChangePassword={setChangePassword} />
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>
<CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"} <GpgBox open={openedTab === "gpg"}
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")} onToggle={() => setOpenedTab(openedTab === "gpg" ? "" : "gpg")}
icon={<Fingerprint />}> profile={profile} setProfile={setProfile}
{profile.twoFactorToken && profile.twoFactorToken.confirmed ? api={api} showDialog={showDialog} />
<Box>
<GpgFingerprintBox mb={2}> <MultiFactorBox open={openedTab === "2fa"}
{ profile.twoFactorToken.confirmed ? onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
<CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> : profile={profile} setProfile={setProfile}
<ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")} /> setDialogData={setDialogData}
} api={api} showDialog={showDialog} />
{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>
<Box mt={2}> <Box mt={2}>
<Button variant={"outlined"} color={"primary"} <Button variant={"outlined"} color={"primary"}
@ -403,7 +154,7 @@ export default function ProfileView(props) {
{isSaving ? L("general.saving") + "…" : L("general.save")} {isSaving ? L("general.saving") + "…" : L("general.save")}
</Button> </Button>
</Box> </Box>
</div> </Box>
<Dialog show={dialogData.show} <Dialog show={dialogData.show}
title={dialogData.title} title={dialogData.title}

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

@ -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>;
}
}

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