JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
add nice admin_users
authorJason Woofenden <jason@jasonwoof.com>
Tue, 23 Jun 2015 14:58:45 +0000 (10:58 -0400)
committerJason Woofenden <jason@jasonwoof.com>
Tue, 23 Jun 2015 14:58:45 +0000 (10:58 -0400)
admin_users.html [new file with mode: 0644]
admin_users.php [new file with mode: 0644]
admin_users.sql [new file with mode: 0644]
config.php
inc/misc.php [new file with mode: 0644]
inc/password_funcs_backported.php [new file with mode: 0644]
styl.styl
template.html

diff --git a/admin_users.html b/admin_users.html
new file mode 100644 (file)
index 0000000..731ae37
--- /dev/null
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+
+<html lang="en">
+<head>
+       <meta charset="utf-8" />
+       <title><!--~$title show {~-->Accounts<!--~}~--></title>
+       <link rel="stylesheet" href="style.css">
+</head>
+
+<body>
+<!--~$body show {~-->
+
+       <!--~form {~-->
+               <h2><!--~id unset {~-->Add a new account<!--~}~--><!--~id {~-->Edit account "~name html~"<!--~}~--></h2>
+
+               <form action="admin_users" method="post"><!--~id {~--><div style="display: none"><input type="hidden" name="edit_id" value="~id attr~"></div><!--~}~-->
+
+                       <div class="caption">Role</div>
+                       <div class="field"><select name="role"><!--~role options~--></select></div>
+
+                       <div class="caption">Name (optional)</div>
+                       <div class="field"><input type="text" name="name" value="~name attr~"></div>
+
+                       <div class="caption">Username</div>
+                       <div class="field_notes">This is used to log in. It is not case sensitive, and symbols/spaces/etc are ignored.</div>
+                       <div class="field~username_bad {~ field_error~}~"><input type="text" name="username" value="~username attr~"></div>
+
+                       <div class="caption">Password</div>
+                       <!--~editing unset {~--><div class="field_notes">If this is left blank, the user will be unable to log in.</div><!--~}~-->
+                       <!--~editing {~--><div class="field_notes">Leave this blank to leave teh password unchanged.</div><!--~}~-->
+                       <div class="field_notes">Password suggestions: ~password_suggestions {~<code class="password_suggestion">~password_suggestions html~</code>~ sep {~ ~}~~}~</div>
+                       <div class="field~password_bad {~ field_error~}~">
+                               <input type="password" name="pass1" value=""><br>
+                               <input type="password" name="pass2" value="">
+                       </div>
+
+                       <div class="caption"></div>
+                       <div class="field"><input type="submit" name="save" value="Save"></div>
+
+               </form>
+
+               <div class="caption">&nbsp;</div>
+               <div class="field"><a href="admin_users~id {~?id=~id~~}~">Cancel</a></div>
+       <!--~}~-->
+
+       <!--~listings {~-->
+               <h2>Accounts Listing</h2>
+
+               <p>On this page you can manage who can log into this site, and what sort of things they have permission to do once logged in.</p>
+
+               <!--~rows once_if {~-->
+                       <p><a href="admin_users?new=1">[Add a new account]</a></p>
+
+                       <table cellspacing="0" cellpadding="4" border="0" summary="" class="evenodd">
+                               <tr>
+                                       <th><a href="?sort=~sorting-by-role~role">Role</a></th>
+                                       <th><a href="?sort=~sorting-by-name~name">Name</a></th>
+                                       <th><a href="?sort=~sorting-by-username~username">Username</a></th>
+                                       <th><a href="?sort=~sorting-by-last_login~last_login">Last Login</a></th>
+                                       <th><a href="?sort=~sorting-by-last_active~last_active">Last Active</a></th>
+                                       <th>&nbsp;</th>
+                               </tr><!--~rows {~-->
+                               <tr>
+                                       <td class="listing"><a href="admin_users?edit_id=~id~">~role html~<!--~role empty {~--><em>(blank)</em><!--~}~--></a></td>
+                                       <td class="listing"><a href="admin_users?edit_id=~id~">~name html~<!--~name empty {~--><em>(blank)</em><!--~}~--></a></td>
+                                       <td class="listing"><a href="admin_users?edit_id=~id~">~username html~<!--~username empty {~--><em>(blank)</em><!--~}~--></a></td>
+                                       <td class="listing"><a href="admin_users?edit_id=~id~" class="unix_date">~last_login html~</a></td>
+                                       <td class="listing"><a href="admin_users?edit_id=~id~" class="unix_time">~last_active html~</a></td>
+                                       <td><a href="admin_users?admin_users_delete_id=~id~" onclick="return confirm('Permanently delete?')">[delete this account]</a></td>
+                               </tr><!--~}~-->
+
+                       </table>
+                       <p><a href="?download_csv=1">Download as CSV file</a></p>
+               <!--~}~-->
+               <!--~rows once_else {~-->
+                       <p>No accounts in database.</p>
+               <!--~}~-->
+
+               <p><a href="admin_users?new=1">[Add a new account]</a></p>
+       <!--~}~-->
+
+<!--~}~-->
+</body>
+</html>
diff --git a/admin_users.php b/admin_users.php
new file mode 100644 (file)
index 0000000..ba3a46c
--- /dev/null
@@ -0,0 +1,189 @@
+<?php
+
+# This form requires wfpl. See: http://sametwice.com/wfpl
+
+# This form was initially auto-generated. If you would like to alter the
+# parameters and generate a new one try this URL:
+#
+# http://metaform.localhost.jasonwoof.com/?file_name=admin_users&table_name=users&singular=account&plural=accounts&opt_email=No&opt_db=Yes&opt_listing=Yes&opt_display=No&opt_pass=No&opt_public_form=No&opt_public_display=No&fields=name%0D%0Ausername%0D%0Atextbox+password%0D%0Arole%0D%0Aint+last_active%0D%0Aint+last_login&edit=yes
+
+
+# SETUP
+
+# To save results to a database, you'll need to create the users table.
+# The file admin_users.sql should help with this
+#
+# if you rename any of the database fields, you'll need to update this:
+define('ADMIN_USERS_DB_FIELDS', 'role,name,username,last_login,last_active');
+
+
+require_once(__DIR__.'/'.'inc/wfpl/format.php');
+
+$GLOBALS['admin_users_field_to_caption'] = array(
+       'name' => 'Name',
+       'role' => 'Role',
+       'username' => 'Username',
+       'password' => 'Password',
+       'last_login' => 'Last Login',
+       'last_active' => 'Last Active'
+);
+
+function admin_users_get_fields() {
+       $data = array();
+
+       $data['role'] = format_options(_REQUEST_cut('role'), 'role');
+       $data['name'] = format_oneline(trim(_REQUEST_cut('name')));
+       $data['username'] = format_oneline(trim(_REQUEST_cut('username')));
+       $data['pass1'] = format_oneline(trim(_REQUEST_cut('pass1')));
+       $data['pass2'] = format_oneline(trim(_REQUEST_cut('pass2')));
+
+       return $data;
+}
+
+
+function admin_users_main() {
+       session_auth_must('admin_users');
+
+       $id = _REQUEST_cut('edit_id');
+       if ($id) {
+               return admin_users_main_form($id);
+       }
+
+       $id = _REQUEST_cut('admin_users_delete_id');
+       if ($id) {
+               return admin_users_main_delete($id);
+       }
+
+       if (_REQUEST_cut('new')) {
+               return admin_users_main_form();
+       }
+
+       if (_REQUEST_cut('list')) {
+               return admin_users_main_listing();
+       }
+
+       if (_REQUEST_cut('download_csv')) {
+               return admin_users_csv_download();
+       }
+
+       if (isset($_POST['name'])) {
+               return admin_users_main_form();
+       }
+
+       # default action:
+       return admin_users_main_listing();
+}
+
+function admin_users_main_delete($id) {
+       db_delete('users', 'where id=%i', $id);
+       message('Account deleted.');
+       return './admin_users';
+}
+
+function admin_users_csv_download() {
+       require_once(__DIR__.'/'.'inc/wfpl/csv.php');
+       $rows = db_get_rows('users', 'id,'.ADMIN_USERS_DB_FIELDS, 'order by id');
+       $fields = explode(',', 'id,'.ADMIN_USERS_DB_FIELDS);
+       $header = array();
+       foreach ($fields as $field) {
+               if (isset($GLOBALS['admin_users_field_to_caption'][$field])) {
+                       $header[] = $GLOBALS['admin_users_field_to_caption'][$field];
+               } else {
+                       $header[] = $field;
+               }
+       }
+       array_unshift($rows, $header);
+       array2d_to_csv_download($rows, 'admin_users.csv');
+}
+
+function admin_users_main_listing() {
+       $data = array();
+       $desc = '';
+       $sort = _REQUEST_cut('sort');
+       if ($sort && substr($sort, 0, 1) === '-') {
+               $sort = substr($sort, 1);
+               $desc = ' DESC ';
+       } else {
+               $data["sorting-by-$sort"] = '-';
+       }
+       $legal_sorts = explode(',', ADMIN_USERS_DB_FIELDS);
+       if (!$sort || !in_array($sort, $legal_sorts)) {
+               $sort = 'role, name';
+       }
+
+       $data['rows'] = db_get_assocs('users', 'id,role,name,username,last_login,last_active', "order by $sort $desc limit 1000");
+       tem_set('listings', $data);
+       render_timestamps();
+}
+
+function admin_users_suggested_password() {
+       $character_set = "ABCDEFHJKLMNPQRTUWXY34789"; # removed all similar-looking characters
+       $code = "          ";
+
+       # PHP 4.2.0 and up seed the random number generator for you.
+       # Lets hope that it seeds with something harder to guess than the clock.
+       for($i = 0; $i < 10; ++$i) {
+               $code{$i} = $character_set{mt_rand(0, 24)}; # inclusive
+       }
+
+       return $code;
+}
+
+function admin_users_main_form($id = false) {
+       if ($id) {
+               tem_set('id', $id);
+       }
+
+       pulldown('role', [
+               ['admin', 'Site Administrator'],
+               ['disabled', 'Account Disabled']
+       ]);
+
+       if (isset($_POST['name'])) {
+               $data = admin_users_get_fields();
+
+               if (strlen($data['username']) < 1) {
+                       message("Oop, Username is required");
+                       $data['username_bad'] = true;
+               } elseif ($data['pass1'] !== $data['pass2']) {
+                       message("Oop, passwords didn't match. Please enter your desired password carefully (twice).");
+                       $data['password_bad'] = true;
+               } else {
+                       # password hash is slow, so only do it if we're really doing a db write
+                       if (isset($data['pass1']) && strlen($data['pass1']) > 0) {
+                               # hash password for db storage
+                               if (!function_exists('password_hash')) {
+                                       require_once(DOCROOT . 'inc/password_funcs_backported.php');
+                               }
+                               $data['password'] = password_hash($data['pass1'], PASSWORD_DEFAULT);
+                       }
+                       unset($data['pass1']);
+                       unset($data['pass2']);
+                       if ($id) {
+                               db_update_assoc('users', $data, 'where id=%i', $id);
+                               message('Account updated.');
+                       } else {
+                               db_insert_assoc('users', $data);
+                               message('Account saved.');
+                       }
+                       return './admin_users';
+               }
+               # else fall through to display the form again. Field values are in $data
+       } elseif ($id) {
+               # we've recieved an edit id, but no data. So we grab the values to be edited from the database
+               $data = db_get_assoc('users', ADMIN_USERS_DB_FIELDS, 'where id=%i', $id);
+       } else {
+               # form not submitted, you can set default values like so:
+               #$data = array('name' => 'Yes');
+               $data = array();
+       }
+
+       tem_set('password_suggestions', [
+               admin_users_suggested_password(),
+               admin_users_suggested_password(),
+               admin_users_suggested_password(),
+               admin_users_suggested_password(),
+               admin_users_suggested_password()
+       ]);
+       tem_set('form', $data);
+}
diff --git a/admin_users.sql b/admin_users.sql
new file mode 100644 (file)
index 0000000..08f0071
--- /dev/null
@@ -0,0 +1,10 @@
+drop table if exists users;
+create table users (
+       id int unique auto_increment,
+       name varchar(200) binary not null default "",
+       username varchar(200) binary not null default "",
+       password varchar(255) binary not null default "",
+       role varchar(200) binary not null default "",
+       last_active int(11) not null default 0,
+       last_login int(11) not null default 0
+) CHARSET=utf8;
index a1506ec..994bfb5 100644 (file)
@@ -15,6 +15,7 @@ require_once(DOCROOT . 'inc/wfpl/db.php');
 require_once(DOCROOT . 'inc/wfpl/session_messages.php');
 require_once(DOCROOT . 'inc/session_auth.php');
 require_once(DOCROOT . 'inc/cms.php');
