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([
{