From 98fcd2822c440b0fa9c342926fb7086effd2d7e0 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 5 Apr 2024 13:01:15 +0200 Subject: [PATCH] Settings bugfix, frontend implementation, API CLI template integration --- Core/API/Parameter/StringType.class.php | 7 +- Core/API/Request.class.php | 2 + Core/API/SettingsAPI.class.php | 15 +- Core/Localization/de_DE/settings.php | 39 +- Core/Localization/en_US/settings.php | 37 +- README.md | 34 +- cli.php | 10 + react/admin-panel/src/AdminDashboard.jsx | 2 + .../admin-panel/src/views/group/group-edit.js | 3 +- .../admin-panel/src/views/route/route-edit.js | 6 +- react/admin-panel/src/views/settings.js | 367 +++++++++++ react/shared/time-zones.js | 603 ++++++++++++++++++ 12 files changed, 1098 insertions(+), 27 deletions(-) create mode 100644 react/admin-panel/src/views/settings.js create mode 100644 react/shared/time-zones.js diff --git a/Core/API/Parameter/StringType.class.php b/Core/API/Parameter/StringType.class.php index 2467416..c3878c1 100644 --- a/Core/API/Parameter/StringType.class.php +++ b/Core/API/Parameter/StringType.class.php @@ -17,8 +17,13 @@ class StringType extends Parameter { return false; } + // as long as it's numeric or bool, we can safely cast it to a string if (!is_string($value)) { - return false; + if (is_bool($value) || is_int($value) || is_float($value)) { + $this->value = strval($value); + } else { + return false; + } } if ($this->maxLength > 0 && strlen($value) > $this->maxLength) { diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index cf84e05..3a1aaed 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -129,6 +129,8 @@ abstract class Request { } protected abstract function _execute(): bool; + + // TODO: replace this function with two abstract methods: getDefaultPermittedGroups and getDescription public static function getDefaultACL(Insert $insert): void { } protected function check2FA(?TwoFactorToken $tfaToken = null): bool { diff --git a/Core/API/SettingsAPI.class.php b/Core/API/SettingsAPI.class.php index 40f3d26..6c55dc7 100644 --- a/Core/API/SettingsAPI.class.php +++ b/Core/API/SettingsAPI.class.php @@ -13,6 +13,7 @@ namespace Core\API { namespace Core\API\Settings { + use Core\API\Parameter\ArrayType; use Core\API\Parameter\Parameter; use Core\API\Parameter\StringType; use Core\API\SettingsAPI; @@ -20,8 +21,6 @@ namespace Core\API\Settings { use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondIn; - use Core\Driver\SQL\Condition\CondNot; - use Core\Driver\SQL\Condition\CondRegex; use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Objects\Context; @@ -57,7 +56,7 @@ namespace Core\API\Settings { class Set extends SettingsAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, array( - 'settings' => new Parameter('settings', Parameter::TYPE_ARRAY) + 'settings' => new ArrayType("settings", Parameter::TYPE_MIXED) )); } @@ -75,13 +74,13 @@ namespace Core\API\Settings { $keys = array(); $deleteKeys = array(); - foreach($values as $key => $value) { + foreach ($values as $key => $value) { if (!$paramKey->parseParam($key)) { $key = print_r($key, true); return $this->createError("Invalid Type for key in parameter settings: '$key' (Required: " . $paramKey->getTypeName() . ")"); - } else if(!is_null($value) && !$paramValue->parseParam($value)) { + } else if (!is_null($value) && !$paramValue->parseParam($value)) { $value = print_r($value, true); - return $this->createError("Invalid Type for value in parameter settings: '$value' (Required: " . $paramValue->getTypeName() . ")"); + return $this->createError("Invalid Type for value in parameter settings for key '$key': '$value' (Required: " . $paramValue->getTypeName() . ")"); } else if(preg_match("/^[a-zA-Z_][a-zA-Z_0-9-]*$/", $paramKey->value) !== 1) { return $this->createError("The property key should only contain alphanumeric characters, underscores and dashes"); } else { @@ -91,6 +90,8 @@ namespace Core\API\Settings { $deleteKeys[] = $paramKey->value; } $keys[] = $paramKey->value; + $paramKey->reset(); + $paramValue->reset(); } } @@ -103,7 +104,7 @@ namespace Core\API\Settings { } } - if (!empty($deleteKeys) && !$this->deleteKeys($keys)) { + if (!empty($deleteKeys) && !$this->deleteKeys($deleteKeys)) { return false; } diff --git a/Core/Localization/de_DE/settings.php b/Core/Localization/de_DE/settings.php index 4f6430b..baa337f 100644 --- a/Core/Localization/de_DE/settings.php +++ b/Core/Localization/de_DE/settings.php @@ -1,7 +1,7 @@ "Einstellungen", + "title" => "Einstellungen", "information" => "Informationen", # API Key @@ -21,5 +21,40 @@ return [ "Unter Android kannst du den Google Authenticator benutzen.", "register_2fa_fido_text" => "Möglicherweise musst du mit dem Gerät interagieren, zum Beispiel durch Eingeben einer PIN oder durch Berühren des Geräts", "remove_2fa" => "2FA-Token entfernen", - "remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen" + "remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen", + + # settings + "key" => "Schlüssel", + "value" => "Wert", + "general" => "Allgemein", + "mail" => "Mail", + "recaptcha" => "reCaptcha", + "uncategorized" => "Unkategorisiert", + "unchanged" => "Unverändert", + + # general settings + "site_name" => "Seitenname", + "base_url" => "Basis URL", + "user_registration_enabled" => "Benutzerregistrierung erlauben", + "allowed_extensions" => "Erlaubte Dateierweiterungen", + "time_zone" => "Zeitzone", + + # mail settings + "mail_enabled" => "E-Mail Versand aktiviert", + "mail_from" => "Absender E-Mailadresse", + "mail_host" => "Mail-Server Host", + "mail_port" => "Mail-Server Port", + "mail_username" => "Mail-Server Benutzername", + "mail_password" => "Mail-Server Passwort", + "mail_footer" => "Pfad zum E-Mail-Footer", + + # recaptcha + "recaptcha_enabled" => "Aktiviere Google reCaptcha", + "recaptcha_public_key" => "reCaptcha öffentlicher Schlüssel", + "recaptcha_private_key" => "reCaptcha privater Schlüssel", + + # dialog + "fetch_settings_error" => "Fehler beim Holen der Einstellungen", + "save_settings_success" => "Einstellungen erfolgreich gespeichert", + "save_settings_error" => "Fehler beim Speichern der Einstellungen", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/settings.php b/Core/Localization/en_US/settings.php index 28176f9..d1939a3 100644 --- a/Core/Localization/en_US/settings.php +++ b/Core/Localization/en_US/settings.php @@ -1,7 +1,7 @@ "Settings", + "title" => "Settings", "information" => "Information", # API Key @@ -22,4 +22,39 @@ return [ "register_2fa_fido_text" => "You may need to interact with your Device, e.g. typing in your PIN or touching to confirm the registration.", "remove_2fa" => "Remove 2FA Token", "remove_2fa_text" => "Enter your current password to confirm the removal of your 2FA Token", + + # settings + "key" => "Key", + "value" => "Value", + "general" => "General", + "mail" => "Mail", + "recaptcha" => "reCaptcha", + "uncategorized" => "Uncategorized", + "unchanged" => "Unchanged", + + # general settings + "site_name" => "Site Name", + "base_url" => "Base URL", + "user_registration_enabled" => "Allow user registration", + "allowed_extensions" => "Allowed file extensions", + "time_zone" => "Time zone", + + # mail settings + "mail_enabled" => "Enable e-mail transport", + "mail_from" => "Sender e-mail address", + "mail_host" => "Mail server host", + "mail_port" => "Mail server port", + "mail_username" => "Mail server username", + "mail_password" => "Mail server password", + "mail_footer" => "Path to e-mail footer", + + # recaptcha + "recaptcha_enabled" => "Enable Google reCaptcha", + "recaptcha_public_key" => "reCaptcha Public Key", + "recaptcha_private_key" => "reCaptcha Private Key", + + # dialog + "fetch_settings_error" => "Error fetching settings", + "save_settings_success" => "Settings saved successfully", + "save_settings_error" => "Error saving settings", ]; \ No newline at end of file diff --git a/README.md b/README.md index 2010561..b06640e 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,12 @@ To access and view any frontend pages, the internal router is used. Available ro A static route targets a file, usually located in [/static](/static) and does nothing more, than returning its content. A dynamic route is usually the way to go: It takes two parameters, firstly the target document and secondly, an optional view. For example, take the following routing table: -| Route | Action | Target | Extra | -|---------------------------| ------ | ------ |---------| -| `/funnyCatImage` | `Serve Static` | `/static/cat.jpg` | | -| `/someRoute/{param:str?}` | `Redirect Dynamic` | `\Documents\MyDocument\` | `param` | +| Route | Action | Target | Extra | +|---------------------------|------------|------------------------------|---------| +| `/funnyCatImage` | `Static` | `/static/cat.jpg` | | +| `/someRoute/{param:str?}` | `Dynamic` | `\Site\Documents\MyDocument` | `param` | +| `/redirectMe` | `Redirect` | `https://romanh.de/` | | + The first route would return the cat image, if the case-insensitive path `/funnyCatImage` is requested. The second route is more interesting, as it includes an optional parameter of type string, which means, any route starting with `/someRoute/` or just `/someRoute` is accepted. @@ -182,12 +184,12 @@ we could have one document, when showing different articles and products, and a To create a new document, a class inside [/Core/Documents](/Core/Documents) is created with the following scheme: ```php -namespace Documents { +namespace Site\Documents { - use Elements\Document; - use Objects\Router\Router; - use Documents\Example\ExampleHead; - use Documents\Example\ExampleBody; + use Core\Elements\Document; + use Core\Objects\Router\Router; + use Site\Documents\Example\ExampleHead; + use Site\Documents\Example\ExampleBody; class ExampleDocument extends Document { public function __construct(Router $router, ?string $view = NULL) { @@ -196,10 +198,10 @@ namespace Documents { } } -namespace Documents\Example { +namespace Site\Documents\Example { - use Elements\Head; - use Elements\Body; + use Core\Elements\Head; + use Core\Elements\Body; class ExampleHead extends Head { @@ -255,7 +257,7 @@ we firstly have to load the module. This is done by adding the class, or the obj To translate the defined strings, we can use the global `L()` function. The following code snipped shows the use of our sample language module: -**/Core/Localization/de_DE/example.php**: +**/Site/Localization/de_DE/example.php**: ```php php cli.php frontend dev ``` +### API commands +```bash +php cli.php api ls +php cli.php api # interactive wizard +``` + ## Project Structure ``` ├── Core diff --git a/cli.php b/cli.php index fb8f27a..1580c2c 100755 --- a/cli.php +++ b/cli.php @@ -859,6 +859,10 @@ function onAPI(array $argv): void { // TODO: auto-generated method stub return \$this->success; } + + public static function getDefaultACL(Insert \$insert): void { + \$insert->addRow(self::getEndpoint(), [], \"Short description, what users are allowed to do with this permission\", false); + } }"; }, $methodNames)); $content = "success; } + + public static function getDefaultACL(Insert \$insert): void { + \$insert->addRow(self::getEndpoint(), [], \"Short description, what users are allowed to do with this permission\", false); + } } "; } diff --git a/react/admin-panel/src/AdminDashboard.jsx b/react/admin-panel/src/AdminDashboard.jsx index 4fab9b3..af40a31 100644 --- a/react/admin-panel/src/AdminDashboard.jsx +++ b/react/admin-panel/src/AdminDashboard.jsx @@ -22,6 +22,7 @@ const LogView = lazy(() => import("./views/log-view")); const AccessControlList = lazy(() => import("./views/access-control-list")); const RouteListView = lazy(() => import("./views/route/route-list")); const RouteEditView = lazy(() => import("./views/route/route-edit")); +const SettingsView = lazy(() => import("./views/settings")); export default function AdminDashboard(props) { @@ -83,6 +84,7 @@ export default function AdminDashboard(props) { }/> }/> }/> + }/> } /> diff --git a/react/admin-panel/src/views/group/group-edit.js b/react/admin-panel/src/views/group/group-edit.js index aae2eb1..e7e333a 100644 --- a/react/admin-panel/src/views/group/group-edit.js +++ b/react/admin-panel/src/views/group/group-edit.js @@ -219,7 +219,8 @@ export default function EditGroupView(props) { onClick={() => navigate("/admin/groups")}> {L("general.go_back")} - - diff --git a/react/admin-panel/src/views/settings.js b/react/admin-panel/src/views/settings.js new file mode 100644 index 0000000..f635654 --- /dev/null +++ b/react/admin-panel/src/views/settings.js @@ -0,0 +1,367 @@ +import {useCallback, useContext, useEffect, useState} from "react"; +import {LocaleContext} from "shared/locale"; +import { + Box, Button, Checkbox, + CircularProgress, FormControl, FormControlLabel, + FormGroup, FormLabel, IconButton, + Paper, Select, styled, + Tab, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tabs, TextField +} from "@mui/material"; +import {Link} from "react-router-dom"; +import {Add, Delete, Google, LibraryBooks, Mail, RestartAlt, Save, SettingsApplications} from "@mui/icons-material"; +import {TableContainer} from "@material-ui/core"; +import TIME_ZONES from "shared/time-zones"; + +const SettingsFormGroup = styled(FormGroup)((props) => ({ + marginBottom: props.theme.spacing(1), +})); + +const ButtonBar = styled(Box)((props) => ({ + "& > button": { + marginRight: props.theme.spacing(1) + } +})); + +export default function SettingsView(props) { + + // meta + const api = props.api; + const showDialog = props.showDialog; + const {translate: L, requestModules, currentLocale} = useContext(LocaleContext); + const KNOWN_SETTING_KEYS = { + "general": [ + "base_url", + "site_name", + "user_registration_enabled", + "time_zone", + "allowed_extensions", + ], + "mail": [ + "mail_enabled", + "mail_footer", + "mail_from", + "mail_host", + "mail_port", + "mail_username", + "mail_password", + ], + "recaptcha": [ + "recaptcha_enabled", + "recaptcha_private_key", + "recaptcha_public_key", + ], + "hidden": ["installation_completed", "mail_last_sync"] + }; + + // data + const [fetchSettings, setFetchSettings] = useState(true); + const [settings, setSettings] = useState(null); + const [uncategorizedKeys, setUncategorizedKeys] = useState([]); + + // ui + const [selectedTab, setSelectedTab] = useState("general"); + const [hasChanged, setChanged] = useState(false); + const [isSaving, setSaving] = useState(false); + const [newKey, setNewKey] = useState(""); + + const isUncategorized = (key) => { + return !(Object.values(KNOWN_SETTING_KEYS).reduce((acc, arr) => { + return [ ...acc, ...arr ]; + }, [])).includes(key); + } + + useEffect(() => { + requestModules(props.api, ["general", "settings"], currentLocale).then(data => { + if (!data.success) { + showDialog("Error fetching translations: " + data.msg); + } + }); + }, [currentLocale]); + + const onFetchSettings = useCallback((force = false) => { + if (fetchSettings || force) { + setFetchSettings(false); + api.getSettings().then(data => { + if (!data.success) { + showDialog(data.msg, L("settings.fetch_settings_error")); + } else { + setSettings(Object.keys(data.settings) + .filter(key => !KNOWN_SETTING_KEYS.hidden.includes(key)) + .reduce((obj, key) => { + obj[key] = data.settings[key]; + return obj; + }, {}) + ); + setUncategorizedKeys(Object.keys(data.settings).filter(key => isUncategorized(key))); + } + }); + } + }, [api, showDialog, fetchSettings]); + + useEffect(() => { + onFetchSettings(); + }, [fetchSettings]); + + const onChangeValue = useCallback((key, value) => { + setChanged(true); + setSettings({...settings, [key]: value}); + }, [settings]); + + const onSaveSettings = useCallback(() => { + setSaving(true); + api.saveSettings(settings).then(data => { + setSaving(false); + if (data.success) { + showDialog(L("settings.save_settings_success"), L("general.success")); + setChanged(false); + } else { + showDialog(data.msg, L("settings.save_settings_error")); + } + }); + }, [api, showDialog, settings]); + + const onDeleteKey = useCallback(key => { + if (key && settings.hasOwnProperty(key)) { + let index = uncategorizedKeys.indexOf(key); + if (index !== -1) { + let newUncategorizedKeys = [...uncategorizedKeys]; + newUncategorizedKeys.splice(index, 1); + setUncategorizedKeys(newUncategorizedKeys); + } + setChanged(true); + setSettings({...settings, [key]: null}); + } + }, [settings, uncategorizedKeys]); + + const onAddKey = useCallback(key => { + if (key) { + if (!isUncategorized(key) || !settings.hasOwnProperty(key) || settings[key] === null) { + setChanged(true); + setSettings({...settings, [key]: ""}); + setUncategorizedKeys([...uncategorizedKeys, key]); + setNewKey(""); + } else { + showDialog("This key is already defined", L("general.error")); + } + } + }, [settings, uncategorizedKeys, showDialog]); + + const onChangeKey = useCallback((oldKey, newKey) => { + if (settings.hasOwnProperty(oldKey) && !settings.hasOwnProperty(newKey)) { + let newSettings = {...settings, [newKey]: settings[oldKey]}; + delete newSettings[oldKey]; + setChanged(true); + setSettings(newSettings); + } + }, [settings]); + + if (settings === null) { + return + } + + const parseBool = (v) => v === true || v === 1 || ["true", "1", "yes"].includes(v.toString().toLowerCase()); + + const renderTextInput = (key_name, disabled=false, props={}) => { + return + {L("settings." + key_name)} + + onChangeValue(key_name, e.target.value)} /> + + + } + + const renderPasswordInput = (key_name, disabled=false, props={}) => { + return + {L("settings." + key_name)} + + onChangeValue(key_name, e.target.value)} /> + + + } + + const renderNumberInput = (key_name, minValue, maxValue, disabled=false, props={}) => { + return + {L("settings." + key_name)} + + onChangeValue(key_name, e.target.value)} /> + + + } + + const renderCheckBox = (key_name, disabled=false, props={}) => { + return + onChangeValue(key_name, v)} />} + label={L("settings." + key_name)} /> + + } + + const renderSelection = (key_name, options, disabled=false, props={}) => { + return + {L("settings." + key_name)} + + + + + } + + const renderTab = () => { + if (selectedTab === "general") { + return [ + renderTextInput("site_name"), + renderTextInput("base_url"), + renderCheckBox("user_registration_enabled"), + renderTextInput("allowed_extensions"), + renderSelection("time_zone", TIME_ZONES), + ]; + } else if (selectedTab === "mail") { + return [ + renderCheckBox("mail_enabled"), + renderTextInput("mail_from", !parseBool(settings.mail_enabled)), + renderTextInput("mail_host", !parseBool(settings.mail_enabled)), + renderNumberInput("mail_port", 1, 65535, !parseBool(settings.mail_enabled)), + renderTextInput("mail_username", !parseBool(settings.mail_enabled)), + renderPasswordInput("mail_password", !parseBool(settings.mail_enabled)), + renderTextInput("mail_footer", !parseBool(settings.mail_enabled)), + ]; + } else if (selectedTab === "recaptcha") { + return [ + renderCheckBox("recaptcha_enabled"), + renderTextInput("recaptcha_public_key", !parseBool(settings.recaptcha_enabled)), + renderPasswordInput("recaptcha_private_key", !parseBool(settings.recaptcha_enabled)), + ]; + } else if (selectedTab === "uncategorized") { + return + + + + {L("settings.key")} + {L("settings.value")} + {L("general.controls")} + + + + {uncategorizedKeys.map(key => + + onChangeKey(key, e.target.value)} /> + + + onChangeValue(key, e.target.value)} /> + + + onDeleteKey(key)} + color={"secondary"}> + + + + )} + + + setNewKey(e.target.value)} + onBlur={() => onAddKey(newKey)} value={newKey}/> + + + + + + + + +
+ + + +
+ } else { + return Invalid tab: {selectedTab} + } + } + + return <> +
+
+
+
+

