diff --git a/Provider/frontend/src/App.svelte b/Provider/frontend/src/App.svelte index 366bf2f..3fdba39 100644 --- a/Provider/frontend/src/App.svelte +++ b/Provider/frontend/src/App.svelte @@ -3,6 +3,7 @@ import OTAUpdate from "./lib/OTAUpdate.svelte"; import Sidebar from "./lib/Sidebar.svelte"; import TaskManager from "./lib/TaskManager.svelte"; + import UserManager from "./lib/UserManager.svelte"; /** @type {'loading' | 'ok' | 'error' | 'rebooting'} */ let status = $state("loading"); @@ -10,7 +11,7 @@ let showRebootConfirm = $state(false); let isRecovering = $state(false); - /** @type {'dashboard' | 'tasks'} */ + /** @type {'dashboard' | 'tasks' | 'users'} */ let currentView = $state("dashboard"); let systemInfo = $state({ @@ -144,7 +145,7 @@
-

Calendink Provider 🚀👑🥸

+

Calendink Provider 📅📅🚀🚀

ESP32-S3 System Dashboard v{__APP_VERSION__}

@@ -323,6 +324,11 @@
+ {:else if currentView === 'users'} + +
+ +
{/if} diff --git a/Provider/frontend/src/lib/Sidebar.svelte b/Provider/frontend/src/lib/Sidebar.svelte index 07e63ee..840ac83 100644 --- a/Provider/frontend/src/lib/Sidebar.svelte +++ b/Provider/frontend/src/lib/Sidebar.svelte @@ -5,6 +5,7 @@ const navItems = [ { id: 'dashboard', label: 'Dashboard', icon: '🏠' }, { id: 'tasks', label: 'Tasks', icon: '📋' }, + { id: 'users', label: 'Users', icon: '👥' }, ]; diff --git a/Provider/frontend/src/lib/UserManager.svelte b/Provider/frontend/src/lib/UserManager.svelte index dabb036..82ab6fb 100644 --- a/Provider/frontend/src/lib/UserManager.svelte +++ b/Provider/frontend/src/lib/UserManager.svelte @@ -1,274 +1,436 @@ + +
+ {#if mode === 'selector'} +
+
+ {#each users as user} + +
selectedUserId = user.id} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }} + > + {user.name} +
+ {/each} +
+
+ {:else} + +
+

User Management

+ +
+ + {#if showAddForm} + + {/if} + +
+ {#each users as user} +
+ {#if editingUserId === user.id} +
{ e.preventDefault(); handleUpdateUser(); }}> + + +
+ + +
+
+ {:else} + + + {/if} +
+ {:else} +
No users found. Create one to get started!
+ {/each} +
+ {/if} + + {#if error} +
{error}
+ {/if} +
- let { selectedUserId = $bindable(null), onUsersChanged = () => {} } = $props(); - - let users = $state([]); - let newUserName = $state(''); - let showAddForm = $state(false); - let error = $state(''); - let confirmDeleteId = $state(null); - - async function fetchUsers() { - try { - users = await getUsers(); - if (users.length > 0 && !selectedUserId) { - selectedUserId = users[0].id; - } - // If selected user was deleted, select first available - if (selectedUserId && !users.find(u => u.id === selectedUserId)) { - selectedUserId = users.length > 0 ? users[0].id : null; - } - error = ''; - } catch (e) { - error = e.message; - } - } - - async function handleAddUser() { - if (!newUserName.trim()) return; - try { - const user = await addUser(newUserName.trim()); - newUserName = ''; - showAddForm = false; - await fetchUsers(); - selectedUserId = user.id; - onUsersChanged(); - } catch (e) { - error = e.message; - } - } - - async function handleRemoveUser(id) { - try { - await removeUser(id); - confirmDeleteId = null; - await fetchUsers(); - onUsersChanged(); - } catch (e) { - error = e.message; - } - } - - $effect(() => { - fetchUsers(); - }); - - -
-
-
- {#each users as user} - -
selectedUserId = user.id} - role="button" - tabindex="0" - onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }} - > - {user.name} - {#if confirmDeleteId === user.id} - - - - - {:else} - - {/if} -
- {/each} - - {#if showAddForm} -
{ e.preventDefault(); handleAddUser(); }}> - - - - -
- {:else} - - {/if} -
-
- - {#if error} -
{error}
- {/if} -
- - + diff --git a/Provider/frontend/src/lib/api.js b/Provider/frontend/src/lib/api.js index 82783a3..8d11e30 100644 --- a/Provider/frontend/src/lib/api.js +++ b/Provider/frontend/src/lib/api.js @@ -163,6 +163,25 @@ export async function removeUser(id) { return res.json(); } +/** + * Update a user's name. + * @param {number} id + * @param {string} name + * @returns {Promise<{status: string}>} + */ +export async function updateUser(id, name) { + const res = await fetch(`${API_BASE}/api/users/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name }) + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`); + } + return res.json(); +} + // ─── Task Management ───────────────────────────────────────────────────────── /** diff --git a/Provider/frontend/version.json b/Provider/frontend/version.json index daebab9..0325e63 100644 --- a/Provider/frontend/version.json +++ b/Provider/frontend/version.json @@ -1,5 +1,5 @@ { "major": 0, "minor": 1, - "revision": 4 + "revision": 13 } \ No newline at end of file diff --git a/Provider/main/api/tasks/store.cpp b/Provider/main/api/tasks/store.cpp index edd6f06..4aef8ab 100644 --- a/Provider/main/api/tasks/store.cpp +++ b/Provider/main/api/tasks/store.cpp @@ -6,7 +6,7 @@ #include "api/users/store.hpp" // Find a task by ID, returns nullptr if not found -internal task_t *find_task(uint16 id) +task_t *find_task(uint16 id) { for (int i = 0; i < MAX_TASKS; i++) { @@ -19,7 +19,7 @@ internal task_t *find_task(uint16 id) } // Add a task, returns pointer to new task or nullptr if full -internal task_t *add_task(uint8 user_id, const char *title, int64 due_date) +task_t *add_task(uint8 user_id, const char *title, int64 due_date) { // Verify user exists if (find_user(user_id) == nullptr) @@ -44,7 +44,7 @@ internal task_t *add_task(uint8 user_id, const char *title, int64 due_date) } // Remove a task by ID, returns true if found and removed -internal bool remove_task(uint16 id) +bool remove_task(uint16 id) { for (int i = 0; i < MAX_TASKS; i++) { @@ -59,7 +59,7 @@ internal bool remove_task(uint16 id) } // Remove all tasks belonging to a user -internal void remove_tasks_for_user(uint8 user_id) +void remove_tasks_for_user(uint8 user_id) { for (int i = 0; i < MAX_TASKS; i++) { @@ -73,7 +73,7 @@ internal void remove_tasks_for_user(uint8 user_id) // Simple insertion sort for small arrays — sort task pointers by due_date // ascending -internal void sort_tasks_by_due_date(task_t **arr, int count) +void sort_tasks_by_due_date(task_t **arr, int count) { for (int i = 1; i < count; i++) { @@ -90,7 +90,7 @@ internal void sort_tasks_by_due_date(task_t **arr, int count) // Populate dummy tasks on boot for development iteration. // Uses relative offsets from current time so due dates always make sense. -internal void seed_tasks() +void seed_tasks() { int64 now = (int64)(esp_timer_get_time() / 1000000); diff --git a/Provider/main/api/tasks/store.hpp b/Provider/main/api/tasks/store.hpp index a900661..d456c38 100644 --- a/Provider/main/api/tasks/store.hpp +++ b/Provider/main/api/tasks/store.hpp @@ -4,9 +4,9 @@ #include "types.hpp" // Data store operations for tasks -internal task_t *find_task(uint16 id); -internal task_t *add_task(uint8 user_id, const char *title, int64 due_date); -internal bool remove_task(uint16 id); -internal void remove_tasks_for_user(uint8 user_id); -internal void sort_tasks_by_due_date(task_t **arr, int count); -internal void seed_tasks(); +task_t *find_task(uint16 id); +task_t *add_task(uint8 user_id, const char *title, int64 due_date); +bool remove_task(uint16 id); +void remove_tasks_for_user(uint8 user_id); +void sort_tasks_by_due_date(task_t **arr, int count); +void seed_tasks(); diff --git a/Provider/main/api/users/store.cpp b/Provider/main/api/users/store.cpp index a49ce71..e8857fe 100644 --- a/Provider/main/api/users/store.cpp +++ b/Provider/main/api/users/store.cpp @@ -3,7 +3,7 @@ #include "api/users/store.hpp" // Find a user by ID, returns nullptr if not found -internal user_t *find_user(uint8 id) +user_t *find_user(uint8 id) { for (int i = 0; i < MAX_USERS; i++) { @@ -16,7 +16,7 @@ internal user_t *find_user(uint8 id) } // Add a user, returns pointer to new user or nullptr if full -internal user_t *add_user(const char *name) +user_t *add_user(const char *name) { for (int i = 0; i < MAX_USERS; i++) { @@ -32,7 +32,7 @@ internal user_t *add_user(const char *name) } // Remove a user by ID, returns true if found and removed -internal bool remove_user(uint8 id) +bool remove_user(uint8 id) { for (int i = 0; i < MAX_USERS; i++) { @@ -47,8 +47,19 @@ internal bool remove_user(uint8 id) return false; } +// Update a user's name, returns pointer to user or nullptr if not found +user_t *update_user(uint8 id, const char *name) +{ + user_t *user = find_user(id); + if (user) + { + strlcpy(user->name, name, sizeof(user->name)); + } + return user; +} + // Populate dummy users on boot for development iteration -internal void seed_users() +void seed_users() { add_user("Alice"); add_user("Bob"); diff --git a/Provider/main/api/users/store.hpp b/Provider/main/api/users/store.hpp index 63ce26f..90312b4 100644 --- a/Provider/main/api/users/store.hpp +++ b/Provider/main/api/users/store.hpp @@ -3,9 +3,9 @@ #include "types.hpp" #include "user.hpp" - // Data store operations for users -internal user_t *find_user(uint8 id); -internal user_t *add_user(const char *name); -internal bool remove_user(uint8 id); -internal void seed_users(); +user_t *find_user(uint8 id); +user_t *add_user(const char *name); +bool remove_user(uint8 id); +user_t *update_user(uint8 id, const char *name); +void seed_users(); diff --git a/Provider/main/api/users/unity.cpp b/Provider/main/api/users/unity.cpp index e1792ed..90c715c 100644 --- a/Provider/main/api/users/unity.cpp +++ b/Provider/main/api/users/unity.cpp @@ -3,4 +3,5 @@ #include "api/users/list.cpp" #include "api/users/add.cpp" #include "api/users/remove.cpp" +#include "api/users/update.cpp" // clang-format on diff --git a/Provider/main/api/users/update.cpp b/Provider/main/api/users/update.cpp new file mode 100644 index 0000000..19013c7 --- /dev/null +++ b/Provider/main/api/users/update.cpp @@ -0,0 +1,68 @@ +// POST /api/users/update — Update an existing user's name +// Body: {"id": 1, "name": "Bob"} + +#include "cJSON.h" +#include "esp_http_server.h" + +#include "api/users/store.hpp" +#include "types.hpp" +#include "user.hpp" + +internal esp_err_t api_users_update_handler(httpd_req_t *req) +{ + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + + char buf[128]; + int received = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (received <= 0) + { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body"); + return ESP_FAIL; + } + buf[received] = '\0'; + + cJSON *body = cJSON_Parse(buf); + if (!body) + { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); + return ESP_FAIL; + } + + cJSON *id_item = cJSON_GetObjectItem(body, "id"); + cJSON *name_item = cJSON_GetObjectItem(body, "name"); + + if (!cJSON_IsNumber(id_item) || !cJSON_IsString(name_item) || + strlen(name_item->valuestring) == 0) + { + cJSON_Delete(body); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'id' or 'name'"); + return ESP_FAIL; + } + + user_t *user = update_user((uint8)id_item->valueint, name_item->valuestring); + cJSON_Delete(body); + + if (!user) + { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "User not found"); + return ESP_FAIL; + } + + cJSON *resp = cJSON_CreateObject(); + cJSON_AddStringToObject(resp, "status", "ok"); + + const char *json = cJSON_PrintUnformatted(resp); + httpd_resp_sendstr(req, json); + + free((void *)json); + cJSON_Delete(resp); + + return ESP_OK; +} + +internal const httpd_uri_t api_users_update_uri = {.uri = "/api/users/update", + .method = HTTP_POST, + .handler = + api_users_update_handler, + .user_ctx = NULL}; diff --git a/Provider/main/appstate.hpp b/Provider/main/appstate.hpp index 5fb8584..a1b7609 100644 --- a/Provider/main/appstate.hpp +++ b/Provider/main/appstate.hpp @@ -2,7 +2,7 @@ #include "types.hpp" -// Shared Application State (Unity Build) -internal bool g_Ethernet_Initialized = false; -internal bool g_Wifi_Initialized = false; -internal uint8_t g_Active_WWW_Partition = 0; +// Shared Application State +extern bool g_Ethernet_Initialized; +extern bool g_Wifi_Initialized; +extern uint8_t g_Active_WWW_Partition; diff --git a/Provider/main/http_server.cpp b/Provider/main/http_server.cpp index ea98a0a..cca72c7 100644 --- a/Provider/main/http_server.cpp +++ b/Provider/main/http_server.cpp @@ -186,6 +186,7 @@ internal httpd_handle_t start_webserver(void) esp_vfs_littlefs_conf_t conf = {}; conf.base_path = "/www"; conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1"; + ESP_LOGI(TAG, "Mounting LittleFS partition: %s", conf.partition_label); conf.format_if_mount_failed = false; conf.dont_mount = false; esp_err_t ret = esp_vfs_littlefs_register(&conf); @@ -244,6 +245,7 @@ internal httpd_handle_t start_webserver(void) // Register todo list API routes httpd_register_uri_handler(server, &api_users_get_uri); httpd_register_uri_handler(server, &api_users_post_uri); + httpd_register_uri_handler(server, &api_users_update_uri); httpd_register_uri_handler(server, &api_users_delete_uri); httpd_register_uri_handler(server, &api_tasks_upcoming_uri); httpd_register_uri_handler(server, &api_tasks_get_uri); @@ -272,14 +274,13 @@ internal httpd_handle_t start_webserver(void) return NULL; } -internal void stop_webserver(httpd_handle_t server) +internal void stop_webserver(httpd_handle_t server, uint8_t partition_index) { if (server) { httpd_stop(server); #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES - esp_vfs_littlefs_unregister(g_Active_WWW_Partition == 0 ? "www_0" - : "www_1"); + esp_vfs_littlefs_unregister(partition_index == 0 ? "www_0" : "www_1"); #endif } } diff --git a/Provider/main/main.cpp b/Provider/main/main.cpp index d4e7c69..06f6afb 100644 --- a/Provider/main/main.cpp +++ b/Provider/main/main.cpp @@ -1,5 +1,6 @@ // STD Lib #include +#include // SDK #include "esp_log.h" @@ -23,7 +24,12 @@ #include "http_server.cpp" // clang-format on -internal constexpr bool kBlockUntilEthernetEstablished = false; +// Global Application State Definitions +bool g_Ethernet_Initialized = false; +bool g_Wifi_Initialized = false; +uint8_t g_Active_WWW_Partition = 0; + +constexpr bool kBlockUntilEthernetEstablished = false; extern "C" void app_main() { @@ -46,6 +52,10 @@ extern "C" void app_main() { // Read active www partition from NVS err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition); + if (err == ESP_OK) + { + printf("NVS: Found active www partition: %d\n", g_Active_WWW_Partition); + } if (err == ESP_ERR_NVS_NOT_FOUND) { @@ -74,6 +84,76 @@ extern "C" void app_main() printf("Error opening NVS handle!\n"); } + // Detect if this is the first boot after a new flash (Firmware or Frontend) + bool is_new_flash = false; + if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK) + { + // 1. Check Firmware Compile Time + char last_time[64] = {0}; + size_t time_size = sizeof(last_time); + const char *current_time = __DATE__ " " __TIME__; + if (nvs_get_str(my_handle, "last_fw_time", last_time, &time_size) != + ESP_OK || + strcmp(last_time, current_time) != 0) + { + printf("New firmware detected! (Last: %s, Current: %s)\n", + last_time[0] ? last_time : "None", current_time); + is_new_flash = true; + nvs_set_str(my_handle, "last_fw_time", current_time); + } + + // 2. Check Frontend Partition Fingerprint (www_0) + const esp_partition_t *www0_p = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, "www_0"); + if (www0_p) + { + uint8_t current_sha[32]; + if (esp_partition_get_sha256(www0_p, current_sha) == ESP_OK) + { + uint8_t last_sha[32] = {0}; + size_t sha_size = sizeof(last_sha); + if (nvs_get_blob(my_handle, "www0_sha", last_sha, &sha_size) != + ESP_OK || + memcmp(last_sha, current_sha, 32) != 0) + { + printf("New frontend partition detected via SHA256!\n"); + is_new_flash = true; + nvs_set_blob(my_handle, "www0_sha", current_sha, 32); + } + } + } + + if (is_new_flash) + { + nvs_commit(my_handle); + } + nvs_close(my_handle); + } + + // If we are running from FACTORY and a new flash was detected, override to + // www_0 + { + const esp_partition_t *running = esp_ota_get_running_partition(); + if (running != NULL && + running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY) + { + if (is_new_flash && g_Active_WWW_Partition != 0) + { + printf("FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)\n", + g_Active_WWW_Partition); + g_Active_WWW_Partition = 0; + + // Persist the override + if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK) + { + nvs_set_u8(my_handle, "www_part", 0); + nvs_commit(my_handle); + nvs_close(my_handle); + } + } + } + } + ESP_ERROR_CHECK(esp_event_loop_create_default()); setup_led(); @@ -168,6 +248,9 @@ extern "C" void app_main() printf("Connected!\n"); + // Start the webserver + web_server = start_webserver(); + // Mark the current app as valid to cancel rollback, only if it's an OTA app { const esp_partition_t *running = esp_ota_get_running_partition(); @@ -179,9 +262,6 @@ extern "C" void app_main() } } - // Start the webserver - web_server = start_webserver(); - // Keep the main task alive indefinitely while (true) { @@ -193,7 +273,7 @@ shutdown: if (web_server) { - stop_webserver(web_server); + stop_webserver(web_server, g_Active_WWW_Partition); web_server = NULL; }