+require_once(DOCROOT . 'inc/misc.php');
 
 # Connect to the database
 db_connect(WFPL_DB, WFPL_DB_USER, WFPL_DB_PASS);
diff --git a/inc/misc.php b/inc/misc.php
new file mode 100644 (file)
index 0000000..429482d
--- /dev/null
@@ -0,0 +1,6 @@
+<?php
+
+# call this when you have class="unix_time" or class="unix_date"
+function render_timestamps() {
+       $GLOBALS['wfpl_main_template']->set('$render_timestamps');
+}
diff --git a/inc/password_funcs_backported.php b/inc/password_funcs_backported.php
new file mode 100644 (file)
index 0000000..b68b1c1
--- /dev/null
@@ -0,0 +1,314 @@
+<?php
+/**
+ * A Compatibility library with PHP 5.5's simplified password hashing API.
+ *
+ * @author Anthony Ferrara <ircmaxell@php.net>
+ * @license http://www.opensource.org/licenses/mit-license.html MIT License
+ * @copyright 2012 The Authors
+ */
+
+namespace {
+
+    if (!defined('PASSWORD_BCRYPT')) {
+        /**
+         * PHPUnit Process isolation caches constants, but not function declarations.
+         * So we need to check if the constants are defined separately from 
+         * the functions to enable supporting process isolation in userland
+         * code.
+         */
+        define('PASSWORD_BCRYPT', 1);
+        define('PASSWORD_DEFAULT', PASSWORD_BCRYPT);
+        define('PASSWORD_BCRYPT_DEFAULT_COST', 10);
+    }
+
+    if (!function_exists('password_hash')) {
+
+        /**
+         * Hash the password using the specified algorithm
+         *
+         * @param string $password The password to hash
+         * @param int    $algo     The algorithm to use (Defined by PASSWORD_* constants)
+         * @param array  $options  The options for the algorithm to use
+         *
+         * @return string|false The hashed password, or false on error.
+         */
+        function password_hash($password, $algo, array $options = array()) {
+            if (!function_exists('crypt')) {
+                trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING);
+                return null;
+            }
+            if (is_null($password) || is_int($password)) {
+                $password = (string) $password;
+            }
+            if (!is_string($password)) {
+                trigger_error("password_hash(): Password must be a string", E_USER_WARNING);
+                return null;
+            }
+            if (!is_int($algo)) {
+                trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING);
+                return null;
+            }
+            $resultLength = 0;
+            switch ($algo) {
+                case PASSWORD_BCRYPT:
+                    $cost = PASSWORD_BCRYPT_DEFAULT_COST;
+                    if (isset($options['cost'])) {
+                        $cost = $options['cost'];
+                        if ($cost < 4 || $cost > 31) {
+                            trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING);
+                            return null;
+                        }
+                    }
+                    // The length of salt to generate
+                    $raw_salt_len = 16;
+                    // The length required in the final serialization
+                    $required_salt_len = 22;
+                    $hash_format = sprintf("$2y$%02d$", $cost);
+                    // The expected length of the final crypt() output
+                    $resultLength = 60;
+                    break;
+                default:
+                    trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING);
+                    return null;
+            }
+            $salt_req_encoding = false;
+            if (isset($options['salt'])) {
+                switch (gettype($options['salt'])) {
+                    case 'NULL':
+                    case 'boolean':
+                    case 'integer':
+                    case 'double':
+                    case 'string':
+                        $salt = (string) $options['salt'];
+                        break;
+                    case 'object':
+                        if (method_exists($options['salt'], '__tostring')) {
+                            $salt = (string) $options['salt'];
+                            break;
+                        }
+                    case 'array':
+                    case 'resource':
+                    default:
+                        trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING);
+                        return null;
+                }
+                if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) {
+                    trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING);
+                    return null;
+                } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) {
+                    $salt_req_encoding = true;
+                }
+            } else {
+                $buffer = '';
+                $buffer_valid = false;
+                if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) {
+                    $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM);
+                    if ($buffer) {
+                        $buffer_valid = true;
+                    }
+                }
+                if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) {
+                    $buffer = openssl_random_pseudo_bytes($raw_salt_len);
+                    if ($buffer) {
+                        $buffer_valid = true;
+                    }
+                }
+                if (!$buffer_valid && @is_readable('/dev/urandom')) {
+                    $file = fopen('/dev/urandom', 'r');
+                    $read = PasswordCompat\binary\_strlen($buffer);
+                    while ($read < $raw_salt_len) {
+                        $buffer .= fread($file, $raw_salt_len - $read);
+                        $read = PasswordCompat\binary\_strlen($buffer);
+                    }
+                    fclose($file);
+                    if ($read >= $raw_salt_len) {
+                        $buffer_valid = true;
+                    }
+                }
+                if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) {
+                    $buffer_length = PasswordCompat\binary\_strlen($buffer);
+                    for ($i = 0; $i < $raw_salt_len; $i++) {
+                        if ($i < $buffer_length) {
+                            $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255));
+                        } else {
+                            $buffer .= chr(mt_rand(0, 255));
+                        }
+                    }
+                }
+                $salt = $buffer;
+                $salt_req_encoding = true;
+            }
+            if ($salt_req_encoding) {
+                // encode string with the Base64 variant used by crypt
+                $base64_digits =
+                    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+                $bcrypt64_digits =
+                    './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+                $base64_string = base64_encode($salt);
+                $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits);
+            }
+            $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len);
+
+            $hash = $hash_format . $salt;
+
+            $ret = crypt($password, $hash);
+
+            if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) {
+                return false;
+            }
+
+            return $ret;
+        }
+
+        /**
+         * Get information about the password hash. Returns an array of the information
+         * that was used to generate the password hash.
+         *
+         * array(
+         *    'algo' => 1,
+         *    'algoName' => 'bcrypt',
+         *    'options' => array(
+         *        'cost' => PASSWORD_BCRYPT_DEFAULT_COST,
+         *    ),
+         * )
+         *
+         * @param string $hash The password hash to extract info from
+         *
+         * @return array The array of information about the hash.
+         */
+        function password_get_info($hash) {
+            $return = array(
+                'algo' => 0,
+                'algoName' => 'unknown',
+                'options' => array(),
+            );
+            if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) {
+                $return['algo'] = PASSWORD_BCRYPT;
+                $return['algoName'] = 'bcrypt';
+                list($cost) = sscanf($hash, "$2y$%d$");
+                $return['options']['cost'] = $cost;
+            }
+            return $return;
+        }
+
+        /**
+         * Determine if the password hash needs to be rehashed according to the options provided
+         *
+         * If the answer is true, after validating the password using password_verify, rehash it.
+         *
+         * @param string $hash    The hash to test
+         * @param int    $algo    The algorithm used for new password hashes
+         * @param array  $options The options array passed to password_hash
+         *
+         * @return boolean True if the password needs to be rehashed.
+         */
+        function password_needs_rehash($hash, $algo, array $options = array()) {
+            $info = password_get_info($hash);
+            if ($info['algo'] != $algo) {
+                return true;
+            }
+            switch ($algo) {
+                case PASSWORD_BCRYPT:
+                    $cost = isset($options['cost']) ? $options['cost'] : PASSWORD_BCRYPT_DEFAULT_COST;
+                    if ($cost != $info['options']['cost']) {
+                        return true;
+                    }
+                    break;
+            }
+            return false;
+        }
+
+        /**
+         * Verify a password against a hash using a timing attack resistant approach
+         *
+         * @param string $password The password to verify
+         * @param string $hash     The hash to verify against
+         *
+         * @return boolean If the password matches the hash
+         */
+        function password_verify($password, $hash) {
+            if (!function_exists('crypt')) {
+                trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING);
+                return false;
+            }
+            $ret = crypt($password, $hash);
+            if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) {
+                return false;
+            }
+
+            $status = 0;
+            for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) {
+                $status |= (ord($ret[$i]) ^ ord($hash[$i]));
+            }
+
+            return $status === 0;
+        }
+    }
+
+}
+
+namespace PasswordCompat\binary {
+
+    if (!function_exists('PasswordCompat\\binary\\_strlen')) {
+
+        /**
+         * Count the number of bytes in a string
+         *
+         * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension.
+         * In this case, strlen() will count the number of *characters* based on the internal encoding. A
+         * sequence of bytes might be regarded as a single multibyte character.
+         *
+         * @param string $binary_string The input string
+         *
+         * @internal
+         * @return int The number of bytes
+         */
+        function _strlen($binary_string) {
+            if (function_exists('mb_strlen')) {
+                return mb_strlen($binary_string, '8bit');
+            }
+            return strlen($binary_string);
+        }
+
+        /**
+         * Get a substring based on byte limits
+         *
+         * @see _strlen()
+         *
+         * @param string $binary_string The input string
+         * @param int    $start
+         * @param int    $length
+         *
+         * @internal
+         * @return string The substring
+         */
+        function _substr($binary_string, $start, $length) {
+            if (function_exists('mb_substr')) {
+                return mb_substr($binary_string, $start, $length, '8bit');
+            }
+            return substr($binary_string, $start, $length);
+        }
+
+        /**
+         * Check if current PHP version is compatible with the library
+         *
+         * @return boolean the check result
+         */
+        function check() {
+            static $pass = NULL;
+
+            if (is_null($pass)) {
+                if (function_exists('crypt')) {
+                    $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
+                    $test = crypt("password", $hash);
+                    $pass = $test == $hash;
+                } else {
+                    $pass = false;
+                }
+            }
+            return $pass;
+        }
+
+    }
+}
index 051415d..54ac463 100644 (file)
--- a/styl.styl
+++ b/styl.styl
@@ -1,4 +1,4 @@
-@require 'inc/wfpl/stylus-helpers.styl'
+@require 'inc/wfpl/stylus_helpers.styl'
 @require '.sha1sums.styl'
 
 // dimensions
