diff --git a/modules/admin/lib/FederationController.php b/modules/admin/lib/FederationController.php new file mode 100644 index 0000000000000000000000000000000000000000..a6608ac62789fe11ce39c0cb03a108db8d152964 --- /dev/null +++ b/modules/admin/lib/FederationController.php @@ -0,0 +1,374 @@ +<?php + +namespace SimpleSAML\Module\admin; + +use SimpleSAML\Locale\Translate; +use SimpleSAML\Metadata\MetaDataStorageHandler; +use SimpleSAML\Metadata\SAMLBuilder; +use SimpleSAML\Module; +use SimpleSAML\Module\adfs\IdP\ADFS as ADFS_IdP; +use SimpleSAML\Module\saml\IdP\SAML1 as SAML1_IdP; +use SimpleSAML\Module\saml\IdP\SAML2 as SAML2_IdP; +use SimpleSAML\Utils\Auth; + +/** + * Controller class for the admin module. + * + * This class serves the federation views available in the module. + * + * @package SimpleSAML\Module\admin + */ +class FederationController +{ + + /** @var \SimpleSAML\Configuration */ + protected $config; + + /** @var MetaDataStorageHandler */ + protected $mdHandler; + + /** @var Menu */ + protected $menu; + + + /** + * FederationController constructor. + * + * @param \SimpleSAML\Configuration $config The configuration to use. + */ + public function __construct(\SimpleSAML\Configuration $config) + { + $this->config = $config; + $this->menu = new Menu(); + $this->mdHandler = MetaDataStorageHandler::getMetadataHandler(); + } + + + /** + * Display the federation page. + * + * @return \SimpleSAML\XHTML\Template + * @throws \SimpleSAML\Error\Exception + * @throws \SimpleSAML_Error_Exception + */ + public function main() + { + Auth::requireAdmin(); + + // initialize basic metadata array + $hostedSPs = $this->getHostedSP(); + $hostedIdPs = $this->getHostedIdP(); + $entries = [ + 'hosted' => array_merge($hostedSPs, $hostedIdPs), + 'remote' => [ + 'saml20-idp-remote' => !empty($hostedSPs) ? $this->mdHandler->getList('saml20-idp-remote') : [], + 'shib13-idp-remote' => !empty($hostedSPs) ? $this->mdHandler->getList('shib13-idp-remote') : [], + 'saml20-sp-remote' => $this->config->getBoolean('enable.saml20-idp', false) === true + ? $this->mdHandler->getList('saml20-sp-remote') : [], + 'shib13-sp-remote' => $this->config->getBoolean('enable.shib13-idp', false) === true + ? $this->mdHandler->getList('shib13-sp-remote') : [], + 'adfs-sp-remote' => ($this->config->getBoolean('enable.adfs-idp', false) === true) && + Module::isModuleEnabled('adfs') ? $this->mdHandler->getList('adfs-sp-remote') : [], + ], + ]; + + // initialize template and language + $t = new \SimpleSAML\XHTML\Template($this->config, 'admin:federation.twig'); + $language = $t->getTranslator()->getLanguage()->getLanguage(); + $defaultLang = $this->config->getString('language.default', 'en'); + + // process hosted entities + foreach ($entries['hosted'] as $index => $entity) { + if (isset($entity['name']) && is_string($entity['name'])) { + // if the entity has no internationalized name, fake it + $entries['hosted'][$index]['name'] = [$language => $entity['name']]; + } + } + + // clean up empty remote entries + foreach ($entries['remote'] as $key => $value) { + if (empty($value)) { + unset($entries['remote'][$key]); + } + } + + $translators = [ + 'name' => 'name_translated', + 'descr' => 'descr_translated', + 'OrganizationDisplayName' => 'organizationdisplayname_translated', + ]; + + foreach ($entries['remote'] as $key => $set) { + foreach ($set as $entityid => $entity) { + foreach ($translators as $old => $new) { + if (isset($entity[$old][$language])) { + $entries['remote'][$key][$entityid][$new] = $entity[$old][$language]; + } elseif (isset($entity[$old][$defaultLang])) { + $entries['remote'][$key][$entityid][$new] = $entity[$old][$defaultLang]; + } elseif (isset($entity[$old]['en'])) { + $entries['remote'][$key][$entityid][$new] = $entity[$old]['en']; + } elseif (isset($entries['remote'][$key][$entityid][$old])) { + $entries['remote'][$key][$entityid][$new] = $entries['remote'][$key][$entityid][$old]; + } + } + } + } + + $t->data = [ + 'links' => [ + [ + 'href' => Module::getModuleURL('admin/metadata-converter'), + 'text' => Translate::noop('XML to SimpleSAMLphp metadata converter'), + ] + ], + 'entries' => $entries, + 'mdtype' => [ + 'saml20-sp-remote' => Translate::noop('SAML 2.0 SP metadata'), + 'saml20-sp-hosted' => Translate::noop('SAML 2.0 SP metadata'), + 'saml20-idp-remote' => Translate::noop('SAML 2.0 IdP metadata'), + 'saml20-idp-hosted' => Translate::noop('SAML 2.0 IdP metadata'), + 'shib13-sp-remote' => Translate::noop('SAML 1.1 SP metadata'), + 'shib13-sp-hosted' => Translate::noop('SAML 1.1 SP metadata'), + 'shib13-idp-remote' => Translate::noop('SAML 1.1 IdP metadata'), + 'shib13-idp-hosted' => Translate::noop('SAML 1.1 IdP metadata'), + 'adfs-sp-remote' => Translate::noop('ADFS SP metadata'), + 'adfs-sp-hosted' => Translate::noop('ADFS SP metadata'), + 'adfs-idp-remote' => Translate::noop('ADFS IdP metadata'), + 'adfs-idp-hosted' => Translate::noop('ADFS IdP metadata'), + ], + 'logouturl' => Auth::getAdminLogoutURL(), + ]; + + Module::callHooks('federationpage', $t); + $this->menu->addOption('logout', $t->data['logouturl'], Translate::noop('Log out')); + return $this->menu->insert($t); + } + + + /** + * Get a list of the hosted IdP entities, including SAML 2, SAML 1.1 and ADFS. + * + * @return array + * @throws \Exception + */ + private function getHostedIdP() + { + $entities = []; + + // SAML 2 + if ($this->config->getBoolean('enable.saml20-idp', false)) { + try { + $idps = $this->mdHandler->getList('saml20-idp-hosted'); + $saml2entities = []; + if (count($idps) > 1) { + foreach ($idps as $index => $idp) { + $idp['url'] = Module::getModuleURL('saml/2/idp/metadata/'.$idp['auth']); + $idp['metadata-set'] = 'saml20-idp-hosted'; + $idp['metadata-index'] = $index; + $idp['metadata_array'] = SAML2_IdP::getHostedMetadata($idp['entityid']); + $saml2entities[] = $idp; + } + } else { + $saml2entities['saml20-idp'] = $this->mdHandler->getMetaDataCurrent('saml20-idp-hosted'); + $saml2entities['saml20-idp']['url'] = \SimpleSAML\Utils\HTTP::getBaseURL().'saml2/idp/metadata.php'; + $saml2entities['saml20-idp']['metadata_array'] = + SAML2_IdP::getHostedMetadata( + $this->mdHandler->getMetaDataCurrentEntityID('saml20-idp-hosted') + ); + } + + foreach ($saml2entities as $index => $entity) { + $builder = new SAMLBuilder($entity['entityid']); + $builder->addMetadataIdP20($entity['metadata_array']); + $builder->addOrganizationInfo($entity['metadata_array']); + foreach ($entity['metadata_array']['contacts'] as $contact) { + $builder->addContact($contact['contactType'], $contact); + } + + $entity['metadata'] = \SimpleSAML\Metadata\Signer::sign( + $builder->getEntityDescriptorText(), + $entity['metadata_array'], + 'SAML 2 IdP' + ); + $entities[$index] = $entity; + } + } catch (\Exception $e) { + \SimpleSAML\Logger::error('Federation: Error loading saml20-idp: '.$e->getMessage()); + } + } + + // SAML 1.1 / Shib13 + if ($this->config->getBoolean('enable.shib13-idp', false)) { + try { + $idps = $this->mdHandler->getList('shib13-idp-hosted'); + $shib13entities = []; + if (count($idps) > 1) { + foreach ($idps as $index => $idp) { + $idp['url'] = Module::getModuleURL('saml/1.1/idp/metadata/'.$idp['auth']); + $idp['metadata-set'] = 'shib13-idp-hosted'; + $idp['metadata-index'] = $index; + $idp['metadata_array'] = SAML1_IdP::getHostedMetadata($idp['entityid']); + $shib13entities[] = $idp; + } + } else { + $shib13entities['shib13-idp'] = $this->mdHandler->getMetaDataCurrent('shib13-idp-hosted'); + $shib13entities['shib13-idp']['url'] = \SimpleSAML\Utils\HTTP::getBaseURL(). + 'shib13/idp/metadata.php'; + $shib13entities['shib13-idp']['metadata_array'] = + SAML1_IdP::getHostedMetadata( + $this->mdHandler->getMetaDataCurrentEntityID('shib13-idp-hosted') + ); + } + + foreach ($shib13entities as $index => $entity) { + $builder = new SAMLBuilder($entity['entityid']); + $builder->addMetadataIdP11($entity['metadata_array']); + $builder->addOrganizationInfo($entity['metadata_array']); + foreach ($entity['metadata_array']['contacts'] as $contact) { + $builder->addContact($contact['contactType'], $contact); + } + + $entity['metadata'] = \SimpleSAML\Metadata\Signer::sign( + $builder->getEntityDescriptorText(), + $entity['metadata_array'], + 'SAML 2 SP' + ); + $entities[$index] = $entity; + } + } catch (\Exception $e) { + \SimpleSAML\Logger::error('Federation: Error loading shib13-idp: '.$e->getMessage()); + } + } + + // ADFS + if ($this->config->getBoolean('enable.adfs-idp', false) && Module::isModuleEnabled('adfs')) { + try { + $idps = $this->mdHandler->getList('adfs-idp-hosted'); + $adfsentities = []; + if (count($idps) > 1) { + foreach ($idps as $index => $idp) { + $idp['url'] = Module::getModuleURL('adfs/idp/metadata/'.$idp['auth']); + $idp['metadata-set'] = 'adfs-idp-hosted'; + $idp['metadata-index'] = $index; + $idp['metadata_array'] = ADFS_IdP::getHostedMetadata($idp['entityid']); + $adfsentities[] = $idp; + } + } else { + $adfsentities['adfs-idp'] = $this->mdHandler->getMetaDataCurrent('adfs-idp-hosted'); + $adfsentities['adfs-idp']['url'] = Module::getModuleURL('adfs/idp/metadata.php'); + $adfsentities['adfs-idp']['metadata_array'] = + ADFS_IdP::getHostedMetadata( + $this->mdHandler->getMetaDataCurrentEntityID('adfs-idp-hosted') + ); + } + + foreach ($adfsentities as $index => $entity) { + $builder = new SAMLBuilder($entity['entityid']); + $builder->addSecurityTokenServiceType($entity['metadata_array']); + $builder->addOrganizationInfo($entity['metadata_array']); + foreach ($entity['metadata_array']['contacts'] as $contact) { + $builder->addContact($contact['contactType'], $contact); + } + + $entity['metadata'] = \SimpleSAML\Metadata\Signer::sign( + $builder->getEntityDescriptorText(), + $entity['metadata_array'], + 'ADFS IdP' + ); + $entities[$index] = $entity; + } + } catch (\Exception $e) { + \SimpleSAML\Logger::error('Federation: Error loading adfs-idp: '.$e->getMessage()); + } + } + + // process certificate information and dump the metadata array + foreach ($entities as $index => $entity) { + $entities[$index]['type'] = $entity['metadata-set']; + foreach ($entity['metadata_array']['keys'] as $kidx => $key) { + $key['url'] = Module::getModuleURL( + 'admin/cert', + [ + 'set' => $entity['metadata-set'], + 'idp' => $entity['metadata-index'], + 'prefix' => $key['prefix'], + ] + ); + $key['name'] = 'idp'; + unset($entity['metadata_array']['keys'][$kidx]['prefix']); + $entities[$index]['certificates'][] = $key; + } + + // only one key, reduce + if (count($entity['metadata_array']['keys']) === 1) { + $cert = array_pop($entity['metadata_array']['keys']); + $entity['metadata_array']['certData'] = $cert['X509Certificate']; + unset($entity['metadata_array']['keys']); + } + + $entities[$index]['metadata_array'] = var_export($entity['metadata_array'], true); + } + + return $entities; + } + + + /** + * Get an array of entities describing the local SP instances. + * + * @return array + * @throws \SimpleSAML\Error\Exception If OrganizationName is set for an SP instance but OrganizationURL is not. + */ + private function getHostedSP() + { + $entities = []; + + /** @var \SimpleSAML\Module\saml\Auth\Source\SP $source */ + foreach (\SimpleSAML\Auth\Source::getSourcesOfType('saml:SP') as $source) { + $metadata = $source->getHostedMetadata(); + $certificates = $metadata['keys']; + if (count($metadata['keys']) === 1) { + $cert = array_pop($metadata['keys']); + $metadata['certData'] = $cert['X509Certificate']; + unset($metadata['keys']); + } + + // get the name + $name = $source->getMetadata()->getLocalizedString( + 'name', + $source->getMetadata()->getLocalizedString('OrganizationDisplayName', $source->getAuthId()) + ); + + $builder = new SAMLBuilder($source->getEntityId()); + $builder->addMetadataSP20($metadata, $source->getSupportedProtocols()); + $builder->addOrganizationInfo($metadata); + $xml = $builder->getEntityDescriptorText(true); + + // sanitize the resulting array + unset($metadata['UIInfo']); + unset($metadata['metadata-set']); + unset($metadata['entityid']); + + // sanitize the attributes array to remove friendly names + if (isset($metadata['attributes']) && is_array($metadata['attributes'])) { + $metadata['attributes'] = array_values($metadata['attributes']); + } + + // sign the metadata if enabled + $xml = \SimpleSAML\Metadata\Signer::sign($xml, $source->getMetadata()->toArray(), 'SAML 2 SP'); + + $entities[] = [ + 'authid' => $source->getAuthId(), + 'entityid' => $source->getEntityId(), + 'type' => 'saml20-sp-hosted', + 'url' => $source->getMetadataURL(), + 'name' => $name, + 'metadata' => $xml, + 'metadata_array' => var_export($metadata, true), + 'certificates' => $certificates, + ]; + } + + return $entities; + } +} diff --git a/modules/admin/templates/federation.twig b/modules/admin/templates/federation.twig new file mode 100644 index 0000000000000000000000000000000000000000..ab2921b53e902dceeba76247091abd509ccd2580 --- /dev/null +++ b/modules/admin/templates/federation.twig @@ -0,0 +1,163 @@ +{% set pagetitle = 'SimpleSAMLphp installation page'|trans %} +{% set frontpage_section = 'federation' %} +{% extends "base.twig" %} + +{% block content %} + {%- include "@admin/includes/menu.twig" %} + {%- if entries.hosted is iterable %} + + <h2>{% trans %}Hosted entities{% endtrans %}</h2> + {%- for key, set in entries.hosted %} + {%- set metadataset = attribute(set, 'metadata-set') %} + {%- if not loop.first %} + + <br/> + {%- endif %} + {%- embed "includes/expander.twig" %} + {%- block general %} + + <dl> + {%- if set.name %} + + <dt>{{ set.name|translateFromArray }}</dt> + {%- endif %} + + <dd>EntityID: <code>{{ set.entityid }}</code></dd> + {%- if set.deprecated %} + + <dd><span class="entity-deprecated">Deprecated</span></dd> + {%- endif %} + {% set index = attribute(set, 'metadata-index')|default(false) %} + {%- if index and set.entityid != index %} + + <dd>Index: <code>{{ index }}</code></dd> + {%- endif %} + + <dd>{% trans %}Type:{% endtrans %} <strong>{{ mdtype[set.type]|trans }}</strong></dd> + </dl> + {%- endblock %} + {%- block content %} + + <dl> + <dt>{% trans %}SAML Metadata{% endtrans %}</dt> + <dd>{% trans %}You can get the metadata XML on a dedicated URL:{% endtrans %}</dd> + <dd class="code-box hljs"> + <div class="pure-button-group top-right-corner"> + <a class="pure-button copy hljs" data-clipboard-target="#url-{{ key }}" + title="{% trans %}Copy to clipboard{% endtrans %}"><span class="fa fa-copy"></span></a> + <a class="pure-button hljs" href="{{ set.url }}"> + <span class="fa fa-external-link-square"></span> + </a> + </div> + <code id="url-{{ key }}" class="code-box-content">{{ set.url }}</code> + </dd> + <dd>{% trans %}In SAML 2.0 Metadata XML format:{% endtrans %}</dd> + <dd class="code-box hljs"> + <div class="pure-button-group top-right-corner"> + <a class="pure-button copy hljs" data-clipboard-target="#xml-{{ key }}" + title="{% trans %}Copy to clipboard{% endtrans %}"><span class="fa fa-copy"></span></a> + </div> + <div id="xml-{{ key }}" class="code-box-content xml">{{ set.metadata }}</div> + </dd> + <dt>{% trans %}SimpleSAMLphp Metadata{% endtrans %}</dt> + <dd>{% trans %}Use this if you are using a SimpleSAMLphp entity on + {#- #} the other side:{% endtrans %}</dd> + <dd class="code-box hljs"> + <div class="pure-button-group top-right-corner"> + <a class="pure-button copy hljs" data-clipboard-target="#php-{{ key }}" + title="{% trans %}Copy to clipboard{% endtrans %}"><span class="fa fa-copy"></span></a> + </div> + <div id="php-{{ key }}" class="code-box-content php"> + {#- #}$metadata['{{ set.entityid }}'] = {{ set.metadata_array }};{# -#} + </div> + </dd> + <dt>{% trans %}Certificates{% endtrans %}</dt> + {%- for cert in set.certificates %} + {%- if loop.first %} + + <ul> + {%- endif %} + + <li> + <a href="{{ cert.url }}"><i class="fa fa-download"></i>{{ cert.name }} + {#- #}{% if cert.signing %}-signing{% endif %} + {#- #}{% if cert.encryption %}-encryption{% endif %}.pem + {#- #}{% if cert.prefix %} ({% trans %}new{% endtrans %}){% endif %}</a> + </li> + {%- if loop.last %} + + </ul> + {%- endif %} + {%- endfor %} + + </dl> + {%- endblock %} + {%- endembed %} + {%- endfor %} + {%- endif %} + + <h2>{% trans %}Trusted entities{% endtrans %}</h2> + {%- if entries.remote is iterable %} + {%- for key, set in entries.remote %} + + <fieldset class="fancyfieldset"> + <legend>{{ mdtype[key]|trans }}</legend> + <ul> + {% for entityid, entity in set %} + + <li><a href="{{ (metadata_url ~ '?entityid=' ~ entity.entityid ~ '&set=' ~ key) }}"> + {%- if entity.name_translated %} + + {{ entity.name_translated }} + {%- elseif entity.organizationdisplayname_translated %} + + {{ entity.organizationdisplayname_translated }} + {%- else %} + + {{ entity.entityid|escape('html') }} + {% endif -%} + + </a> + {%- if entity.expire %} + {%- if entity.expire < date().timestamp %} + + <span class="entity-expired"> (expired {{ ((date().timestamp - entity.expire) / 3600) }} hours ago)</span> + {%- else %} + {%- set expiration = (entity.expire - date().timestamp) / 3600 %} + + ({% trans %}expires in {{ expiration }} hours{% endtrans %}) + {%- endif %} + {%- endif %} + + </li> + {% endfor %} + </ul> + </fieldset> + {% endfor %} + {% endif %} + + <h2>{% trans %}Tools{% endtrans %}</h2> + <ul> + {%- for key, link in links %} + + <li><a href="{{ link.href }}">{{ link.text|trans }}</a></li> + {%- endfor %} + + </ul> + <form action="{{ metadata_url }}" method="get" class="pure-form"> + <fieldset class="fancyfieldset"> + <legend>{% trans %}Look up metadata for entity:{% endtrans %}</legend> + <select name="set"> + {%- if entries.remote %} + {%- for key, set in entries.remote %} + + <option value="{{ key|escape }}">{{ mdtype[key]|trans }}</option> + {%- endfor %} + {%- endif %} + + </select> + <input type="text" name="entityid" placeholder="{% trans %}EntityID{% endtrans %}" /> + <button class="pure-button pure-button-red" type="submit">{% trans %}Search{% endtrans %}</button> + </fieldset> + </form> +{% endblock %} diff --git a/package-lock.json b/package-lock.json index 299a4db408df509f5d9e459f9d2f1dcf764e385f..39ad954e87fdcea7b94c381acb310f0b2d5bcadc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1822,6 +1822,11 @@ "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=" }, + "highlight.js": { + "version": "9.13.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.13.1.tgz", + "integrity": "sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A==" + }, "jquery": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz", diff --git a/package.json b/package.json index 088e6515ff9d244bb5a0ac47198bfa49140feaff..6e2f9764d53206213d795081c0c1fa7d488c397b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "jquery-ui": "^1.12.1", "purecss": "^1.0.0", "reset-css": "^4.0.1", - "selectize": "^0.12.6" + "selectize": "^0.12.6", + "highlight.js": "^9.13.1" }, "devDependencies": { "babel-core": "^6.26.3", diff --git a/src/css/default.scss b/src/css/default.scss index 94e0611be0c9f8af30865b772c4f77c7bf813c27..7f63e9ba922c942d518fbb19b747d1f5fb6e23ab 100644 --- a/src/css/default.scss +++ b/src/css/default.scss @@ -2,6 +2,7 @@ @import "../../node_modules/purecss/build/pure.css"; @import "../../node_modules/font-awesome/scss/font-awesome.scss"; @import "../../node_modules/selectize/dist/css/selectize.css"; +@import "../../node_modules/highlight.js/styles/zenburn.css"; /************************************************************ * GENERAL @@ -47,15 +48,17 @@ body { } h1 { - margin: 0.2em 0; + margin: 1em 0; font-size: 2em; font-weight: 900; } h2 { - margin: 50px 0 20px 0; + margin: 1em 0; + font-size: 1.5em; font-weight: 700; color: #1c1c1c; + border-bottom: solid 1px #bbb; } h3 { @@ -72,10 +75,20 @@ p { a { color: midnightblue; -} - -a:hover { - color: rgba(25, 25, 112, 0.5); + &:hover, + &:focus, + &.pure-menu-link:hover, + &.pure-menu-link:focus, + .pure-menu-selected &.pure-menu-link:hover, + .pure-menu-selected &.pure-menu-link:focus { + color: white; + background-color: #444444; + padding: .5em 1em; + } + &:hover, + &:focus { + padding: .15rem; + } } .dark-bg a { @@ -122,6 +135,9 @@ pre, code, kbd, samp, tt { a.pure-button-red { background-color: rgb(219, 1, 0); color: #fff; + &:hover, &:focus { + background-color: #555; + } } .pure-button.hollow { @@ -145,6 +161,16 @@ a.pure-button-red { user-select: text; } +.pure-button.hljs { + display: inline-block; + border: 0; + background-color: transparent; + &:hover, &:focus { + background-color: #f0f0f0; + color: black; + } +} + .pure-button-group .pure-button:first-child, .pure-button-group .pure-button:last-child { -webkit-border-radius: 0; @@ -171,6 +197,12 @@ a.pure-button-red { overflow: hidden; } +.top-right-corner { + position: absolute; + right: 1.75em; + +} + /* *********************************************************** SLIDING SIDE-MENU FOR SMALL SCREENS ************************************************************ */ @@ -305,9 +337,12 @@ small screens. background: transparent; z-index: 10; height: 2rem; - padding-top: 2rem; - padding-bottom: 2rem; + padding: 2rem 0; text-decoration: none; + &:hover, &:focus { + padding: 2rem 0; + background: none; + } } .menu-link span:hover, @@ -430,14 +465,26 @@ CONTENT } .code-box { - border: 1px solid #ccc; margin-bottom: 1em; + border: 1px solid #ccc; + a { + padding: .5em; + } } .code-box-content { font-size: 1em; line-height: 1.15; padding: 0.5em 1em; + display: inline-block; + min-height: 1em; + max-height: 20em; + height: 100%; + white-space: pre-wrap; + &::selection { + color: black; + background: #fee9be; + } } .code-box-title { @@ -713,14 +760,16 @@ MEDIA QUERIES } fieldset.fancyfieldset { - margin: 2em 1em 1em 0; - border: 1px solid #bbb; + padding-left: 1.5em; + //margin: 2em 1em 1em 0; + //border: 1px solid #bbb; } fieldset.fancyfieldset legend { - margin-left: 2em; - padding: 3px 2em 3px 2em; - border: 1px solid #bbb; + //margin-left: 2em; + padding: 3px 2em 3px 0; + //border: 1px solid #bbb; + width: 100%; } dt { @@ -744,3 +793,61 @@ div.preferredidp { background: #eee; padding: 2px 2em 2px 2em; } + + +/********* + * Utils * + *********/ + +.clear { + clear: both; +} + +.breathe-top { + margin-top: 1em; +} + +.expandable { + border: solid 1px #bbb; + width: 100%; + + .general { + padding: 1em; + } + .content { + display: none; + padding: 1em; + } + .expander { + cursor: pointer; + text-align: center; + padding: .25em; + display: block; + color: black; + &:focus, &:hover { + background-color: #555; + color: white; + } + &:after { + content: "\f078"; + font-family: FontAwesome; + } + } + + &.expanded { + .content { + display: block; + border-left: solid .25em #555; + border-right: solid .25em #555; + } + .expander { + border-bottom: none; + border-top: solid 1px #bbb; + border-left: solid .25em #555; + border-right: solid .25em #555; + &:after { + content: "\f077"; + } + } + } +} diff --git a/src/js/bundle.js b/src/js/bundle.js index 50aeece058ec5f8aa413e9e2729bfae76f96e235..2766b06fabec8ee653d7e356f357a7e2a3355c01 100644 --- a/src/js/bundle.js +++ b/src/js/bundle.js @@ -1,6 +1,9 @@ import "es6-shim"; -import "clipboard/dist/clipboard"; +import ClipboardJS from "clipboard/dist/clipboard"; import "selectize/dist/js/selectize"; +import hljs from "highlight.js/lib/highlight"; +import xml from "highlight.js/lib/languages/xml"; +import php from "highlight.js/lib/languages/php"; $(document).ready(function () { // get available languages @@ -24,4 +27,27 @@ $(document).ready(function () { $('#foot').toggleClass('active'); $(this).toggleClass('active'); }); + + // expander boxes + $('.expandable > .expander').on('click', function(e) { + e.preventDefault(); + let target = $(e.currentTarget); + target.parents('.expandable').toggleClass('expanded'); + target.blur(); + }); + + // syntax highlight + hljs.registerLanguage('xml', xml); + hljs.registerLanguage('php', php); + $('.code-box-content.xml, .code-box-content.php').each(function(i, block) { + hljs.highlightBlock(block) + }); + + // clipboard + let clipboard = new ClipboardJS('.copy'); + clipboard.on('success', function(e) { + setTimeout(function() { + e.clearSelection(); + }, 150); + }); }); \ No newline at end of file diff --git a/templates/includes/expander.twig b/templates/includes/expander.twig new file mode 100644 index 0000000000000000000000000000000000000000..8096b91332caa22e1df26bf548f9f19e31466d7e --- /dev/null +++ b/templates/includes/expander.twig @@ -0,0 +1,11 @@ + <div class="expandable{% if expanded %} expanded{% endif %}"> + <div class="general"> + {%- block general%}{% endblock %} + + </div> + <a tabindex="0" class="expander"></a> + <div class="content"> + {%- block content %}{% endblock %} + + </div> + </div> \ No newline at end of file