{L("settings.title")}

+
+
+
    +
  1. Home
  2. +
  3. {L("settings.title")}
  4. +
+
+
+
+
+
+ setSelectedTab(v)} component={Paper}> + } iconPosition={"start"} /> + } iconPosition={"start"} /> + } iconPosition={"start"} /> + } iconPosition={"start"} /> + + + { + renderTab() + } + + + + + +
+ + +} \ No newline at end of file diff --git a/react/shared/time-zones.js b/react/shared/time-zones.js new file mode 100644 index 0000000..809c946 --- /dev/null +++ b/react/shared/time-zones.js @@ -0,0 +1,603 @@ +// Source: https://pecl.php.net/get/timezonedb +// Date: 2024-04-05 12:00:00 +const TIME_ZONES = [ + "Africa/Abidjan", + "Africa/Accra", + "Africa/Addis_Ababa", + "Africa/Algiers", + "Africa/Asmara", + "Africa/Asmera", + "Africa/Bamako", + "Africa/Bangui", + "Africa/Banjul", + "Africa/Bissau", + "Africa/Blantyre", + "Africa/Brazzaville", + "Africa/Bujumbura", + "Africa/Cairo", + "Africa/Casablanca", + "Africa/Ceuta", + "Africa/Conakry", + "Africa/Dakar", + "Africa/Dar_es_Salaam", + "Africa/Djibouti", + "Africa/Douala", + "Africa/El_Aaiun", + "Africa/Freetown", + "Africa/Gaborone", + "Africa/Harare", + "Africa/Johannesburg", + "Africa/Juba", + "Africa/Kampala", + "Africa/Khartoum", + "Africa/Kigali", + "Africa/Kinshasa", + "Africa/Lagos", + "Africa/Libreville", + "Africa/Lome", + "Africa/Luanda", + "Africa/Lubumbashi", + "Africa/Lusaka", + "Africa/Malabo", + "Africa/Maputo", + "Africa/Maseru", + "Africa/Mbabane", + "Africa/Mogadishu", + "Africa/Monrovia", + "Africa/Nairobi", + "Africa/Ndjamena", + "Africa/Niamey", + "Africa/Nouakchott", + "Africa/Ouagadougou", + "Africa/Porto-Novo", + "Africa/Sao_Tome", + "Africa/Timbuktu", + "Africa/Tripoli", + "Africa/Tunis", + "Africa/Windhoek", + "America/Adak", + "America/Anchorage", + "America/Anguilla", + "America/Antigua", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Argentina/Catamarca", + "America/Argentina/ComodRivadavia", + "America/Argentina/Cordoba", + "America/Argentina/Jujuy", + "America/Argentina/La_Rioja", + "America/Argentina/Mendoza", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta", + "America/Argentina/San_Juan", + "America/Argentina/San_Luis", + "America/Argentina/Tucuman", + "America/Argentina/Ushuaia", + "America/Aruba", + "America/Asuncion", + "America/Atikokan", + "America/Atka", + "America/Bahia", + "America/Bahia_Banderas", + "America/Barbados", + "America/Belem", + "America/Belize", + "America/Blanc-Sablon", + "America/Boa_Vista", + "America/Bogota", + "America/Boise", + "America/Buenos_Aires", + "America/Cambridge_Bay", + "America/Campo_Grande", + "America/Cancun", + "America/Caracas", + "America/Catamarca", + "America/Cayenne", + "America/Cayman", + "America/Chicago", + "America/Chihuahua", + "America/Ciudad_Juarez", + "America/Coral_Harbour", + "America/Cordoba", + "America/Costa_Rica", + "America/Creston", + "America/Cuiaba", + "America/Curacao", + "America/Danmarkshavn", + "America/Dawson", + "America/Dawson_Creek", + "America/Denver", + "America/Detroit", + "America/Dominica", + "America/Edmonton", + "America/Eirunepe", + "America/El_Salvador", + "America/Ensenada", + "America/Fort_Nelson", + "America/Fort_Wayne", + "America/Fortaleza", + "America/Glace_Bay", + "America/Godthab", + "America/Goose_Bay", + "America/Grand_Turk", + "America/Grenada", + "America/Guadeloupe", + "America/Guatemala", + "America/Guayaquil", + "America/Guyana", + "America/Halifax", + "America/Havana", + "America/Hermosillo", + "America/Indiana/Indianapolis", + "America/Indiana/Knox", + "America/Indiana/Marengo", + "America/Indiana/Petersburg", + "America/Indiana/Tell_City", + "America/Indiana/Vevay", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Indianapolis", + "America/Inuvik", + "America/Iqaluit", + "America/Jamaica", + "America/Jujuy", + "America/Juneau", + "America/Kentucky/Louisville", + "America/Kentucky/Monticello", + "America/Knox_IN", + "America/Kralendijk", + "America/La_Paz", + "America/Lima", + "America/Los_Angeles", + "America/Louisville", + "America/Lower_Princes", + "America/Maceio", + "America/Managua", + "America/Manaus", + "America/Marigot", + "America/Martinique", + "America/Matamoros", + "America/Mazatlan", + "America/Mendoza", + "America/Menominee", + "America/Merida", + "America/Metlakatla", + "America/Mexico_City", + "America/Miquelon", + "America/Moncton", + "America/Monterrey", + "America/Montevideo", + "America/Montreal", + "America/Montserrat", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Nome", + "America/Noronha", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/Nuuk", + "America/Ojinaga", + "America/Panama", + "America/Pangnirtung", + "America/Paramaribo", + "America/Phoenix", + "America/Port-au-Prince", + "America/Port_of_Spain", + "America/Porto_Acre", + "America/Porto_Velho", + "America/Puerto_Rico", + "America/Punta_Arenas", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Recife", + "America/Regina", + "America/Resolute", + "America/Rio_Branco", + "America/Rosario", + "America/Santa_Isabel", + "America/Santarem", + "America/Santiago", + "America/Santo_Domingo", + "America/Sao_Paulo", + "America/Scoresbysund", + "America/Shiprock", + "America/Sitka", + "America/St_Barthelemy", + "America/St_Johns", + "America/St_Kitts", + "America/St_Lucia", + "America/St_Thomas", + "America/St_Vincent", + "America/Swift_Current", + "America/Tegucigalpa", + "America/Thule", + "America/Thunder_Bay", + "America/Tijuana", + "America/Toronto", + "America/Tortola", + "America/Vancouver", + "America/Virgin", + "America/Whitehorse", + "America/Winnipeg", + "America/Yakutat", + "America/Yellowknife", + "Antarctica/Casey", + "Antarctica/Davis", + "Antarctica/DumontDUrville", + "Antarctica/Macquarie", + "Antarctica/Mawson", + "Antarctica/McMurdo", + "Antarctica/Palmer", + "Antarctica/Rothera", + "Antarctica/South_Pole", + "Antarctica/Syowa", + "Antarctica/Troll", + "Antarctica/Vostok", + "Arctic/Longyearbyen", + "Asia/Aden", + "Asia/Almaty", + "Asia/Amman", + "Asia/Anadyr", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Ashgabat", + "Asia/Ashkhabad", + "Asia/Atyrau", + "Asia/Baghdad", + "Asia/Bahrain", + "Asia/Baku", + "Asia/Bangkok", + "Asia/Barnaul", + "Asia/Beirut", + "Asia/Bishkek", + "Asia/Brunei", + "Asia/Calcutta", + "Asia/Chita", + "Asia/Choibalsan", + "Asia/Chongqing", + "Asia/Chungking", + "Asia/Colombo", + "Asia/Dacca", + "Asia/Damascus", + "Asia/Dhaka", + "Asia/Dili", + "Asia/Dubai", + "Asia/Dushanbe", + "Asia/Famagusta", + "Asia/Gaza", + "Asia/Harbin", + "Asia/Hebron", + "Asia/Ho_Chi_Minh", + "Asia/Hong_Kong", + "Asia/Hovd", + "Asia/Irkutsk", + "Asia/Istanbul", + "Asia/Jakarta", + "Asia/Jayapura", + "Asia/Jerusalem", + "Asia/Kabul", + "Asia/Kamchatka", + "Asia/Karachi", + "Asia/Kashgar", + "Asia/Kathmandu", + "Asia/Katmandu", + "Asia/Khandyga", + "Asia/Kolkata", + "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Kuwait", + "Asia/Macao", + "Asia/Macau", + "Asia/Magadan", + "Asia/Makassar", + "Asia/Manila", + "Asia/Muscat", + "Asia/Nicosia", + "Asia/Novokuznetsk", + "Asia/Novosibirsk", + "Asia/Omsk", + "Asia/Oral", + "Asia/Phnom_Penh", + "Asia/Pontianak", + "Asia/Pyongyang", + "Asia/Qatar", + "Asia/Qostanay", + "Asia/Qyzylorda", + "Asia/Rangoon", + "Asia/Riyadh", + "Asia/Saigon", + "Asia/Sakhalin", + "Asia/Samarkand", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Srednekolymsk", + "Asia/Taipei", + "Asia/Tashkent", + "Asia/Tbilisi", + "Asia/Tehran", + "Asia/Tel_Aviv", + "Asia/Thimbu", + "Asia/Thimphu", + "Asia/Tokyo", + "Asia/Tomsk", + "Asia/Ujung_Pandang", + "Asia/Ulaanbaatar", + "Asia/Ulan_Bator", + "Asia/Urumqi", + "Asia/Ust-Nera", + "Asia/Vientiane", + "Asia/Vladivostok", + "Asia/Yakutsk", + "Asia/Yangon", + "Asia/Yekaterinburg", + "Asia/Yerevan", + "Atlantic/Azores", + "Atlantic/Bermuda", + "Atlantic/Canary", + "Atlantic/Cape_Verde", + "Atlantic/Faeroe", + "Atlantic/Faroe", + "Atlantic/Jan_Mayen", + "Atlantic/Madeira", + "Atlantic/Reykjavik", + "Atlantic/South_Georgia", + "Atlantic/St_Helena", + "Atlantic/Stanley", + "Australia/ACT", + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Broken_Hill", + "Australia/Canberra", + "Australia/Currie", + "Australia/Darwin", + "Australia/Eucla", + "Australia/Hobart", + "Australia/LHI", + "Australia/Lindeman", + "Australia/Lord_Howe", + "Australia/Melbourne", + "Australia/North", + "Australia/NSW", + "Australia/Perth", + "Australia/Queensland", + "Australia/South", + "Australia/Sydney", + "Australia/Tasmania", + "Australia/Victoria", + "Australia/West", + "Australia/Yancowinna", + "Brazil/Acre", + "Brazil/DeNoronha", + "Brazil/East", + "Brazil/West", + "Canada/Atlantic", + "Canada/Central", + "Canada/Eastern", + "Canada/Mountain", + "Canada/Newfoundland", + "Canada/Pacific", + "Canada/Saskatchewan", + "Canada/Yukon", + "CET", + "Chile/Continental", + "Chile/EasterIsland", + "CST6CDT", + "Cuba", + "EET", + "Egypt", + "Eire", + "EST", + "EST5EDT", + "Etc/GMT", + "Etc/GMT+0", + "Etc/GMT+1", + "Etc/GMT+10", + "Etc/GMT+11", + "Etc/GMT+12", + "Etc/GMT+2", + "Etc/GMT+3", + "Etc/GMT+4", + "Etc/GMT+5", + "Etc/GMT+6", + "Etc/GMT+7", + "Etc/GMT+8", + "Etc/GMT+9", + "Etc/GMT-0", + "Etc/GMT-1", + "Etc/GMT-10", + "Etc/GMT-11", + "Etc/GMT-12", + "Etc/GMT-13", + "Etc/GMT-14", + "Etc/GMT-2", + "Etc/GMT-3", + "Etc/GMT-4", + "Etc/GMT-5", + "Etc/GMT-6", + "Etc/GMT-7", + "Etc/GMT-8", + "Etc/GMT-9", + "Etc/GMT0", + "Etc/Greenwich", + "Etc/UCT", + "Etc/Universal", + "Etc/UTC", + "Etc/Zulu", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Astrakhan", + "Europe/Athens", + "Europe/Belfast", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Bratislava", + "Europe/Brussels", + "Europe/Bucharest", + "Europe/Budapest", + "Europe/Busingen", + "Europe/Chisinau", + "Europe/Copenhagen", + "Europe/Dublin", + "Europe/Gibraltar", + "Europe/Guernsey", + "Europe/Helsinki", + "Europe/Isle_of_Man", + "Europe/Istanbul", + "Europe/Jersey", + "Europe/Kaliningrad", + "Europe/Kiev", + "Europe/Kirov", + "Europe/Kyiv", + "Europe/Lisbon", + "Europe/Ljubljana", + "Europe/London", + "Europe/Luxembourg", + "Europe/Madrid", + "Europe/Malta", + "Europe/Mariehamn", + "Europe/Minsk", + "Europe/Monaco", + "Europe/Moscow", + "Europe/Nicosia", + "Europe/Oslo", + "Europe/Paris", + "Europe/Podgorica", + "Europe/Prague", + "Europe/Riga", + "Europe/Rome", + "Europe/Samara", + "Europe/San_Marino", + "Europe/Sarajevo", + "Europe/Saratov", + "Europe/Simferopol", + "Europe/Skopje", + "Europe/Sofia", + "Europe/Stockholm", + "Europe/Tallinn", + "Europe/Tirane", + "Europe/Tiraspol", + "Europe/Ulyanovsk", + "Europe/Uzhgorod", + "Europe/Vaduz", + "Europe/Vatican", + "Europe/Vienna", + "Europe/Vilnius", + "Europe/Volgograd", + "Europe/Warsaw", + "Europe/Zagreb", + "Europe/Zaporozhye", + "Europe/Zurich", + "Factory", + "GB", + "GB-Eire", + "GMT", + "GMT+0", + "GMT-0", + "GMT0", + "Greenwich", + "Hongkong", + "HST", + "Iceland", + "Indian/Antananarivo", + "Indian/Chagos", + "Indian/Christmas", + "Indian/Cocos", + "Indian/Comoro", + "Indian/Kerguelen", + "Indian/Mahe", + "Indian/Maldives", + "Indian/Mauritius", + "Indian/Mayotte", + "Indian/Reunion", + "Iran", + "Israel", + "Jamaica", + "Japan", + "Kwajalein", + "Libya", + "MET", + "Mexico/BajaNorte", + "Mexico/BajaSur", + "Mexico/General", + "MST", + "MST7MDT", + "Navajo", + "NZ", + "NZ-CHAT", + "Pacific/Apia", + "Pacific/Auckland", + "Pacific/Bougainville", + "Pacific/Chatham", + "Pacific/Chuuk", + "Pacific/Easter", + "Pacific/Efate", + "Pacific/Enderbury", + "Pacific/Fakaofo", + "Pacific/Fiji", + "Pacific/Funafuti", + "Pacific/Galapagos", + "Pacific/Gambier", + "Pacific/Guadalcanal", + "Pacific/Guam", + "Pacific/Honolulu", + "Pacific/Johnston", + "Pacific/Kanton", + "Pacific/Kiritimati", + "Pacific/Kosrae", + "Pacific/Kwajalein", + "Pacific/Majuro", + "Pacific/Marquesas", + "Pacific/Midway", + "Pacific/Nauru", + "Pacific/Niue", + "Pacific/Norfolk", + "Pacific/Noumea", + "Pacific/Pago_Pago", + "Pacific/Palau", + "Pacific/Pitcairn", + "Pacific/Pohnpei", + "Pacific/Ponape", + "Pacific/Port_Moresby", + "Pacific/Rarotonga", + "Pacific/Saipan", + "Pacific/Samoa", + "Pacific/Tahiti", + "Pacific/Tarawa", + "Pacific/Tongatapu", + "Pacific/Truk", + "Pacific/Wake", + "Pacific/Wallis", + "Pacific/Yap", + "Poland", + "Portugal", + "PRC", + "PST8PDT", + "ROC", + "ROK", + "Singapore", + "Turkey", + "UCT", + "Universal", + "US/Alaska", + "US/Aleutian", + "US/Arizona", + "US/Central", + "US/East-Indiana", + "US/Eastern", + "US/Hawaii", + "US/Indiana-Starke", + "US/Michigan", + "US/Mountain", + "US/Pacific", + "US/Samoa", + "UTC", + "W-SU", + "WET", + "Zulu", +]; + +export default TIME_ZONES; \ No newline at end of file