From 5ba93f0335ab74fcc8758e4810786a96347203c8 Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Sat, 7 Feb 2026 16:38:01 -0500 Subject: [PATCH] Added API Key management --- api/beestat.sql | 3 +- api/user.php | 142 +++++++++++++++ css/dashboard.css | 1 + js/component/card/manage_api_key.js | 257 ++++++++++++++++++++++++++++ js/js.php | 1 + js/layer/load.js | 8 + js/layer/settings.js | 8 + 7 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 js/component/card/manage_api_key.js diff --git a/api/beestat.sql b/api/beestat.sql index 5df9705..77de746 100644 --- a/api/beestat.sql +++ b/api/beestat.sql @@ -124,7 +124,6 @@ CREATE TABLE `api_user` ( `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`api_user_id`), UNIQUE KEY `api_key` (`api_key`), - UNIQUE KEY `username` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -571,7 +570,7 @@ CREATE TABLE `session` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_by` int unsigned NOT NULL, `last_used_at` timestamp NULL DEFAULT NULL, - `last_used_by` int unsigned NOT NULL, + `last_used_by` int unsigned NULL DEFAULT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`session_id`), UNIQUE KEY `key` (`session_key`), diff --git a/api/user.php b/api/user.php index 506171e..25754c0 100644 --- a/api/user.php +++ b/api/user.php @@ -15,6 +15,10 @@ class user extends cora\crud { 'log_out', 'sync_patreon_status', 'unlink_patreon_account', + 'create_api_key', + 'recycle_api_key', + 'delete_api_key', + 'session_read_id', ], 'public' => [] ]; @@ -372,4 +376,142 @@ class user extends cora\crud { ); } + /** + * Get the current user's API session if one exists. Only returns sessions + * where api_user_id is not null. Should only ever be one API session per user. + * + * @return array|null Full session row including session_key (the API key), or null if none exists + */ + public function get_api_session() { + $sessions = $this->session_read_id(); + + // Return the first API session (should only be one) + if(count($sessions) > 0) { + return reset($sessions); + } + + return null; + } + + /** + * Create a new API key for the current user. Only one API key allowed per user. + * Generates a secure 40-character API key and creates both api_user and session + * records. The session_key field contains the API key. + * + * @return array The created session row + * @throws cora\exception if user already has an API key + */ + public function create_api_key() { + // Check if user already has an API key + $existing = $this->get_api_session(); + if($existing !== null) { + throw new cora\exception('User already has an API key. Use recycle_api_key to generate a new one.', 10001); + } + + // Generate secure API key + $api_key = bin2hex(random_bytes(20)); + + // Create api_user record + $api_user_id = $this->database->create( + 'cora\api_user', + [ + 'name' => $this->session->get_user_id(), + 'api_key' => $api_key, + 'session_key' => $api_key + ], + 'id' + ); + + // Create session record linking user to api_user + return $this->database->create( + 'cora\session', + [ + 'session_key' => $api_key, + 'user_id' => $this->session->get_user_id(), + 'api_user_id' => $api_user_id, + 'created_by' => ip2long($_SERVER['REMOTE_ADDR']) + ] + ); + } + + /** + * Regenerate the API key for the current user. If no API key exists, creates + * a new one. Generates a new secure 40-character key and updates both the + * api_user and session records with the new key. + * + * @return array The updated session row with the new session_key + */ + public function recycle_api_key() { + // Check if user has an API key and get the session + $existing = $this->get_api_session(); + if($existing === null) { + return $this->create_api_key(); + } + + // Generate new API key + $new_api_key = bin2hex(random_bytes(20)); + + // Update api_user record + $this->database->update( + 'cora\api_user', + [ + 'api_user_id' => $existing['api_user_id'], + 'api_key' => $new_api_key, + 'session_key' => $new_api_key + ] + ); + + // Update session record with new session_key + $this->database->update( + 'cora\session', + [ + 'session_id' => $existing['session_id'], + 'session_key' => $new_api_key + ] + ); + + // Read back the updated session to return full data + return $this->get_api_session(); + } + + /** + * Delete the current user's API key. Soft-deletes both the session and + * api_user records (sets deleted = 1). If no API key exists, does nothing. + */ + public function delete_api_key() { + // Check if user has an API key and get the session + $existing = $this->get_api_session(); + if($existing !== null) { + $this->database->delete('cora\session', $existing['session_id']); + $this->database->delete('cora\api_user', $existing['api_user_id']); + } + } + + /** + * Read all API sessions for the current user, indexed by session_id. + * Only returns sessions where api_user_id is not null (filters out normal + * login sessions). Used by frontend to populate session cache. + * + * @return array API sessions indexed by session_id (e.g., [123 => [...], 456 => [...]]) + */ + public function session_read_id() { + $sessions = $this->database->read( + 'cora\session', + [ + 'user_id' => $this->session->get_user_id(), + 'deleted' => 0 + ] + ); + + // Index by session_id, only including API sessions + $sessions_indexed = []; + foreach($sessions as $session) { + if($session['api_user_id'] !== null) { + $sessions_indexed[$session['session_id']] = $session; + } + } + + return $sessions_indexed; + } + } diff --git a/css/dashboard.css b/css/dashboard.css index 4622096..16cff69 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -455,6 +455,7 @@ input[type=range]::-moz-range-thumb { .icon.cloud_question:before { content: "\F0A39"; } .icon.code_tags:before { content: "\F0174"; } .icon.cog:before { content: "\F0493"; } +.icon.content_copy:before { content: "\F018F"; } .icon.credit_card_lock:before { content: "\F18E7"; } .icon.credit_card_settings:before { content: "\F0FF5"; } .icon.currency_usd:before { content: "\F01C1"; } diff --git a/js/component/card/manage_api_key.js b/js/component/card/manage_api_key.js new file mode 100644 index 0000000..d461b42 --- /dev/null +++ b/js/component/card/manage_api_key.js @@ -0,0 +1,257 @@ +/** + * Manage API Key card. Allows users to create, regenerate, and delete their + * API keys. Listens to cache.session changes to automatically update the UI. + */ +beestat.component.card.manage_api_key = function() { + const self = this; + + var change_function = beestat.debounce(function() { + self.rerender(); + }, 10); + + beestat.dispatcher.addEventListener( + [ + 'cache.session' + ], + change_function + ); + + beestat.component.card.apply(this, arguments); +}; +beestat.extend(beestat.component.card.manage_api_key, beestat.component.card); + +/** + * Decorate contents. + * + * @param {rocket.Elements} parent Parent + */ +beestat.component.card.manage_api_key.prototype.decorate_contents_ = function(parent) { + // Introduction text + var intro = document.createElement('p'); + intro.innerText = 'API keys allow you to access your beestat data programmatically. Keep your API key secure and do not share it publicly.'; + parent.appendChild(intro); + + // Find API session in cache + var api_session = null; + var sessions = $.values(beestat.cache.session); + for (var i = 0; i < sessions.length; i++) { + if (sessions[i].api_user_id !== null) { + api_session = sessions[i]; + break; + } + } + + if (api_session === null) { + this.render_no_key_state_(parent); + } else { + this.render_existing_key_state_(parent, api_session); + } +}; + +/** + * Render state when no API key exists. + * + * @param {Element} parent Parent element + */ +beestat.component.card.manage_api_key.prototype.render_no_key_state_ = function(parent) { + const self = this; + + const button_container = document.createElement('div'); + parent.appendChild(button_container); + + const tile_create = new beestat.component.tile() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('Create API Key') + .addEventListener('click', function() { + self.show_loading_(); + + new beestat.api() + .add_call( + 'user', + 'create_api_key', + {}, + 'session' + ) + .add_call( + 'user', + 'session_read_id', + {}, + 'session_read_id' + ) + .set_callback(function(response) { + beestat.cache.set('session', response.session_read_id); + self.hide_loading_(); + }) + .send(); + }) + .render($(button_container)); +}; + +/** + * Render state when API key exists. Displays the API key (session_key), + * created timestamp, copy button, and management buttons (regenerate/delete). + * + * @param {Element} parent Parent element + * @param {Object} session Session row from database (session_key is the API key) + */ +beestat.component.card.manage_api_key.prototype.render_existing_key_state_ = function(parent, session) { + const self = this; + + parent.appendChild( + $.createElement('p') + .style('font-weight', '500') + .innerText('Your API Key') + ); + + // API Key display with copy functionality + const key_container = document.createElement('div'); + key_container.style.cssText = 'display: flex; flex-direction: row; align-items: center; gap: ' + beestat.style.size.gutter + 'px; margin-bottom: ' + beestat.style.size.gutter + 'px; padding: ' + (beestat.style.size.gutter / 2) + 'px; background-color: ' + beestat.style.color.bluegray.light + '; border-radius: ' + beestat.style.size.border_radius + 'px; font-family: monospace; font-size: 14px;'; + + const key_text = document.createElement('span'); + key_text.innerText = session.session_key; + key_text.style.flex = '1'; + key_container.appendChild(key_text); + + const copy_button_container = document.createElement('div'); + key_container.appendChild(copy_button_container); + + const copy_button = new beestat.component.tile() + .set_icon('content_copy') + .set_shadow(false) + .set_size(24) + .set_background_color('transparent') + .set_background_hover_color(beestat.style.color.bluegray.base) + .set_text_color(beestat.style.color.gray.base) + .addEventListener('click', function() { + // Copy to clipboard + if (navigator.clipboard) { + navigator.clipboard.writeText(session.session_key).then(function() { + // Show temporary success feedback + copy_button.set_icon('check'); + setTimeout(function() { + copy_button.set_icon('content_copy'); + }, 2000); + }); + } + }) + .render($(copy_button_container)); + + parent.appendChild(key_container); + + // Created date + var created_text = document.createElement('p'); + created_text.style.fontSize = '12px'; + created_text.style.color = beestat.style.color.gray.base; + created_text.innerText = 'Created: ' + moment.utc(session.created_at).local().format('M/D/YYYY h:mm A'); + parent.appendChild(created_text); + + // Management buttons + parent.appendChild( + $.createElement('p') + .style({ + 'font-weight': '500', + 'margin-top': (beestat.style.size.gutter * 2) + 'px' + }) + .innerText('Manage') + ); + + const button_container = document.createElement('div'); + button_container.style.cssText = 'display: flex; flex-direction: row; gap: ' + beestat.style.size.gutter + 'px;'; + parent.appendChild(button_container); + + // Recycle button + const recycle_container = document.createElement('div'); + button_container.appendChild(recycle_container); + + const tile_recycle = new beestat.component.tile() + .set_background_color(beestat.style.color.blue.base) + .set_background_hover_color(beestat.style.color.blue.light) + .set_text_color('#fff') + .set_text('Regenerate Key') + .set_icon('refresh') + .addEventListener('click', function() { + if (confirm('Regenerating your API key will invalidate the current key. Any applications using the old key will stop working. Continue?')) { + self.show_loading_(); + + new beestat.api() + .add_call( + 'user', + 'recycle_api_key', + {}, + 'session' + ) + .add_call( + 'user', + 'session_read_id', + {}, + 'session_read_id' + ) + .set_callback(function(response) { + beestat.cache.set('session', response.session_read_id); + self.hide_loading_(); + }) + .send(); + } + }) + .render($(recycle_container)); + + // Delete button + const delete_container = document.createElement('div'); + button_container.appendChild(delete_container); + + const tile_delete = new beestat.component.tile() + .set_background_color(beestat.style.color.red.base) + .set_background_hover_color(beestat.style.color.red.light) + .set_text_color('#fff') + .set_text('Delete Key') + .set_icon('trash_can') + .addEventListener('click', function() { + if (confirm('Are you sure you want to delete your API key? This cannot be undone.')) { + self.show_loading_(); + + new beestat.api() + .add_call( + 'user', + 'delete_api_key', + {}, + 'delete' + ) + .add_call( + 'user', + 'session_read_id', + {}, + 'session_read_id' + ) + .set_callback(function(response) { + beestat.cache.set('session', response.session_read_id); + self.hide_loading_(); + }) + .send(); + } + }) + .render($(delete_container)); +}; + +/** + * Get the title of the card. + * + * @return {string} The title. + */ +beestat.component.card.manage_api_key.prototype.get_title_ = function() { + return 'Manage API Key'; +}; + +/** + * Decorate the top right. + * + * @param {rocket.Elements} parent + */ +beestat.component.card.manage_api_key.prototype.decorate_top_right_ = function(parent) { + var link = $.createElement('a') + .setAttribute('href', 'https://api.beestat.io/doc') + .setAttribute('target', '_blank') + .innerText('API Documentation'); + parent.appendChild(link); +}; diff --git a/js/js.php b/js/js.php index 615a9df..2a92622 100755 --- a/js/js.php +++ b/js/js.php @@ -110,6 +110,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; diff --git a/js/layer/load.js b/js/layer/load.js index 5d28452..5e76b77 100644 --- a/js/layer/load.js +++ b/js/layer/load.js @@ -134,6 +134,13 @@ beestat.layer.load.prototype.decorate_ = function(parent) { 'stripe_event' ); + api.add_call( + 'user', + 'session_read_id', + {}, + 'session' + ); + api.set_callback(function(response) { beestat.cache.set('user', response.user); @@ -153,6 +160,7 @@ beestat.layer.load.prototype.decorate_ = function(parent) { beestat.cache.set('announcement', response.announcement); beestat.cache.set('runtime_thermostat_summary', response.runtime_thermostat_summary); beestat.cache.set('stripe_event', response.stripe_event); + beestat.cache.set('session', response.session); // Send you to the no thermostats layer if none were returned. if(Object.keys(response.thermostat).length === 0) { diff --git a/js/layer/settings.js b/js/layer/settings.js index 39d00fc..e38b7c4 100644 --- a/js/layer/settings.js +++ b/js/layer/settings.js @@ -47,6 +47,14 @@ beestat.layer.settings.prototype.decorate_ = function(parent) { } ]); + // Manage API Key + cards.push([ + { + 'card': new beestat.component.card.manage_api_key(), + 'size': 12 + } + ]); + // Footer cards.push([ {