diff --git a/Core/API/MailAPI.class.php b/Core/API/MailAPI.class.php index a3cf2e8..f36061a 100644 --- a/Core/API/MailAPI.class.php +++ b/Core/API/MailAPI.class.php @@ -227,9 +227,9 @@ namespace Core\API\Mail { $sql = $this->context->getSQL(); $mailQueueItems = MailQueueItem::findBy(MailQueueItem::createBuilder($sql, false) - ->whereGt("retryCount", 0) + ->whereGt("retry_count", 0) ->whereEq("status", "waiting") - ->where(new Compare("nextTry", $sql->now(), "<="))); + ->where(new Compare("next_try", $sql->now(), "<="))); $this->success = ($mailQueueItems !== false); $this->lastError = $sql->getLastError(); diff --git a/Core/API/Swagger.class.php b/Core/API/Swagger.class.php index 6bbd1be..2cf6e5e 100644 --- a/Core/API/Swagger.class.php +++ b/Core/API/Swagger.class.php @@ -68,6 +68,7 @@ class Swagger extends Request { foreach (self::getApiEndpoints() as $endpoint => $apiClass) { $body = null; $requiredProperties = []; + $endpoint = "/$endpoint"; $apiObject = $apiClass->newInstance($this->context, false); if (!$this->canView($permissions[strtolower($endpoint)] ?? [], $apiObject)) { continue; diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index aa95faf..e99b2b1 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -222,7 +222,7 @@ namespace Core\API\User { public function __construct(Context $context, $externalCall = false) { parent::__construct($context, $externalCall, - self::getPaginationParameters(['id', 'name', 'email', 'groups', 'registeredAt'], + self::getPaginationParameters(['id', 'name', 'fullName', 'email', 'groups', 'registeredAt', 'confirmed'], 'id', 'asc') ); } @@ -341,7 +341,9 @@ namespace Core\API\User { $this->result["loggedIn"] = true; $userGroups = array_keys($currentUser->getGroups()); $this->result["user"] = $currentUser->jsonSerialize(); - $this->result["session"] = $this->context->getSession()->jsonSerialize(); + $this->result["session"] = $this->context->getSession()->jsonSerialize([ + "id", "expires", "stayLoggedIn", "data", "csrfToken" + ]); } $sql = $this->context->getSQL(); @@ -1022,7 +1024,7 @@ namespace Core\API\User { $userToken = UserToken::findBy(UserToken::createBuilder($sql, true) ->whereFalse("used") - ->whereEq("tokenType", UserToken::TYPE_EMAIL_CONFIRM) + ->whereEq("token_type", UserToken::TYPE_EMAIL_CONFIRM) ->whereEq("user_id", $user->getId())); $validHours = 48; diff --git a/Core/Configuration/CreateDatabase.class.php b/Core/Configuration/CreateDatabase.class.php index 766a027..da08dc4 100644 --- a/Core/Configuration/CreateDatabase.class.php +++ b/Core/Configuration/CreateDatabase.class.php @@ -85,7 +85,6 @@ class CreateDatabase extends DatabaseScript { $method = "$className::getHandler"; $handler = call_user_func($method, $sql, null, true); $persistables[$handler->getTableName()] = $handler; - foreach ($handler->getNMRelations() as $nmTableName => $nmRelation) { $persistables[$nmTableName] = $nmRelation; } diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php index 6b15685..6896fab 100644 --- a/Core/Configuration/Settings.class.php +++ b/Core/Configuration/Settings.class.php @@ -107,7 +107,7 @@ class Settings { public static function loadDefaults(): Settings { $hostname = $_SERVER["SERVER_NAME"] ?? null; if (empty($hostname)) { - $hostname = $_SERVER["HTTP_HOST"]; + $hostname = $_SERVER["HTTP_HOST"] ?? null; if (empty($hostname)) { $hostname = "localhost"; } diff --git a/Core/Documents/Security.class.php b/Core/Documents/Security.class.php new file mode 100644 index 0000000..736a89d --- /dev/null +++ b/Core/Documents/Security.class.php @@ -0,0 +1,99 @@ +searchable = false; + } + + public function getTitle(): string { + return "security.txt"; + } + + public function getCode(array $params = []) { + + $activeRoute = $this->router->getActiveRoute(); + + $sql = $this->getContext()->getSQL(); + $settings = $this->getSettings(); + $mailSettings = Settings::getAll($sql, "^mail_"); + + if ($activeRoute->getPattern() === "/.well-known/security.txt") { + + // The order in which they appear is not an indication of priority; the listed languages are intended to have equal priority. + $languageCodes = implode(", ", array_map(function (Language $language) { + return $language->getShortCode(); + }, Language::findAll($sql))); + + $expires = (new \DateTime())->setTime(0, 0, 0)->modify("+3 months"); + $baseUrl = $settings->getBaseUrl(); + $gpgKey = null; + + $lines = [ + "# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base", + "# Any non-site specific issues can be reported via the github security reporting feature:", + "# https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability", + "", + "Canonical: $baseUrl/.well-known/security.txt", + "Preferred-Languages: $languageCodes", + "Expires: " . $expires->format(\DateTime::ATOM), + "", + ]; + + if (isset($mailSettings["mail_contact"])) { + $lines[] = "Contact: " . $mailSettings["mail_contact"]; + + if (isset($mailSettings["mail_contact_gpg_key_id"])) { + $gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]); + if ($gpgKey) { + $lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt"; + } + } + } + + $code = implode("\n", $lines); + + if ($gpgKey !== null) { + $res = GpgKey::sign($code, $gpgKey->getFingerprint()); + if ($res["success"]) { + $code = $res["data"]; + } + } + + return $code; + } else if ($activeRoute->getPattern() === "/.well-known/gpg-key.txt") { + + if (isset($mailSettings["mail_contact_gpg_key_id"])) { + $gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]); + if ($gpgKey !== null) { + header("Content-Type: text/plain"); + $res = $gpgKey->_export(true); + if ($res["success"]) { + return $res["data"]; + } else { + return "Error exporting public key: " . $res["msg"]; + } + } + } + } + + http_response_code(404); + return ""; + } + + public function sendHeaders(): void { + parent::sendHeaders(); + header("Content-Type: text/plain"); + } +} \ No newline at end of file diff --git a/Core/Elements/Document.class.php b/Core/Elements/Document.class.php index 97c0841..cadd9a3 100644 --- a/Core/Elements/Document.class.php +++ b/Core/Elements/Document.class.php @@ -89,7 +89,7 @@ abstract class Document { } } - public function sendHeaders() { + public function sendHeaders(): void { if ($this->cspEnabled) { $cspWhiteList = implode(" ", $this->cspWhitelist); $csp = [ @@ -98,7 +98,8 @@ abstract class Document { "base-uri 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' 'unsafe-inline' data: https:;", - "script-src $cspWhiteList 'nonce-$this->cspNonce'" + "script-src $cspWhiteList 'nonce-$this->cspNonce'", + "frame-ancestors 'self'", ]; if ($this->getSettings()->isRecaptchaEnabled()) { $csp[] = "frame-src https://www.google.com/ 'self'"; @@ -107,6 +108,15 @@ abstract class Document { $compiledCSP = implode("; ", $csp); header("Content-Security-Policy: $compiledCSP;"); } + + // additional security headers + header("X-XSS-Protection: 0"); // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection + header("X-Content-Type-Options: nosniff"); + + if (getProtocol() === "https") { + $maxAge = 365 * 24 * 60 * 60; // 1 year in seconds + header("Strict-Transport-Security: max-age=$maxAge; includeSubDomains; preload"); + } } public abstract function getCode(array $params = []); diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index b80951b..c012567 100644 --- a/Core/Localization/de_DE/account.php +++ b/Core/Localization/de_DE/account.php @@ -2,6 +2,7 @@ return [ "title" => "Account", + "users" => "Benutzer", "login_title" => "Einloggen", "login_description" => "Loggen Sie sich in Ihren Account ein", "accept_invite_title" => "Einladung", @@ -32,6 +33,7 @@ return [ "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", @@ -57,4 +59,6 @@ return [ "error_profile_get" => "Fehler beim Laden des Benutzerprofils", "registered_at" => "Registriert am", "last_online" => "Zuletzt online", + "groups" => "Gruppen", + "logged_in_as" => "Eingeloggt als", ]; \ No newline at end of file diff --git a/Core/Localization/de_DE/general.php b/Core/Localization/de_DE/general.php index b260a9d..d6c554b 100644 --- a/Core/Localization/de_DE/general.php +++ b/Core/Localization/de_DE/general.php @@ -7,6 +7,7 @@ return [ "go_back" => "Zurück", "save" => "Speichern", "save_only" => "Nur Speichern", + "saved" => "Gespeichert", "saving" => "Speichere", "unsaved_changes" => "Du hast nicht gespeicherte Änderungen", "new" => "Neu", @@ -19,6 +20,8 @@ return [ "confirm" => "Bestätigen", "add" => "Hinzufügen", "ok" => "OK", + "id" => "ID", + "user" => "Benutzer", "language" => "Sprache", "loading" => "Laden", "logout" => "Ausloggen", diff --git a/Core/Localization/de_DE/settings.php b/Core/Localization/de_DE/settings.php index d9d1254..4f6430b 100644 --- a/Core/Localization/de_DE/settings.php +++ b/Core/Localization/de_DE/settings.php @@ -10,6 +10,7 @@ return [ "token" => "Token", "request_new_key" => "Neuen Schlüssel anfordern", "show_only_active_keys" => "Zeige nur aktive Schlüssel", + "no_api_key_registered" => "Keine gültigen API-Schlüssel registriert", # GPG Key "gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...", diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index c77452a..58a689e 100644 --- a/Core/Localization/en_US/account.php +++ b/Core/Localization/en_US/account.php @@ -2,6 +2,7 @@ return [ "title" => "Account", + "users" => "Users", "login_title" => "Sign In", "login_description" => "Sign In into your account", "accept_invite_title" => "Invitation", @@ -32,6 +33,7 @@ return [ "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", @@ -57,4 +59,6 @@ return [ "error_profile_get" => "Error retrieving user profile", "registered_at" => "Registered At", "last_online" => "Last Online", + "groups" => "Groups", + "logged_in_as" => "Logged in as", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/general.php b/Core/Localization/en_US/general.php index 67d2a7b..fe39ed8 100644 --- a/Core/Localization/en_US/general.php +++ b/Core/Localization/en_US/general.php @@ -31,6 +31,8 @@ return [ "add" => "Add", "close" => "Close", "ok" => "OK", + "id" => "ID", + "user" => "User", "remove" => "Remove", "change" => "Change", "reset" => "Reset", @@ -38,6 +40,7 @@ return [ "go_back" => "Go Back", "save" => "Save", "save_only" => "Save Only", + "saved" => "Saved", "saving" => "Saving", "delete" => "Delete", "info" => "Info", diff --git a/Core/Localization/en_US/settings.php b/Core/Localization/en_US/settings.php index 481eb3d..28176f9 100644 --- a/Core/Localization/en_US/settings.php +++ b/Core/Localization/en_US/settings.php @@ -10,6 +10,7 @@ return [ "token" => "Token", "request_new_key" => "Request new Key", "show_only_active_keys" => "Show only active keys", + "no_api_key_registered" => "No valid API-Keys registered", # GPG Key "gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...", diff --git a/Core/Objects/DatabaseEntity/Attribute/UsePropertiesOf.php b/Core/Objects/DatabaseEntity/Attribute/UsePropertiesOf.php new file mode 100644 index 0000000..b873b2e --- /dev/null +++ b/Core/Objects/DatabaseEntity/Attribute/UsePropertiesOf.php @@ -0,0 +1,16 @@ +class = $class; + } + + public function getClass(): string { + return $this->class; + } +} \ No newline at end of file diff --git a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php index 337a5fa..2c10467 100644 --- a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php +++ b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php @@ -38,6 +38,7 @@ use Core\Objects\DatabaseEntity\Attribute\MultipleReference; use Core\Objects\DatabaseEntity\Attribute\NoFetch; use Core\Objects\DatabaseEntity\Attribute\Transient; use Core\Objects\DatabaseEntity\Attribute\Unique; +use Core\Objects\DatabaseEntity\Attribute\UsePropertiesOf; class DatabaseEntityHandler implements Persistable { @@ -75,14 +76,17 @@ class DatabaseEntityHandler implements Persistable { public function init() { $className = $this->entityClass->getName(); + $entityClass = $this->entityClass; + while ($usePropsOf = self::getAttribute($entityClass, UsePropertiesOf::class)) { + $entityClass = new \ReflectionClass($usePropsOf->getClass()); + } - - $uniqueColumns = self::getAttribute($this->entityClass, Unique::class); + $uniqueColumns = self::getAttribute($entityClass, Unique::class); if ($uniqueColumns) { $this->constraints[] = new \Core\Driver\SQL\Constraint\Unique($uniqueColumns->getColumns()); } - foreach ($this->entityClass->getProperties() as $property) { + foreach ($entityClass->getProperties() as $property) { $propertyName = $property->getName(); if ($propertyName === "id") { $this->properties[$propertyName] = $property; @@ -123,8 +127,8 @@ class DatabaseEntityHandler implements Persistable { try { $requestedClass = new \ReflectionClass($extendingClass); - if (!$requestedClass->isSubclassOf($this->entityClass)) { - $this->raiseError("Class '$extendingClass' must be an inheriting from '" . $this->entityClass->getName() . "' for an extending enum"); + if (!$requestedClass->isSubclassOf($entityClass)) { + $this->raiseError("Class '$extendingClass' must be an inheriting from '" . $entityClass->getName() . "' for an extending enum"); } else { $this->extendingClasses[$key] = $requestedClass; } diff --git a/Core/Objects/DatabaseEntity/GpgKey.class.php b/Core/Objects/DatabaseEntity/GpgKey.class.php index ce36bf4..3af6609 100644 --- a/Core/Objects/DatabaseEntity/GpgKey.class.php +++ b/Core/Objects/DatabaseEntity/GpgKey.class.php @@ -27,6 +27,10 @@ class GpgKey extends DatabaseEntity { $this->added = new \DateTime(); } + public function _encrypt(string $body): array { + return self::encrypt($body, $this->fingerprint); + } + public static function encrypt(string $body, string $gpgFingerprint): array { $gpgFingerprint = escapeshellarg($gpgFingerprint); $cmd = self::GPG2 . " --encrypt --output - --recipient $gpgFingerprint --trust-model always --batch --armor"; @@ -40,6 +44,23 @@ class GpgKey extends DatabaseEntity { } } + public function _sign(string $body): array { + return self::sign($body, $this->fingerprint); + } + + public static function sign(string $body, string $gpgFingerprint): array { + $gpgFingerprint = escapeshellarg($gpgFingerprint); + $cmd = self::GPG2 . " --clearsign --output - --local-user $gpgFingerprint --batch --armor"; + list($out, $err) = self::proc_exec($cmd, $body, true); + if ($out === null) { + return createError("Error while communicating with GPG agent"); + } else if ($err) { + return createError($err); + } else { + return ["success" => true, "data" => $out]; + } + } + private static function proc_exec(string $cmd, ?string $stdin = null, bool $raw = false): ?array { $descriptorSpec = array(0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]); $process = proc_open($cmd, $descriptorSpec, $pipes); @@ -101,7 +122,11 @@ class GpgKey extends DatabaseEntity { return createError($err); } - public static function export($gpgFingerprint, bool $armored): array { + public function _export(bool $armored = true): array { + return self::export($this->fingerprint, $armored); + } + + public static function export(string $gpgFingerprint, bool $armored): array { $cmd = self::GPG2 . " --export "; if ($armored) { $cmd .= "--armor "; diff --git a/Core/Objects/DatabaseEntity/Language.class.php b/Core/Objects/DatabaseEntity/Language.class.php index 5bfb1d5..6ffafd6 100644 --- a/Core/Objects/DatabaseEntity/Language.class.php +++ b/Core/Objects/DatabaseEntity/Language.class.php @@ -2,7 +2,6 @@ namespace Core\Objects\DatabaseEntity { - use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Attribute\MaxLength; use Core\Objects\DatabaseEntity\Attribute\Transient; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; diff --git a/Core/Objects/DatabaseEntity/Route.class.php b/Core/Objects/DatabaseEntity/Route.class.php index be2cc53..898b73d 100644 --- a/Core/Objects/DatabaseEntity/Route.class.php +++ b/Core/Objects/DatabaseEntity/Route.class.php @@ -249,6 +249,8 @@ abstract class Route extends DatabaseEntity { new DocumentRoute("/login", true, \Core\Documents\Account::class, "account/login.twig"), new DocumentRoute("/resendConfirmEmail", true, \Core\Documents\Account::class, "account/resend_confirm_email.twig"), new DocumentRoute("/debug", true, \Core\Documents\Info::class), + new DocumentRoute("/.well-known/security.txt", true, \Core\Documents\Security::class), + new DocumentRoute("/.well-known/gpg-key.txt", true, \Core\Documents\Security::class), new StaticFileRoute("/", true, "/static/welcome.html"), ]; } diff --git a/Core/Objects/DatabaseEntity/Session.class.php b/Core/Objects/DatabaseEntity/Session.class.php index 8793fd2..8456c7a 100644 --- a/Core/Objects/DatabaseEntity/Session.class.php +++ b/Core/Objects/DatabaseEntity/Session.class.php @@ -44,6 +44,16 @@ class Session extends DatabaseEntity { return null; } + if (is_array($session->data)) { + foreach ($session->data as $key => $value) { + $_SESSION[$key] = $value; + if ($key === "2faAuthenticated" && $value === true) { + $tfaToken = $session->getUser()->getTwoFactorToken(); + $tfaToken?->authenticate(); + } + } + } + $session->context = $context; return $session; } @@ -66,6 +76,7 @@ class Session extends DatabaseEntity { } public function setData(array $data) { + $this->data = $data; foreach ($data as $key => $value) { $_SESSION[$key] = $value; } @@ -107,7 +118,7 @@ class Session extends DatabaseEntity { $sql = $this->context->getSQL(); return $this->user->update($sql) && - $this->save($sql, ["expires", "data"]); + $this->save($sql, ["expires", "data", "os", "browser"]); } public function getCsrfToken(): string { diff --git a/Core/Templates/account/resend_confirm_email.twig b/Core/Templates/account/resend_confirm_email.twig index 0e1ec59..3229dd1 100644 --- a/Core/Templates/account/resend_confirm_email.twig +++ b/Core/Templates/account/resend_confirm_email.twig @@ -2,21 +2,23 @@ {% set view_title = 'account.resend_confirm_email_title' %} {% set view_icon = 'envelope' %} -{% set view_description = 'resend_confirm_email_description' %} +{% set view_description = 'account.resend_confirm_email_description' %} {% block view_content %} -

- {{ L("account.resend_confirm_email_form_title") }} -

-
-
-
- + +
+

+ {{ L("account.resend_confirm_email_form_title") }} +

+
+
+ +
+
-
-
+
diff --git a/Core/Templates/account/reset_password.twig b/Core/Templates/account/reset_password.twig index 64a7d4d..db5a2cf 100644 --- a/Core/Templates/account/reset_password.twig +++ b/Core/Templates/account/reset_password.twig @@ -40,18 +40,20 @@ {% endif %} {% else %} -

- {{ L("account.reset_password_request_form_title") }} -

-
-
-
- + +
+

+ {{ L("account.reset_password_request_form_title") }} +

+
+
+ +
+
-
-
+
diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 7d395b9..3b308f3 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -15,6 +15,10 @@ RUN apt-get update -y && \ printf "\n" | pecl install yaml imagick && docker-php-ext-enable yaml imagick && \ docker-php-ext-install gd +# Browscap +RUN mkdir -p /usr/local/etc/php/extra/ && \ + curl -s "https://browscap.org/stream?q=Full_PHP_BrowsCapINI" -o /usr/local/etc/php/extra/browscap.ini + # NodeJS RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - && \ apt-get update && \ diff --git a/docker/php/php.ini b/docker/php/php.ini index c1d49b9..a0d6ba7 100644 --- a/docker/php/php.ini +++ b/docker/php/php.ini @@ -932,7 +932,7 @@ default_socket_timeout = 60 ;extension=ldap ;extension=mbstring ;extension=exif ; Must be after mbstring as it depends on it -extension=mysqli +;extension=mysqli ;extension=oci8_12c ; Use with Oracle Database 12c Instant Client ;extension=oci8_19 ; Use with Oracle Database 19 Instant Client ;extension=odbc @@ -1318,7 +1318,7 @@ bcmath.scale = 0 [browscap] ; https://php.net/browscap -;browscap = extra/browscap.ini +browscap = /usr/local/etc/php/extra/browscap.ini [Session] ; Handler used to store/retrieve data. diff --git a/js/account.js b/js/account.js index defa891..4361e2f 100644 --- a/js/account.js +++ b/js/account.js @@ -52,7 +52,7 @@ $(document).ready(function () { $("#password").val(""); createdDiv.hide(); if (res.emailConfirmed === false) { - showAlert("danger", res.msg + ' Click here to resend the confirmation mail.', true); + showAlert("danger", res.msg + ' Click here to resend the confirmation mail.', true); } else { showAlert("danger", res.msg); } diff --git a/js/install.js b/js/install.js index f36f67b..2ee8347 100644 --- a/js/install.js +++ b/js/install.js @@ -19,7 +19,7 @@ function setState(state) { case SUCCESSFUL: icon = 'fas fa-check-circle'; - text = "Successfull"; + text = "Successful"; color = "success"; break; @@ -112,7 +112,7 @@ function waitForStatusChange() { $(document).ready(function() { $("#btnSubmit").click(function() { - params = { }; + let params = { }; let submitButton = $("#btnSubmit"); let textBefore = submitButton.text(); submitButton.prop("disabled", true); @@ -143,7 +143,7 @@ $(document).ready(function() { $("#btnPrev").prop("disabled", true); sendRequest({ "prev": true }, function(success) { if(!success) { - $("#btnPrev").prop("disabled",false); + $("#btnPrev").prop("disabled", false); } else { window.location.reload(); } diff --git a/react/shared/elements/data-table.css b/react/shared/elements/data-table.css index 45a1e7d..a63fb9e 100644 --- a/react/shared/elements/data-table.css +++ b/react/shared/elements/data-table.css @@ -23,6 +23,10 @@ text-align: center; } +.font-monospace { + font-family: monospace; +} + .data-table-clickable { cursor: pointer; } diff --git a/react/shared/elements/data-table.js b/react/shared/elements/data-table.js index 8c96cca..d8b4207 100644 --- a/react/shared/elements/data-table.js +++ b/react/shared/elements/data-table.js @@ -184,6 +184,60 @@ export class StringColumn extends DataColumn { } } +export class ArrayColumn extends DataColumn { + constructor(label, field = null, params = {}) { + super(label, field, params); + this.seperator = params.seperator || ", "; + } + + renderData(L, entry, index) { + let data = super.renderData(L, entry, index); + + if (!Array.isArray(data)) { + data = Object.values(data); + } + + data = data.join(this.seperator); + + if (this.params.style) { + let style = (typeof this.params.style === 'function' + ? this.params.style(entry) : this.params.style); + data = {data} + } + + return data; + } +} + +export class SecretsColumn extends DataColumn { + constructor(label, field = null, params = {}) { + super(label, field, params); + this.asteriskCount = params.asteriskCount || 8; + this.character = params.character || "*"; + this.canCopy = params.hasOwnProperty("canCopy") ? params.canCopy : true; + } + + renderData(L, entry, index) { + let originalData = super.renderData(L, entry, index); + if (!originalData) { + return "(None)"; + } + + let properties = this.params.properties || {}; + properties.className = clsx(properties.className, "font-monospace"); + + if (this.canCopy) { + properties.title = L("Click to copy"); + properties.className = clsx(properties.className, "data-table-clickable"); + properties.onClick = () => { + navigator.clipboard.writeText(originalData); + }; + } + + return {this.character.repeat(this.asteriskCount)} + } +} + export class NumericColumn extends DataColumn { constructor(label, field = null, params = {}) { super(label, field, params); diff --git a/react/shared/elements/dialog.jsx b/react/shared/elements/dialog.jsx index c2783a8..0998663 100644 --- a/react/shared/elements/dialog.jsx +++ b/react/shared/elements/dialog.jsx @@ -7,7 +7,7 @@ import { DialogContent, DialogContentText, DialogTitle, - Input, List, ListItem, Select, TextField + Input, List, ListItem, TextField } from "@mui/material"; export default function Dialog(props) { @@ -48,8 +48,10 @@ export default function Dialog(props) { switch (input.type) { case 'text': + case 'password': inputElements.push( { + if (modified) { + event.preventDefault(); + } + }, [modified]); + + useEffect(() => { + window.addEventListener("beforeunload", capture, {capture: true}); + return () => window.removeEventListener("beforeunload", capture, { capture: true }); + }, []); + +} \ No newline at end of file diff --git a/react/shared/hooks/editor-navigate.js b/react/shared/hooks/editor-navigate.js new file mode 100644 index 0000000..9789c5e --- /dev/null +++ b/react/shared/hooks/editor-navigate.js @@ -0,0 +1,21 @@ +import {useNavigate} from "react-router-dom"; + +export default function useEditorNavigate(L, showDialog) { + + const navigate = useNavigate(); + + return (uri, modified, options = null) => { + + if (!modified) { + navigate(uri, options ?? {}); + } else { + showDialog( + "You still have unsaved changes, are you really sure you want to leave this view?", + "Unsaved changes", + [L("general.cancel"), L("general.leave")], + (buttonIndex) => buttonIndex === 1 && navigate(uri, options ?? {}) + ) + } + }; + +} \ No newline at end of file diff --git a/react/shared/views/login.jsx b/react/shared/views/login.jsx index fe627ed..525d388 100644 --- a/react/shared/views/login.jsx +++ b/react/shared/views/login.jsx @@ -297,7 +297,7 @@ export default function LoginForm(props) { ? {error} {emailConfirmed === false - ? <> Click here to resend the confirmation email. + ? <> Click here to resend the confirmation email. : <> } diff --git a/robots.txt b/robots.txt index 7e85f31..5ebd9b8 100644 --- a/robots.txt +++ b/robots.txt @@ -1,3 +1,4 @@ User-agent: * Disallow: /Core Disallow: /Site +Disallow: /admin \ No newline at end of file