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"?>
|
<?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?;
|
||||||
}
|
}
|
||||||
|
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";
|
$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
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>
|
||||||
|
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)),
|
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",
|
||||||
|
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,
|
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'};
|
||||||
|
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",
|
type: "public-key",
|
||||||
}],
|
}],
|
||||||
userVerification: "discouraged",
|
userVerification: "discouraged",
|
||||||
attestation: "direct",
|
|
||||||
},
|
},
|
||||||
signal: abortSignal
|
signal: abortSignal
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
|
Loading…
Reference in New Issue
Block a user