@@ -159,16 +159,16 @@ body
 nav#site-nav
        padding-bottom: 20px
        ul
-               li-reset()
-               space-evenly()
+               li_reset()
+               space_evenly()
 
 footer
        clear: both
        padding-top: 40px
        ul
-               li-reset()
+               li_reset()
                li
-                       li-reset()
+                       li_reset()
                        display: inline-block
                        margin-right: 10px
 
@@ -258,6 +258,9 @@ div > :last-child,
        margin-bottom: 0px
 
 table.evenodd
+       td, th
+               padding: 6px 12px
+               text-align: left
        > thead, > tbody, &
                > tr:nth-child(2n+1)
                        > td, > th
@@ -271,3 +274,11 @@ table.evenodd
                        &:hover
                                > td, > th
                                        background: rgba(0,0,0,0.09)
+
+.field_error
+       input
+               border: 1px solid red
+
+.password_suggestion
+       & + &
+               margin-left: 10px
index 442685d..89c3cb6 100644 (file)
                        footer text here
                </footer>
        </div>
+       <!--~$render_timestamps {~-->
+               <script>
+                       (function() {
+                               var i02 = function (i) { return i > 9 ? i : '0' + i; };
+                               var date_to_html = function (d, include_time) {
+                                       var hours = d.getHours()
+                                       var ret = '<span class="date_time">' +
+                                               i02(d.getMonth() + 1) + '/' +
+                                               i02(d.getDate()) + '/' +
+                                               d.getFullYear();
+                                       if (include_time) {
+                                               ret += ' ' + ((hours + 11) % 12 + 1) + ':' +
+                                                       i02(d.getMinutes()) +
+                                                       (hours < 12 ? 'am' : 'pm')
+                                       }
+                                       ret += '</span>';
+                                       return ret;
+                               }
+                               window.render_timestamps = function() {
+                                       var els = document.getElementsByClassName('unix_time');
+                                       var i, d, hours;
+                                       for (i in els) {
+                                               d = new Date(1000 * parseInt(els[i].innerHTML));
+                                               els[i].innerHTML = date_to_html(d, true);
+                                       }
+                                       els = document.getElementsByClassName('unix_date');
+                                       for (i in els) {
+                                               d = new Date(1000 * parseInt(els[i].innerHTML));
+                                               els[i].innerHTML = date_to_html(d, false);
+                                       }
+                               }
+                       }).call();
+                       render_timestamps();
+               </script>
+       <!--~}~-->
 </body>
 </html>