bugfix, profile picture WIP, profile frontend refactored
This commit is contained in:
parent
c892ef5b6e
commit
29c72d13e7
@ -1,6 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MessDetectorOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCSFixerOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.1">
|
||||
<option name="suggestChangeDefaultLanguageLevel" value="false" />
|
||||
</component>
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
</project>
|
@ -18,11 +18,7 @@ namespace Core\API {
|
||||
}
|
||||
|
||||
protected function verifyAuthData(AuthenticationData $authData): bool {
|
||||
$settings = $this->context->getSettings();
|
||||
// $relyingParty = $settings->getSiteName();
|
||||
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
|
||||
// $domain = "localhost";
|
||||
|
||||
$domain = getCurrentHostName();
|
||||
if (!$authData->verifyIntegrity($domain)) {
|
||||
return $this->createError("mismatched rpIDHash. expected: " . hash("sha256", $domain) . " got: " . bin2hex($authData->getHash()));
|
||||
} else if (!$authData->isUserPresent()) {
|
||||
@ -264,7 +260,7 @@ namespace Core\API\TFA {
|
||||
$settings = $this->context->getSettings();
|
||||
$relyingParty = $settings->getSiteName();
|
||||
$sql = $this->context->getSQL();
|
||||
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
|
||||
$domain = getCurrentHostName();
|
||||
|
||||
if (!$clientDataJSON || !$attestationObjectRaw) {
|
||||
$challenge = null;
|
||||
@ -297,7 +293,6 @@ namespace Core\API\TFA {
|
||||
|
||||
$this->result["data"] = [
|
||||
"challenge" => $challenge,
|
||||
"id" => $currentUser->getId() . "@" . $domain, // <userId>@<domain>
|
||||
"relyingParty" => [
|
||||
"name" => $relyingParty,
|
||||
"id" => $domain
|
||||
|
@ -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,27 +1311,31 @@ namespace Core\API\User {
|
||||
return $this->createError("Error saving user token: " . $sql->getLastError());
|
||||
}
|
||||
|
||||
$name = htmlspecialchars($currentUser->getFullName());
|
||||
if (!$name) {
|
||||
$name = htmlspecialchars($currentUser->getUsername());
|
||||
}
|
||||
|
||||
$validHours = 1;
|
||||
$settings = $this->context->getSettings();
|
||||
$siteName = htmlspecialchars($settings->getSiteName());
|
||||
$baseUrl = htmlspecialchars($settings->getBaseUrl());
|
||||
$token = htmlspecialchars(urlencode($token));
|
||||
$url = "$baseUrl/confirmGPG?token=$token";
|
||||
$mailBody = "Hello $name,<br><br>" .
|
||||
"you imported a GPG public key for end-to-end encrypted mail communication. " .
|
||||
"To confirm the key and verify, you own the corresponding private key, please click on the following link. " .
|
||||
"The link is active for one hour.<br><br>" .
|
||||
"<a href='$url'>$url</a><br>Best Regards<br>$siteName Administration";
|
||||
$baseUrl = $settings->getBaseUrl();
|
||||
$siteName = $settings->getSiteName();
|
||||
$req = new Render($this->context);
|
||||
$this->success = $req->execute([
|
||||
"file" => "mail/gpg_import.twig",
|
||||
"parameters" => [
|
||||
"link" => "$baseUrl/resetPassword?token=$token",
|
||||
"site_name" => $siteName,
|
||||
"base_url" => $baseUrl,
|
||||
"username" => $currentUser->getDisplayName(),
|
||||
"valid_time" => $this->formatDuration($validHours, "hour")
|
||||
]
|
||||
]);
|
||||
|
||||
$this->lastError = $req->getLastError();
|
||||
|
||||
if ($this->success) {
|
||||
$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" => $mailBody,
|
||||
"body" => $messageBody,
|
||||
"gpgFingerprint" => $gpgKey->getFingerprint()
|
||||
));
|
||||
|
||||
@ -1345,6 +1349,7 @@ namespace Core\API\User {
|
||||
return $this->createError("Error updating user details: " . $sql->getLastError());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success;
|
||||
}
|
||||
@ -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;
|
||||
|
@ -52,6 +52,7 @@ class MySQL extends SQL {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->connection = @mysqli_connect(
|
||||
$this->connectionData->getHost(),
|
||||
$this->connectionData->getLogin(),
|
||||
@ -68,6 +69,11 @@ class MySQL extends SQL {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public function disconnect() {
|
||||
|
@ -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",
|
||||
];
|
@ -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",
|
||||
];
|
@ -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) {
|
||||
|
@ -38,8 +38,9 @@ class AuthenticationData extends ApiObject {
|
||||
$credentialIdLength = unpack("n", substr($buffer, $offset, 4))[1]; $offset += 2;
|
||||
$this->credentialID = substr($buffer, $offset, $credentialIdLength); $offset += $credentialIdLength;
|
||||
|
||||
if ($offset < $bufferLength) {
|
||||
if ($bufferLength > $offset) {
|
||||
$publicKeyData = $this->decode(substr($buffer, $offset));
|
||||
var_dump($publicKeyData);
|
||||
$this->publicKey = new PublicKey($publicKeyData);
|
||||
// TODO: we should add $publicKeyData->length to $offset, but it's not implemented yet?;
|
||||
}
|
||||
|
9
Core/Templates/mail/gpg_import.twig
Normal file
9
Core/Templates/mail/gpg_import.twig
Normal file
@ -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
26
cli.php
@ -268,12 +268,15 @@ function onMaintenance(array $argv): void {
|
||||
$action = $argv[2] ?? "status";
|
||||
$maintenanceFile = "MAINTENANCE";
|
||||
$isMaintenanceEnabled = file_exists($maintenanceFile);
|
||||
$sql = connectSQL();
|
||||
$logger = new \Core\Driver\Logger\Logger("CLI", $sql);
|
||||
|
||||
if ($action === "status") {
|
||||
_exit("Maintenance: " . ($isMaintenanceEnabled ? "on" : "off"));
|
||||
} else if ($action === "on") {
|
||||
$file = fopen($maintenanceFile, 'w') or _exit("Unable to create maintenance file");
|
||||
fclose($file);
|
||||
$logger->info("Maintenance mode enabled");
|
||||
_exit("Maintenance enabled");
|
||||
} else if ($action === "off") {
|
||||
if (file_exists($maintenanceFile)) {
|
||||
@ -281,13 +284,15 @@ function onMaintenance(array $argv): void {
|
||||
_exit("Unable to delete maintenance file");
|
||||
}
|
||||
}
|
||||
$logger->info("Maintenance mode disabled");
|
||||
_exit("Maintenance disabled");
|
||||
} else if ($action === "update") {
|
||||
|
||||
$logger->info("Update started");
|
||||
$oldPatchFiles = glob('Core/Configuration/Patch/*.php');
|
||||
printLine("$ git remote -v");
|
||||
exec("git remote -v", $gitRemote, $ret);
|
||||
if ($ret !== 0) {
|
||||
$logger->warning("Update stopped. git remote returned:\n" . implode("\n", $gitRemote));
|
||||
die();
|
||||
}
|
||||
|
||||
@ -304,12 +309,14 @@ function onMaintenance(array $argv): void {
|
||||
printLine("$ git fetch " . str_replace("/", " ", $pullBranch));
|
||||
exec("git fetch " . str_replace("/", " ", $pullBranch), $gitFetch, $ret);
|
||||
if ($ret !== 0) {
|
||||
$logger->warning("Update stopped. git fetch returned:\n" . implode("\n", $gitFetch));
|
||||
die();
|
||||
}
|
||||
|
||||
printLine("$ git log HEAD..$pullBranch --oneline");
|
||||
exec("git log HEAD..$pullBranch --oneline", $gitLog, $ret);
|
||||
if ($ret !== 0) {
|
||||
$logger->warning("Update stopped. git log returned:\n" . implode("\n", $gitLog));
|
||||
die();
|
||||
} else if (count($gitLog) === 0) {
|
||||
_exit("Already up to date.");
|
||||
@ -319,12 +326,14 @@ function onMaintenance(array $argv): void {
|
||||
printLine("$ git diff-index --quiet HEAD --"); // check for any uncommitted changes
|
||||
exec("git diff-index --quiet HEAD --", $gitDiff, $ret);
|
||||
if ($ret !== 0) {
|
||||
$logger->warning("Update stopped due to uncommitted changes");
|
||||
_exit("You have uncommitted changes. Please commit them before updating.");
|
||||
}
|
||||
|
||||
// enable maintenance mode if it wasn't turned on before
|
||||
if (!$isMaintenanceEnabled) {
|
||||
printLine("Turning on maintenance mode");
|
||||
$logger->info("Maintenance mode enabled");
|
||||
$file = fopen($maintenanceFile, 'w') or _exit("Unable to create maintenance file");
|
||||
fclose($file);
|
||||
}
|
||||
@ -338,6 +347,7 @@ function onMaintenance(array $argv): void {
|
||||
printLine("Follow the instructions and afterwards turn off the maintenance mode again using:");
|
||||
printLine("cli.php maintenance off");
|
||||
printLine("Also don't forget to apply new database patches using: cli.php db migrate");
|
||||
$logger->error("Update stopped. git pull returned:\n" . implode("\n", $gitPull));
|
||||
die();
|
||||
}
|
||||
|
||||
@ -345,15 +355,17 @@ function onMaintenance(array $argv): void {
|
||||
$newPatchFiles = glob('Core/Configuration/Patch/*.php');
|
||||
$newPatchFiles = array_diff($newPatchFiles, $oldPatchFiles);
|
||||
if (count($newPatchFiles) > 0) {
|
||||
printLine("Applying new database patches");
|
||||
$sql = connectSQL();
|
||||
if ($sql) {
|
||||
printLine("Applying new database patches");
|
||||
foreach ($newPatchFiles as $patchFile) {
|
||||
if (preg_match("/Core\/Configuration\/(Patch\/.*)\.class\.php/", $patchFile, $match)) {
|
||||
$patchName = $match[1];
|
||||
applyPatch($sql, $patchName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
printLine("Cannot apply database patches, since the database connection failed.");
|
||||
$logger->warning("Cannot apply database patches, since the database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -363,9 +375,13 @@ function onMaintenance(array $argv): void {
|
||||
if (file_exists($maintenanceFile)) {
|
||||
if (!unlink($maintenanceFile)) {
|
||||
_exit("Unable to delete maintenance file");
|
||||
} else {
|
||||
$logger->info("Maintenance mode disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$logger->info("Update completed.");
|
||||
} else {
|
||||
_exit("Usage: cli.php maintenance <status|on|off|update>");
|
||||
}
|
||||
@ -953,8 +969,8 @@ $argv = $_SERVER['argv'];
|
||||
$registeredCommands = [
|
||||
"help" => ["handler" => "printHelp", "description" => "prints this help page"],
|
||||
"db" => ["handler" => "handleDatabase", "description" => "database actions like importing, exporting and shell"],
|
||||
"routes" => ["handler" => "onRoutes", "description" => "view and modify routes"],
|
||||
"maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode"],
|
||||
"routes" => ["handler" => "onRoutes", "description" => "view and modify routes", "requiresDocker" => true],
|
||||
"maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode", "requiresDocker" => true],
|
||||
"test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true],
|
||||
"mail" => ["handler" => "onMail", "description" => "send mails and process the pipeline", "requiresDocker" => true],
|
||||
"settings" => ["handler" => "onSettings", "description" => "change and view settings"],
|
||||
|
@ -11,7 +11,7 @@ RUN mkdir -p /application/Core/Configuration /var/www/.gnupg && \
|
||||
|
||||
# YAML + dev dependencies + additional packages
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y libyaml-dev libzip-dev libgmp-dev libpng-dev libmagickwand-dev gnupg2 $ADDITIONAL_PACKAGES && \
|
||||
apt-get install -y libyaml-dev libzip-dev libgmp-dev libpng-dev libmagickwand-dev gnupg2 git $ADDITIONAL_PACKAGES && \
|
||||
printf "\n" | pecl install yaml imagick && docker-php-ext-enable yaml imagick && \
|
||||
docker-php-ext-install gd
|
||||
|
||||
|
0
img/.gitkeep
Normal file
0
img/.gitkeep
Normal file
@ -2,7 +2,8 @@ import React, {useCallback, useContext} from 'react';
|
||||
import {Link, NavLink} from "react-router-dom";
|
||||
import Icon from "shared/elements/icon";
|
||||
import {LocaleContext} from "shared/locale";
|
||||
import {Avatar, styled} from "@mui/material";
|
||||
import {styled} from "@mui/material";
|
||||
import ProfilePicture from "shared/elements/profile-picture";
|
||||
|
||||
const ProfileLink = styled(Link)((props) => ({
|
||||
"& > div": {
|
||||
@ -114,7 +115,7 @@ export default function Sidebar(props) {
|
||||
<div className="info">
|
||||
<div className={"d-block text-light"}>{L("account.logged_in_as")}:</div>
|
||||
<ProfileLink to={"/admin/profile"}>
|
||||
<Avatar fontSize={"small"} />
|
||||
<ProfilePicture user={api.user} />
|
||||
<span>{api.user?.name || L("account.not_logged_in")}</span>
|
||||
</ProfileLink>
|
||||
</div>
|
||||
|
@ -78,16 +78,19 @@ export default function LogView(props) {
|
||||
let column = new DataColumn(L("logs.message"), "message");
|
||||
column.sortable = false;
|
||||
column.renderData = (L, entry) => {
|
||||
let lines = entry.message.trim().split("\n");
|
||||
return <Box display={"grid"} gridTemplateColumns={"40px auto"}>
|
||||
<Box alignSelf={"top"} textAlign={"center"}>
|
||||
{lines.length > 1 &&
|
||||
<IconButton size={"small"} onClick={() => onToggleDetails(entry)}
|
||||
title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}>
|
||||
{entry.showDetails ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
}
|
||||
</Box>
|
||||
<Box alignSelf={"center"}>
|
||||
<pre>
|
||||
{entry.showDetails ? entry.message : entry.message.split("\n")[0]}
|
||||
{entry.showDetails ? entry.message : lines[0]}
|
||||
</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
|
53
react/admin-panel/src/views/profile/change-password-box.js
Normal file
53
react/admin-panel/src/views/profile/change-password-box.js
Normal file
@ -0,0 +1,53 @@
|
||||
import {Password} from "@mui/icons-material";
|
||||
import SpacedFormGroup from "../../elements/form-group";
|
||||
import {Box, FormControl, FormLabel, TextField} from "@mui/material";
|
||||
import PasswordStrength from "shared/elements/password-strength";
|
||||
import CollapseBox from "./collapse-box";
|
||||
import React, {useContext} from "react";
|
||||
import {LocaleContext} from "shared/locale";
|
||||
|
||||
export default function ChangePasswordBox(props) {
|
||||
|
||||
// meta
|
||||
const {changePassword, setChangePassword, ...other} = props;
|
||||
const {translate: L} = useContext(LocaleContext);
|
||||
|
||||
return <CollapseBox title={L("account.change_password")}
|
||||
icon={<Password />}
|
||||
{...other} >
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password_old")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"}
|
||||
size="small"
|
||||
type={"password"}
|
||||
placeholder={L("general.unchanged")}
|
||||
value={changePassword.old}
|
||||
onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password_new")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"}
|
||||
size="small"
|
||||
type={"password"}
|
||||
value={changePassword.new}
|
||||
onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password_confirm")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"}
|
||||
size="small"
|
||||
type={"password"}
|
||||
value={changePassword.confirm}
|
||||
onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<Box className={"w-50"}>
|
||||
<PasswordStrength password={changePassword.new} minLength={6} />
|
||||
</Box>
|
||||
</CollapseBox>
|
||||
}
|
131
react/admin-panel/src/views/profile/edit-picture.js
Normal file
131
react/admin-panel/src/views/profile/edit-picture.js
Normal file
@ -0,0 +1,131 @@
|
||||
import {Box, Button, CircularProgress, Slider, styled} from "@mui/material";
|
||||
import {useCallback, useContext, useRef, useState} from "react";
|
||||
import {LocaleContext} from "shared/locale";
|
||||
import PreviewProfilePicture from "./preview-picture";
|
||||
import {Delete, Edit} from "@mui/icons-material";
|
||||
import ProfilePicture from "shared/elements/profile-picture";
|
||||
|
||||
const ProfilePictureBox = styled(Box)((props) => ({
|
||||
padding: props.theme.spacing(2),
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto 60px",
|
||||
gridGap: props.theme.spacing(2),
|
||||
textAlign: "center",
|
||||
}));
|
||||
|
||||
const VerticalButtonBar = styled(Box)((props) => ({
|
||||
"& > button": {
|
||||
width: "100%",
|
||||
marginBottom: props.theme.spacing(1),
|
||||
}
|
||||
}));
|
||||
|
||||
export default function EditProfilePicture(props) {
|
||||
|
||||
// meta
|
||||
const {translate: L} = useContext(LocaleContext);
|
||||
// const [scale, setScale] = useState(100);
|
||||
const scale = useRef(100);
|
||||
const {api, showDialog, setProfile, profile, setDialogData, ...other} = props
|
||||
|
||||
const onUploadPicture = useCallback((data) => {
|
||||
api.uploadPicture(data, scale.current / 100.0).then((res) => {
|
||||
if (!res.success) {
|
||||
showDialog(res.msg, L("Error uploading profile picture"));
|
||||
} else {
|
||||
setProfile({...profile, profilePicture: res.profilePicture});
|
||||
}
|
||||
})
|
||||
}, [api, scale.current, showDialog, profile]);
|
||||
|
||||
const onRemoveImage = useCallback(() => {
|
||||
api.removePicture().then((res) => {
|
||||
if (!res.success) {
|
||||
showDialog(res.msg, L("Error removing profile picture"));
|
||||
} else {
|
||||
setProfile({...profile, profilePicture: null});
|
||||
}
|
||||
});
|
||||
}, [api, showDialog, profile]);
|
||||
|
||||
const onOpenDialog = useCallback((file = null, data = null) => {
|
||||
|
||||
let img = null;
|
||||
if (data !== null) {
|
||||
img = new Image();
|
||||
img.src = data;
|
||||
}
|
||||
|
||||
setDialogData({
|
||||
show: true,
|
||||
title: L("account.change_picture_title"),
|
||||
text: L("account.change_picture_text"),
|
||||
options: data === null ? [L("general.cancel")] : [L("general.apply"), L("general.cancel")],
|
||||
inputs: data === null ? [{
|
||||
key: "pfp-loading",
|
||||
type: "custom",
|
||||
element: CircularProgress,
|
||||
}] : [
|
||||
{
|
||||
key: "pfp-preview",
|
||||
type: "custom",
|
||||
element: PreviewProfilePicture,
|
||||
img: img,
|
||||
scale: scale.current,
|
||||
setScale: (v) => scale.current = v,
|
||||
},
|
||||
],
|
||||
onOption: (option) => {
|
||||
if (option === 0 && file) {
|
||||
onUploadPicture(file)
|
||||
}
|
||||
|
||||
// scale.current = 100;
|
||||
}
|
||||
})
|
||||
}, [setDialogData, onUploadPicture]);
|
||||
|
||||
const onSelectImage = useCallback(() => {
|
||||
let fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/jpeg,image/jpg,image/png";
|
||||
fileInput.onchange = () => {
|
||||
let file = fileInput.files[0];
|
||||
if (file) {
|
||||
let reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
onOpenDialog(file, e.target.result);
|
||||
}
|
||||
|
||||
onOpenDialog();
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
}, [onOpenDialog]);
|
||||
|
||||
|
||||
return <ProfilePictureBox {...other}>
|
||||
<ProfilePicture user={profile} onClick={onSelectImage} />
|
||||
<VerticalButtonBar>
|
||||
<Button variant="outlined" size="small"
|
||||
startIcon={<Edit />}
|
||||
onClick={onSelectImage}>
|
||||
{L("account.change_picture")}
|
||||
</Button>
|
||||
{profile.profilePicture &&
|
||||
<Button variant="outlined" size="small"
|
||||
startIcon={<Delete />} color="secondary"
|
||||
onClick={() => setDialogData({
|
||||
show: true,
|
||||
title: L("account.picture_remove_title"),
|
||||
message: L("account.picture_remove_text"),
|
||||
options: [L("general.confirm"), L("general.cancel")],
|
||||
onOption: (option) => option === 0 ? onRemoveImage() : true
|
||||
})}>
|
||||
{L("account.remove_picture")}
|
||||
</Button>
|
||||
}
|
||||
</VerticalButtonBar>
|
||||
</ProfilePictureBox>
|
||||
}
|
172
react/admin-panel/src/views/profile/gpg-box.js
Normal file
172
react/admin-panel/src/views/profile/gpg-box.js
Normal file
@ -0,0 +1,172 @@
|
||||
import React, {useCallback, useContext, useState} from "react";
|
||||
import {LocaleContext} from "shared/locale";
|
||||
import {Box, Button, CircularProgress, FormControl, FormLabel, styled, TextField} from "@mui/material";
|
||||
import {CheckCircle, CloudUpload, ErrorOutline, Remove, Upload, VpnKey} from "@mui/icons-material";
|
||||
import SpacedFormGroup from "../../elements/form-group";
|
||||
import ButtonBar from "../../elements/button-bar";
|
||||
import CollapseBox from "./collapse-box";
|
||||
|
||||
const GpgKeyField = styled(TextField)((props) => ({
|
||||
"& > div": {
|
||||
fontFamily: "monospace",
|
||||
padding: props.theme.spacing(1),
|
||||
fontSize: '0.8rem',
|
||||
},
|
||||
marginBottom: props.theme.spacing(1)
|
||||
}));
|
||||
|
||||
const GpgFingerprintBox = styled(Box)((props) => ({
|
||||
"& > svg": {
|
||||
marginRight: props.theme.spacing(1),
|
||||
},
|
||||
"& > code": {
|
||||
cursor: "pointer"
|
||||
}
|
||||
}));
|
||||
|
||||
const VisuallyHiddenInput = styled('input')({
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
height: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
width: 1,
|
||||
});
|
||||
|
||||
export default function GpgBox(props) {
|
||||
|
||||
// meta
|
||||
const {profile, setProfile, api, showDialog, ...other} = props;
|
||||
const {translate: L} = useContext(LocaleContext);
|
||||
|
||||
// data
|
||||
const [gpgKey, setGpgKey] = useState("");
|
||||
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
|
||||
|
||||
// ui
|
||||
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
|
||||
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
|
||||
|
||||
const onUploadGPG = useCallback(() => {
|
||||
if (!isGpgKeyUploading) {
|
||||
setGpgKeyUploading(true);
|
||||
api.uploadGPG(gpgKey).then(data => {
|
||||
setGpgKeyUploading(false);
|
||||
if (!data.success) {
|
||||
showDialog(data.msg, L("account.upload_gpg_error"));
|
||||
} else {
|
||||
setProfile({...profile, gpgKey: data.gpgKey});
|
||||
setGpgKey("");
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [api, showDialog, isGpgKeyUploading, profile, gpgKey]);
|
||||
|
||||
const onRemoveGpgKey = useCallback(() => {
|
||||
if (!isGpgKeyRemoving) {
|
||||
setGpgKeyRemoving(true);
|
||||
api.removeGPG(gpgKeyPassword).then(data => {
|
||||
setGpgKeyRemoving(false);
|
||||
setGpgKeyPassword("");
|
||||
if (!data.success) {
|
||||
showDialog(data.msg, L("account.remove_gpg_error"));
|
||||
} else {
|
||||
setProfile({...profile, gpgKey: null});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword, profile]);
|
||||
|
||||
const getFileContents = useCallback((file, callback) => {
|
||||
let reader = new FileReader();
|
||||
let data = "";
|
||||
reader.onload = function(event) {
|
||||
data += event.target.result;
|
||||
if (reader.readyState === 2) {
|
||||
if (!data.match(/^-+\s*BEGIN/m)) {
|
||||
showDialog(L("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file"));
|
||||
return false;
|
||||
} else {
|
||||
callback(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
setGpgKey("");
|
||||
reader.readAsText(file);
|
||||
}, [showDialog]);
|
||||
|
||||
return <CollapseBox title={L("account.gpg_key")} {...other}
|
||||
|
||||
icon={<VpnKey />}>
|
||||
{
|
||||
profile.gpgKey ? <Box>
|
||||
<GpgFingerprintBox mb={2}>
|
||||
{ profile.gpgKey.confirmed ?
|
||||
<CheckCircle color="info" title={L("account.gpg_key_confirmed")} /> :
|
||||
<ErrorOutline color="secondary" title={L("account.gpg_key_pending")} />
|
||||
}
|
||||
GPG-Fingerprint: <code title={L("general.click_to_copy")}
|
||||
onClick={() => navigator.clipboard.writeText(profile.gpgKey.fingerprint)}>
|
||||
{profile.gpgKey.fingerprint}
|
||||
</code>
|
||||
</GpgFingerprintBox>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"} size="small"
|
||||
value={gpgKeyPassword} type={"password"}
|
||||
onChange={e => setGpgKeyPassword(e.target.value)}
|
||||
placeholder={L("account.password")}
|
||||
/>
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />}
|
||||
color="secondary" onClick={onRemoveGpgKey}
|
||||
variant="outlined" size="small"
|
||||
disabled={isGpgKeyRemoving || !api.hasPermission("user/removeGPG")}>
|
||||
{isGpgKeyRemoving ? L("general.removing") + "…" : L("general.remove")}
|
||||
</Button>
|
||||
</Box> :
|
||||
<Box>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.gpg_key")}</FormLabel>
|
||||
<GpgKeyField value={gpgKey} multiline={true} rows={8}
|
||||
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}
|
||||
placeholder={L("account.gpg_key_placeholder_text")}
|
||||
onChange={e => setGpgKey(e.target.value)}
|
||||
onDrop={e => {
|
||||
let file = e.dataTransfer.files[0];
|
||||
getFileContents(file, (data) => {
|
||||
setGpgKey(data);
|
||||
});
|
||||
return false;
|
||||
}}/>
|
||||
</SpacedFormGroup>
|
||||
<ButtonBar>
|
||||
<Button size="small"
|
||||
variant="outlined"
|
||||
startIcon={<CloudUpload />}
|
||||
component={"label"}>
|
||||
Upload file
|
||||
<VisuallyHiddenInput type={"file"} onChange={e => {
|
||||
let file = e.target.files[0];
|
||||
getFileContents(file, (data) => {
|
||||
setGpgKey(data);
|
||||
});
|
||||
return false;
|
||||
}} />
|
||||
</Button>
|
||||
<Button startIcon={isGpgKeyUploading ? <CircularProgress size={12} /> : <Upload />}
|
||||
color="primary" onClick={onUploadGPG}
|
||||
variant="outlined" size="small"
|
||||
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}>
|
||||
{isGpgKeyUploading ? L("general.uploading") + "…" : L("general.upload")}
|
||||
</Button>
|
||||
</ButtonBar>
|
||||
</Box>
|
||||
}
|
||||
</CollapseBox>
|
||||
}
|
99
react/admin-panel/src/views/profile/mfa-box.js
Normal file
99
react/admin-panel/src/views/profile/mfa-box.js
Normal file
@ -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)),
|
||||
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",
|
||||
|
@ -24,9 +24,8 @@ export default function MfaTotp(props) {
|
||||
if (api.hasPermission("tfa/generateQR")) {
|
||||
setDialogData({
|
||||
show: true,
|
||||
title: L("Register a 2FA-Device"),
|
||||
message: L("Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " +
|
||||
"On Android, you can use the Google Authenticator."),
|
||||
title: L("account.register_2fa_device"),
|
||||
message: L("account.register_2fa_totp_text"),
|
||||
inputs: [
|
||||
{
|
||||
type: "custom", element: Box, textAlign: "center", key: "qr-code",
|
||||
|
53
react/admin-panel/src/views/profile/preview-picture.js
Normal file
53
react/admin-panel/src/views/profile/preview-picture.js
Normal file
@ -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,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormLabel, styled,
|
||||
FormLabel,
|
||||
TextField
|
||||
} from "@mui/material";
|
||||
import {
|
||||
CheckCircle,
|
||||
CloudUpload,
|
||||
ErrorOutline,
|
||||
Fingerprint,
|
||||
Password,
|
||||
Remove,
|
||||
Save,
|
||||
Upload,
|
||||
VpnKey
|
||||
} from "@mui/icons-material";
|
||||
import CollapseBox from "./collapse-box";
|
||||
import ButtonBar from "../../elements/button-bar";
|
||||
import MfaTotp from "./mfa-totp";
|
||||
import MfaFido from "./mfa-fido";
|
||||
import Dialog from "shared/elements/dialog";
|
||||
import PasswordStrength from "shared/elements/password-strength";
|
||||
import SpacedFormGroup from "../../elements/form-group";
|
||||
|
||||
const GpgKeyField = styled(TextField)((props) => ({
|
||||
"& > div": {
|
||||
fontFamily: "monospace",
|
||||
padding: props.theme.spacing(1),
|
||||
fontSize: '0.8rem',
|
||||
},
|
||||
marginBottom: props.theme.spacing(1)
|
||||
}));
|
||||
|
||||
const GpgFingerprintBox = styled(Box)((props) => ({
|
||||
"& > svg": {
|
||||
marginRight: props.theme.spacing(1),
|
||||
},
|
||||
"& > code": {
|
||||
cursor: "pointer"
|
||||
}
|
||||
}));
|
||||
|
||||
const MFAOptions = styled(Box)((props) => ({
|
||||
"& > div": {
|
||||
borderColor: props.theme.palette.divider,
|
||||
borderStyle: "solid",
|
||||
borderWidth: 1,
|
||||
borderRadius: 5,
|
||||
maxWidth: 150,
|
||||
cursor: "pointer",
|
||||
textAlign: "center",
|
||||
display: "inline-grid",
|
||||
gridTemplateRows: "130px 50px",
|
||||
alignItems: "center",
|
||||
padding: props.theme.spacing(1),
|
||||
marginRight: props.theme.spacing(1),
|
||||
"&:hover": {
|
||||
backgroundColor: "lightgray",
|
||||
},
|
||||
"& img": {
|
||||
width: "100%",
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
||||
const VisuallyHiddenInput = styled('input')({
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
height: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
width: 1,
|
||||
});
|
||||
import ChangePasswordBox from "./change-password-box";
|
||||
import GpgBox from "./gpg-box";
|
||||
import MultiFactorBox from "./mfa-box";
|
||||
import EditProfilePicture from "./edit-picture";
|
||||
|
||||
export default function ProfileView(props) {
|
||||
|
||||
@ -100,16 +37,10 @@ export default function ProfileView(props) {
|
||||
// data
|
||||
const [profile, setProfile] = useState({...api.user});
|
||||
const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
|
||||
const [gpgKey, setGpgKey] = useState("");
|
||||
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
|
||||
const [mfaPassword, set2FAPassword] = useState("");
|
||||
|
||||
// ui
|
||||
const [openedTab, setOpenedTab] = useState(null);
|
||||
const [isSaving, setSaving] = useState(false);
|
||||
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
|
||||
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
|
||||
const [is2FARemoving, set2FARemoving] = useState(false);
|
||||
const [dialogData, setDialogData] = useState({show: false});
|
||||
|
||||
const onUpdateProfile = useCallback(() => {
|
||||
@ -146,69 +77,6 @@ export default function ProfileView(props) {
|
||||
|
||||
}, [profile, changePassword, api, showDialog, isSaving]);
|
||||
|
||||
const onUploadGPG = useCallback(() => {
|
||||
if (!isGpgKeyUploading) {
|
||||
setGpgKeyUploading(true);
|
||||
api.uploadGPG(gpgKey).then(data => {
|
||||
setGpgKeyUploading(false);
|
||||
if (!data.success) {
|
||||
showDialog(data.msg, L("account.upload_gpg_error"));
|
||||
} else {
|
||||
setProfile({...profile, gpgKey: data.gpgKey});
|
||||
setGpgKey("");
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [api, showDialog, isGpgKeyUploading, profile, gpgKey]);
|
||||
|
||||
const onRemoveGpgKey = useCallback(() => {
|
||||
if (!isGpgKeyRemoving) {
|
||||
setGpgKeyRemoving(true);
|
||||
api.removeGPG(gpgKeyPassword).then(data => {
|
||||
setGpgKeyRemoving(false);
|
||||
setGpgKeyPassword("");
|
||||
if (!data.success) {
|
||||
showDialog(data.msg, L("account.remove_gpg_error"));
|
||||
} else {
|
||||
setProfile({...profile, gpgKey: null});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword, profile]);
|
||||
|
||||
const onRemove2FA = useCallback(() => {
|
||||
if (!is2FARemoving) {
|
||||
set2FARemoving(true);
|
||||
api.remove2FA(mfaPassword).then(data => {
|
||||
set2FARemoving(false);
|
||||
set2FAPassword("");
|
||||
if (!data.success) {
|
||||
showDialog(data.msg, L("account.remove_2fa_error"));
|
||||
} else {
|
||||
setProfile({...profile, twoFactorToken: null});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [api, showDialog, is2FARemoving, mfaPassword, profile]);
|
||||
|
||||
const getFileContents = useCallback((file, callback) => {
|
||||
let reader = new FileReader();
|
||||
let data = "";
|
||||
reader.onload = function(event) {
|
||||
data += event.target.result;
|
||||
if (reader.readyState === 2) {
|
||||
if (!data.match(/^-+\s*BEGIN/m)) {
|
||||
showDialog(L("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file"));
|
||||
return false;
|
||||
} else {
|
||||
callback(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
setGpgKey("");
|
||||
reader.readAsText(file);
|
||||
}, [showDialog]);
|
||||
|
||||
return <>
|
||||
<div className={"content-header"}>
|
||||
<div className={"container-fluid"}>
|
||||
@ -227,7 +95,11 @@ export default function ProfileView(props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"content"}>
|
||||
<Box>
|
||||
<Box display={"grid"} gridTemplateColumns={"300px auto"}>
|
||||
<EditProfilePicture api={api} showDialog={showDialog} setProfile={setProfile}
|
||||
profile={profile} setDialogData={setDialogData} />
|
||||
<Box p={2}>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.username")}</FormLabel>
|
||||
<FormControl>
|
||||
@ -246,154 +118,33 @@ export default function ProfileView(props) {
|
||||
onChange={e => setProfile({...profile, fullName: e.target.value })} />
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"}
|
||||
size={"small"}
|
||||
value={profile.email ?? ""}
|
||||
disabled={true}/>
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<CollapseBox title={L("account.change_password")} open={openedTab === "password"}
|
||||
<ChangePasswordBox open={openedTab === "password"}
|
||||
onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
|
||||
icon={<Password />}>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password_old")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"}
|
||||
size={"small"}
|
||||
type={"password"}
|
||||
placeholder={L("general.unchanged")}
|
||||
value={changePassword.old}
|
||||
onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password_new")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"}
|
||||
size={"small"}
|
||||
type={"password"}
|
||||
value={changePassword.new}
|
||||
onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password_confirm")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"}
|
||||
size={"small"}
|
||||
type={"password"}
|
||||
value={changePassword.confirm}
|
||||
onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<Box className={"w-50"}>
|
||||
<PasswordStrength password={changePassword.new} minLength={6} />
|
||||
</Box>
|
||||
</CollapseBox>
|
||||
changePassword={changePassword}
|
||||
setChangePassword={setChangePassword} />
|
||||
|
||||
<CollapseBox title={L("account.gpg_key")} open={openedTab === "gpg"}
|
||||
<GpgBox open={openedTab === "gpg"}
|
||||
onToggle={() => setOpenedTab(openedTab === "gpg" ? "" : "gpg")}
|
||||
icon={<VpnKey />}>
|
||||
{
|
||||
profile.gpgKey ? <Box>
|
||||
<GpgFingerprintBox mb={2}>
|
||||
{ profile.gpgKey.confirmed ?
|
||||
<CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
|
||||
<ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")} />
|
||||
}
|
||||
GPG-Fingerprint: <code title={L("general.click_to_copy")} onClick={() => navigator.clipboard.writeText(profile.gpgKey.fingerprint)}>
|
||||
{profile.gpgKey.fingerprint}
|
||||
</code>
|
||||
</GpgFingerprintBox>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"} size={"small"}
|
||||
value={gpgKeyPassword} type={"password"}
|
||||
onChange={e => setGpgKeyPassword(e.target.value)}
|
||||
placeholder={L("account.password")}
|
||||
/>
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />}
|
||||
color={"secondary"} onClick={onRemoveGpgKey}
|
||||
variant={"outlined"} size={"small"}
|
||||
disabled={isGpgKeyRemoving || !api.hasPermission("user/removeGPG")}>
|
||||
{isGpgKeyRemoving ? L("general.removing") + "…" : L("general.remove")}
|
||||
</Button>
|
||||
</Box> :
|
||||
<Box>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.gpg_key")}</FormLabel>
|
||||
<GpgKeyField value={gpgKey} multiline={true} rows={8}
|
||||
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}
|
||||
placeholder={L("account.gpg_key_placeholder_text")}
|
||||
onChange={e => setGpgKey(e.target.value)}
|
||||
onDrop={e => {
|
||||
let file = e.dataTransfer.files[0];
|
||||
getFileContents(file, (data) => {
|
||||
setGpgKey(data);
|
||||
});
|
||||
return false;
|
||||
}}/>
|
||||
</SpacedFormGroup>
|
||||
<ButtonBar>
|
||||
<Button size={"small"}
|
||||
variant={"outlined"}
|
||||
startIcon={<CloudUpload />}
|
||||
component={"label"}>
|
||||
Upload file
|
||||
<VisuallyHiddenInput type={"file"} onChange={e => {
|
||||
let file = e.target.files[0];
|
||||
getFileContents(file, (data) => {
|
||||
setGpgKey(data);
|
||||
});
|
||||
return false;
|
||||
}} />
|
||||
</Button>
|
||||
<Button startIcon={isGpgKeyUploading ? <CircularProgress size={12} /> : <Upload />}
|
||||
color={"primary"} onClick={onUploadGPG}
|
||||
variant={"outlined"} size={"small"}
|
||||
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}>
|
||||
{isGpgKeyUploading ? L("general.uploading") + "…" : L("general.upload")}
|
||||
</Button>
|
||||
</ButtonBar>
|
||||
</Box>
|
||||
}
|
||||
</CollapseBox>
|
||||
profile={profile} setProfile={setProfile}
|
||||
api={api} showDialog={showDialog} />
|
||||
|
||||
<CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
|
||||
<MultiFactorBox open={openedTab === "2fa"}
|
||||
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
|
||||
icon={<Fingerprint />}>
|
||||
{profile.twoFactorToken && profile.twoFactorToken.confirmed ?
|
||||
<Box>
|
||||
<GpgFingerprintBox mb={2}>
|
||||
{ profile.twoFactorToken.confirmed ?
|
||||
<CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
|
||||
<ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")} />
|
||||
}
|
||||
{L("account.2fa_type_" + profile.twoFactorToken.type)}
|
||||
</GpgFingerprintBox>
|
||||
<SpacedFormGroup>
|
||||
<FormLabel>{L("account.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<TextField variant={"outlined"} size={"small"}
|
||||
value={mfaPassword} type={"password"}
|
||||
onChange={e => set2FAPassword(e.target.value)}
|
||||
placeholder={L("account.password")}
|
||||
/>
|
||||
</FormControl>
|
||||
</SpacedFormGroup>
|
||||
<Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
|
||||
color={"secondary"} onClick={onRemove2FA}
|
||||
variant={"outlined"} size={"small"}
|
||||
disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
|
||||
{is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
|
||||
</Button>
|
||||
</Box> :
|
||||
<MFAOptions>
|
||||
<MfaTotp api={api} showDialog={showDialog} setDialogData={setDialogData}
|
||||
set2FA={token => setProfile({...profile, twoFactorToken: token })} />
|
||||
<MfaFido api={api} showDialog={showDialog} setDialogData={setDialogData}
|
||||
set2FA={token => setProfile({...profile, twoFactorToken: token })} />
|
||||
</MFAOptions>
|
||||
}
|
||||
</CollapseBox>
|
||||
profile={profile} setProfile={setProfile}
|
||||
setDialogData={setDialogData}
|
||||
api={api} showDialog={showDialog} />
|
||||
|
||||
<Box mt={2}>
|
||||
<Button variant={"outlined"} color={"primary"}
|
||||
@ -403,7 +154,7 @@ export default function ProfileView(props) {
|
||||
{isSaving ? L("general.saving") + "…" : L("general.save")}
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Dialog show={dialogData.show}
|
||||
title={dialogData.title}
|
||||
|
@ -18,7 +18,7 @@ export default class API {
|
||||
return this.loggedIn ? this.session.csrfToken : null;
|
||||
}
|
||||
|
||||
async apiCall(method, params, expectBinary=false) {
|
||||
async apiCall(method, params = {}, expectBinary=false) {
|
||||
params = params || { };
|
||||
const csrfToken = this.csrfToken();
|
||||
const config = {method: 'post'};
|
||||
|
45
react/shared/elements/profile-picture.js
Normal file
45
react/shared/elements/profile-picture.js
Normal file
@ -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",
|
||||
}],
|
||||
userVerification: "discouraged",
|
||||
attestation: "direct",
|
||||
},
|
||||
signal: abortSignal
|
||||
}).then((res) => {
|
||||
|
Loading…
Reference in New Issue
Block a user