Browse Source

Fix auth, registration, and frontend API integration bugs

Backend:
- First registered user gets admin role with system owner privileges
- Auto-create default workspace on registration for all users
- Fix thread-safety: use thread_local for auth context in httplib pool
- Add validateRefreshToken() to accept refresh tokens on /auth/refresh
- Settings access checks system admin role, not just workspace ownership
- Add cache-control headers for index.html to prevent stale bundles
- Add user role field and is_owner to User serialization

Frontend:
- Unwrap all API responses to match backend envelope format
- Fix refresh token flow to preserve existing token on refresh
- Refresh user data from /auth/me on protected route mount
- Fix workspace name display in header selector
- Fix member list to use nested user object from backend
- Update Workspace and WorkspaceMember types to match backend
- Fix settings page test mutation handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fszontagh 5 days ago
parent
commit
13fc7ce5d7

+ 2 - 2
lib/auth/include/smartbotic/microbit/auth/auth_middleware.hpp

@@ -37,9 +37,10 @@ public:
     [[nodiscard]] const AuthConfig& config() const { return config_; }
 
     bool authenticate(const httplib::Request& req, httplib::Response& res);
-    [[nodiscard]] std::optional<AuthContext> getAuthContext() const { return lastAuthContext_; }
+    [[nodiscard]] static std::optional<AuthContext> getAuthContext();
     [[nodiscard]] bool isPublicPath(const std::string& path) const;
     std::optional<AuthContext> validateToken(const std::string& token);
+    std::optional<AuthContext> validateRefreshToken(const std::string& token);
 
     std::string createAccessToken(const std::string& userId, const std::string& email, const std::string& role);
     std::string createRefreshToken(const std::string& userId, const std::string& sessionId);
@@ -49,7 +50,6 @@ public:
 
 private:
     AuthConfig config_;
-    std::optional<AuthContext> lastAuthContext_;
     std::unordered_set<std::string> publicPaths_;
 
     std::string extractToken(const httplib::Request& req) const;

+ 22 - 3
lib/auth/src/auth_middleware.cpp

@@ -20,15 +20,17 @@ AuthMiddleware::AuthMiddleware(const AuthConfig& config)
     }
 }
 
+static thread_local std::optional<AuthContext> tl_authContext;
+
 bool AuthMiddleware::authenticate(const httplib::Request& req, httplib::Response& res) {
-    lastAuthContext_.reset();
+    tl_authContext.reset();
 
     if (!config_.enabled) {
         AuthContext ctx;
         ctx.userId = "system";
         ctx.email = "system@localhost";
         ctx.role = "owner";
-        lastAuthContext_ = ctx;
+        tl_authContext = ctx;
         return true;
     }
 
@@ -53,10 +55,14 @@ bool AuthMiddleware::authenticate(const httplib::Request& req, httplib::Response
         return false;
     }
 
-    lastAuthContext_ = authCtx;
+    tl_authContext = authCtx;
     return true;
 }
 
+std::optional<AuthContext> AuthMiddleware::getAuthContext() {
+    return tl_authContext;
+}
+
 bool AuthMiddleware::isPublicPath(const std::string& path) const {
     return publicPaths_.count(path) > 0;
 }
@@ -76,6 +82,19 @@ std::optional<AuthContext> AuthMiddleware::validateToken(const std::string& toke
     return ctx;
 }
 
+std::optional<AuthContext> AuthMiddleware::validateRefreshToken(const std::string& token) {
+    auto payload = JWTUtils::validateToken(token, config_.jwtSecret);
+    if (!payload) return std::nullopt;
+
+    if (payload->type != JWTUtils::TOKEN_TYPE_REFRESH) {
+        return std::nullopt;
+    }
+
+    AuthContext ctx;
+    ctx.userId = payload->sub;
+    return ctx;
+}
+
 std::string AuthMiddleware::createAccessToken(
     const std::string& userId, const std::string& email, const std::string& role
 ) {

+ 7 - 0
src/app.cpp

@@ -168,6 +168,13 @@ void App::start() {
     // Serve static files
     bool serveStatic = ConfigLoader::get<bool>(config_, "http.static_files.enabled", true);
     if (serveStatic && std::filesystem::exists(staticFilesPath_)) {
+        // Disable caching for index.html so browser always gets the latest version
+        httpServer_->set_file_request_handler([](const httplib::Request& req, httplib::Response& res) {
+            if (req.path == "/" || req.path == "/index.html") {
+                res.set_header("Cache-Control", "no-cache, no-store, must-revalidate");
+            }
+        });
+
         httpServer_->set_mount_point("/", staticFilesPath_);
         spdlog::info("Serving static files from {}", staticFilesPath_);
 

+ 26 - 1
src/routes/auth_routes.cpp

@@ -1,6 +1,7 @@
 #include "auth_routes.hpp"
 #include "../app.hpp"
 #include "../stores/user_store.hpp"
+#include "../stores/workspace_store.hpp"
 #include "../services/invitation_service.hpp"
 
 #include <smartbotic/microbit/auth/auth_middleware.hpp>
@@ -42,25 +43,49 @@ void setupAuthRoutes(httplib::Server& svr, App& app) {
                 throw std::invalid_argument("Email already registered");
             }
 
+            // First user becomes system admin
+            bool isFirstUser = !app.userStore()->hasAnyUsers();
+
             // Create user
             User user;
             user.id = auth::BCryptUtils::generateId("usr");
             user.email = email;
             user.passwordHash = auth::BCryptUtils::hashPassword(password);
             user.displayName = displayName.empty() ? email : displayName;
+            user.role = isFirstUser ? "admin" : "user";
             user.status = "active";
 
             app.userStore()->createUser(user);
 
             // Process invitation if invite_token provided
+            bool hasInviteWorkspace = false;
             if (!inviteToken.empty()) {
                 try {
                     app.invitationService()->processRegistrationInvites(user.id, user.email);
+                    // Check if user now has any workspaces from the invite
+                    auto workspaces = app.workspaceStore()->listUserWorkspaces(user.id);
+                    hasInviteWorkspace = !workspaces.empty();
                 } catch (const std::exception& e) {
                     spdlog::warn("Failed to process registration invites: {}", e.what());
                 }
             }
 
+            // Create default workspace if user has no workspace from invites
+            if (!hasInviteWorkspace) {
+                Workspace ws;
+                ws.name = displayName.empty() ? "My Workspace" : displayName + "'s Workspace";
+                std::string wsId = app.workspaceStore()->createWorkspace(ws);
+
+                WorkspaceMember member;
+                member.workspaceId = wsId;
+                member.userId = user.id;
+                member.role = "owner";
+                member.invitedBy = user.id;
+                app.workspaceStore()->addMember(member);
+
+                spdlog::info("Created default workspace for user {}", user.id);
+            }
+
             // Create session
             Session session;
             session.id = auth::BCryptUtils::generateId("ses");
@@ -177,7 +202,7 @@ void setupAuthRoutes(httplib::Server& svr, App& app) {
             }
 
             // Validate the refresh token
-            auto payload = app.authMiddleware()->validateToken(refreshToken);
+            auto payload = app.authMiddleware()->validateRefreshToken(refreshToken);
             if (!payload) {
                 throw std::invalid_argument("Invalid or expired refresh token");
             }

+ 14 - 6
src/routes/settings_routes.cpp

@@ -2,6 +2,7 @@
 #include "../app.hpp"
 #include "../stores/settings_store.hpp"
 #include "../stores/workspace_store.hpp"
+#include "../stores/user_store.hpp"
 
 #include <smartbotic/microbit/auth/auth_middleware.hpp>
 #include <smartbotic/microbit/smtp/smtp_client.hpp>
@@ -18,8 +19,15 @@ namespace smartbotic::microbit {
 
 using json = nlohmann::json;
 
-// Helper: check if user is owner of any workspace
-static bool isOwnerOfAnyWorkspace(App& app, const std::string& userId) {
+// Helper: check if user is system admin or owner of any workspace
+static bool canAccessSettings(App& app, const std::string& userId) {
+    // System admins always have access
+    auto user = app.userStore()->getUser(userId);
+    if (user && user->role == "admin") {
+        return true;
+    }
+
+    // Workspace owners also have access
     auto workspaces = app.workspaceStore()->listUserWorkspaces(userId);
     for (const auto& ws : workspaces) {
         auto member = app.workspaceStore()->getMember(ws.id, userId);
@@ -65,7 +73,7 @@ void setupSettingsRoutes(httplib::Server& svr, App& app) {
                 return;
             }
 
-            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+            if (!canAccessSettings(app, authCtx->userId)) {
                 res.status = 403;
                 res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
                 return;
@@ -97,7 +105,7 @@ void setupSettingsRoutes(httplib::Server& svr, App& app) {
                 return;
             }
 
-            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+            if (!canAccessSettings(app, authCtx->userId)) {
                 res.status = 403;
                 res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
                 return;
@@ -143,7 +151,7 @@ void setupSettingsRoutes(httplib::Server& svr, App& app) {
                 return;
             }
 
-            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+            if (!canAccessSettings(app, authCtx->userId)) {
                 res.status = 403;
                 res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
                 return;
@@ -182,7 +190,7 @@ void setupSettingsRoutes(httplib::Server& svr, App& app) {
                 return;
             }
 
-            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+            if (!canAccessSettings(app, authCtx->userId)) {
                 res.status = 403;
                 res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
                 return;

+ 0 - 3
src/services/auth_service.cpp

@@ -35,9 +35,6 @@ AuthService::RegisterResult AuthService::registerUser(
         throw std::runtime_error("A user with this email already exists");
     }
 
-    // Check if this is the first user in the system
-    bool isFirstUser = !userStore_->hasAnyUsers();
-
     // Hash the password
     std::string passwordHash = auth::BCryptUtils::hashPassword(password);
 

+ 3 - 0
src/stores/user_store.cpp

@@ -15,6 +15,8 @@ json User::toJson() const {
         {"password_hash", passwordHash},
         {"display_name", displayName},
         {"avatar_url", avatarUrl},
+        {"role", role},
+        {"is_owner", role == "admin"},
         {"status", status},
         {"created_at", createdAt},
         {"updated_at", updatedAt}
@@ -28,6 +30,7 @@ User User::fromJson(const json& j) {
     u.passwordHash = j.value("password_hash", "");
     u.displayName = j.value("display_name", "");
     u.avatarUrl = j.value("avatar_url", "");
+    u.role = j.value("role", "user");
     u.status = j.value("status", "active");
     u.createdAt = j.value("created_at", uint64_t{0});
     u.updatedAt = j.value("updated_at", uint64_t{0});

+ 1 - 0
src/stores/user_store.hpp

@@ -18,6 +18,7 @@ struct User {
     std::string passwordHash;
     std::string displayName;
     std::string avatarUrl;
+    std::string role = "user";  // "admin" (system admin) or "user"
     std::string status = "active";
     uint64_t createdAt = 0;
     uint64_t updatedAt = 0;

+ 87 - 60
webui/src/api/client.ts

@@ -71,9 +71,9 @@ async function attemptRefresh(): Promise<boolean> {
         if (!res.ok) return false;
         const data = (await res.json()) as {
             access_token: string;
-            refresh_token: string;
         };
-        setTokens(data.access_token, data.refresh_token);
+        // Keep existing refresh token — backend only returns a new access token
+        setTokens(data.access_token, rt);
         return true;
     } catch {
         return false;
@@ -163,8 +163,9 @@ export const authApi = {
             body: JSON.stringify(data),
         });
     },
-    me() {
-        return request<User>("/auth/me");
+    async me() {
+        const res = await request<{ user: User }>("/auth/me");
+        return res.user;
     },
     refresh(refreshToken: string) {
         return request<{ access_token: string; refresh_token: string }>(
@@ -180,23 +181,27 @@ export const authApi = {
 // ── Workspace API ────────────────────────────────────────────────────────────
 
 export const workspaceApi = {
-    list() {
-        return request<Workspace[]>("/workspaces");
+    async list() {
+        const res = await request<{ workspaces: Workspace[] }>("/workspaces");
+        return res.workspaces;
     },
-    get(id: string) {
-        return request<Workspace>(`/workspaces/${id}`);
+    async get(id: string) {
+        const res = await request<{ workspace: Workspace }>(`/workspaces/${id}`);
+        return res.workspace;
     },
-    create(data: Partial<Workspace>) {
-        return request<Workspace>("/workspaces", {
+    async create(data: Partial<Workspace>) {
+        const res = await request<{ workspace: Workspace }>("/workspaces", {
             method: "POST",
             body: JSON.stringify(data),
         });
+        return res.workspace;
     },
-    update(id: string, data: Partial<Workspace>) {
-        return request<Workspace>(`/workspaces/${id}`, {
+    async update(id: string, data: Partial<Workspace>) {
+        const res = await request<{ workspace: Workspace }>(`/workspaces/${id}`, {
             method: "PUT",
             body: JSON.stringify(data),
         });
+        return res.workspace;
     },
     delete(id: string) {
         return request<void>(`/workspaces/${id}`, { method: "DELETE" });
@@ -206,10 +211,11 @@ export const workspaceApi = {
 // ── Member API ───────────────────────────────────────────────────────────────
 
 export const memberApi = {
-    list(workspaceId: string) {
-        return request<WorkspaceMember[]>(
+    async list(workspaceId: string) {
+        const res = await request<{ members: WorkspaceMember[] }>(
             `/workspaces/${workspaceId}/members`,
         );
+        return res.members;
     },
     updateRole(
         workspaceId: string,
@@ -235,22 +241,24 @@ export const memberApi = {
 // ── Invitation API ───────────────────────────────────────────────────────────
 
 export const invitationApi = {
-    list(workspaceId: string) {
-        return request<Invitation[]>(
+    async list(workspaceId: string) {
+        const res = await request<{ invitations: Invitation[] }>(
             `/workspaces/${workspaceId}/invitations`,
         );
+        return res.invitations;
     },
-    create(
+    async create(
         workspaceId: string,
         data: { email: string; role: string },
     ) {
-        return request<Invitation>(
+        const res = await request<{ invitation: Invitation }>(
             `/workspaces/${workspaceId}/invitations`,
             {
                 method: "POST",
                 body: JSON.stringify(data),
             },
         );
+        return res.invitation;
     },
     revoke(workspaceId: string, invitationId: string) {
         return request<void>(
@@ -258,8 +266,9 @@ export const invitationApi = {
             { method: "DELETE" },
         );
     },
-    pending() {
-        return request<Invitation[]>("/invitations/pending");
+    async pending() {
+        const res = await request<{ invitations: Invitation[] }>("/invitations/pending");
+        return res.invitations;
     },
     accept(invitationId: string) {
         return request<void>(`/invitations/${invitationId}/accept`, {
@@ -276,40 +285,44 @@ export const invitationApi = {
 // ── Assistant API ────────────────────────────────────────────────────────────
 
 export const assistantApi = {
-    list(workspaceId: string) {
-        return request<Assistant[]>(
+    async list(workspaceId: string) {
+        const res = await request<{ assistants: Assistant[] }>(
             `/workspaces/${workspaceId}/assistants`,
         );
+        return res.assistants;
     },
-    get(workspaceId: string, assistantId: string) {
-        return request<Assistant>(
+    async get(workspaceId: string, assistantId: string) {
+        const res = await request<{ assistant: Assistant }>(
             `/workspaces/${workspaceId}/assistants/${assistantId}`,
         );
+        return res.assistant;
     },
-    create(
+    async create(
         workspaceId: string,
         data: { name: string; phone_number_id: string },
     ) {
-        return request<Assistant>(
+        const res = await request<{ assistant: Assistant }>(
             `/workspaces/${workspaceId}/assistants`,
             {
                 method: "POST",
                 body: JSON.stringify(data),
             },
         );
+        return res.assistant;
     },
-    update(
+    async update(
         workspaceId: string,
         assistantId: string,
         data: Partial<Assistant>,
     ) {
-        return request<Assistant>(
+        const res = await request<{ assistant: Assistant }>(
             `/workspaces/${workspaceId}/assistants/${assistantId}`,
             {
                 method: "PUT",
                 body: JSON.stringify(data),
             },
         );
+        return res.assistant;
     },
     delete(workspaceId: string, assistantId: string) {
         return request<void>(
@@ -322,48 +335,54 @@ export const assistantApi = {
 // ── CallerAI API (phone numbers / voices) ────────────────────────────────────
 
 export const calleraiApi = {
-    phoneNumbers() {
-        return request<PhoneNumber[]>("/callerai/phone-numbers");
+    async phoneNumbers() {
+        const res = await request<{ phone_numbers: PhoneNumber[] }>("/callerai/phone-numbers");
+        return res.phone_numbers;
     },
-    voiceProviders() {
-        return request<ProviderConfig[]>("/callerai/voice-providers");
+    async voiceProviders() {
+        const res = await request<{ provider_configs: ProviderConfig[] }>("/callerai/provider-configs");
+        return res.provider_configs;
     },
 };
 
 // ── Calendar API ─────────────────────────────────────────────────────────────
 
 export const calendarApi = {
-    list(workspaceId: string) {
-        return request<Calendar[]>(
+    async list(workspaceId: string) {
+        const res = await request<{ calendars: Calendar[] }>(
             `/workspaces/${workspaceId}/calendars`,
         );
+        return res.calendars;
     },
-    get(workspaceId: string, calendarId: string) {
-        return request<Calendar>(
+    async get(workspaceId: string, calendarId: string) {
+        const res = await request<{ calendar: Calendar }>(
             `/workspaces/${workspaceId}/calendars/${calendarId}`,
         );
+        return res.calendar;
     },
-    create(workspaceId: string, data: Partial<Calendar>) {
-        return request<Calendar>(
+    async create(workspaceId: string, data: Partial<Calendar>) {
+        const res = await request<{ calendar: Calendar }>(
             `/workspaces/${workspaceId}/calendars`,
             {
                 method: "POST",
                 body: JSON.stringify(data),
             },
         );
+        return res.calendar;
     },
-    update(
+    async update(
         workspaceId: string,
         calendarId: string,
         data: Partial<Calendar>,
     ) {
-        return request<Calendar>(
+        const res = await request<{ calendar: Calendar }>(
             `/workspaces/${workspaceId}/calendars/${calendarId}`,
             {
                 method: "PUT",
                 body: JSON.stringify(data),
             },
         );
+        return res.calendar;
     },
     delete(workspaceId: string, calendarId: string) {
         return request<void>(
@@ -376,37 +395,40 @@ export const calendarApi = {
 // ── TimeSlot API ─────────────────────────────────────────────────────────────
 
 export const timeSlotApi = {
-    list(workspaceId: string, calendarId: string) {
-        return request<TimeSlot[]>(
+    async list(workspaceId: string, calendarId: string) {
+        const res = await request<{ time_slots: TimeSlot[] }>(
             `/workspaces/${workspaceId}/calendars/${calendarId}/time-slots`,
         );
+        return res.time_slots;
     },
-    create(
+    async create(
         workspaceId: string,
         calendarId: string,
         data: Partial<TimeSlot>,
     ) {
-        return request<TimeSlot>(
+        const res = await request<{ time_slot: TimeSlot }>(
             `/workspaces/${workspaceId}/calendars/${calendarId}/time-slots`,
             {
                 method: "POST",
                 body: JSON.stringify(data),
             },
         );
+        return res.time_slot;
     },
-    update(
+    async update(
         workspaceId: string,
         calendarId: string,
         slotId: string,
         data: Partial<TimeSlot>,
     ) {
-        return request<TimeSlot>(
+        const res = await request<{ time_slot: TimeSlot }>(
             `/workspaces/${workspaceId}/calendars/${calendarId}/time-slots/${slotId}`,
             {
                 method: "PUT",
                 body: JSON.stringify(data),
             },
         );
+        return res.time_slot;
     },
     delete(
         workspaceId: string,
@@ -423,7 +445,7 @@ export const timeSlotApi = {
 // ── Appointment API ──────────────────────────────────────────────────────────
 
 export const appointmentApi = {
-    list(
+    async list(
         workspaceId: string,
         params?: { from?: string; to?: string; status?: string },
     ) {
@@ -432,45 +454,50 @@ export const appointmentApi = {
         if (params?.to) qs.set("to", params.to);
         if (params?.status) qs.set("status", params.status);
         const query = qs.toString() ? `?${qs.toString()}` : "";
-        return request<Appointment[]>(
+        const res = await request<{ appointments: Appointment[] }>(
             `/workspaces/${workspaceId}/appointments${query}`,
         );
+        return res.appointments;
     },
-    cancel(workspaceId: string, appointmentId: string) {
-        return request<Appointment>(
+    async cancel(workspaceId: string, appointmentId: string) {
+        const res = await request<{ appointment: Appointment }>(
             `/workspaces/${workspaceId}/appointments/${appointmentId}/cancel`,
             { method: "POST" },
         );
+        return res.appointment;
     },
 };
 
 // ── Settings API ─────────────────────────────────────────────────────────────
 
 export const settingsApi = {
-    get() {
-        return request<GlobalSettings>("/settings");
+    async get() {
+        const res = await request<{ settings: GlobalSettings }>("/settings");
+        return res.settings;
     },
-    updateSmtp(data: SmtpSettings) {
-        return request<SmtpSettings>("/settings/smtp", {
+    async updateSmtp(data: SmtpSettings) {
+        const res = await request<{ settings: GlobalSettings }>("/settings", {
             method: "PUT",
             body: JSON.stringify(data),
         });
+        return res.settings;
     },
     testSmtp() {
-        return request<{ success: boolean; message: string }>(
-            "/settings/smtp/test",
+        return request<{ message: string }>(
+            "/settings/test-smtp",
             { method: "POST" },
         );
     },
-    updateCallerAi(data: CallerAiSettings) {
-        return request<CallerAiSettings>("/settings/callerai", {
+    async updateCallerAi(data: CallerAiSettings) {
+        const res = await request<{ settings: GlobalSettings }>("/settings", {
             method: "PUT",
             body: JSON.stringify(data),
         });
+        return res.settings;
     },
     testCallerAi() {
-        return request<{ success: boolean; message: string }>(
-            "/settings/callerai/test",
+        return request<{ message: string }>(
+            "/settings/test-callerai",
             { method: "POST" },
         );
     },

+ 10 - 0
webui/src/components/auth/ProtectedRoute.tsx

@@ -1,8 +1,18 @@
+import { useEffect } from "react";
 import { Navigate, Outlet } from "react-router-dom";
 import { useAuthStore } from "@/stores/authStore";
+import { authApi } from "@/api/client";
 
 export function ProtectedRoute() {
     const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+    const setUser = useAuthStore((s) => s.setUser);
+
+    // Refresh user data from server on mount to ensure is_owner etc. are current
+    useEffect(() => {
+        if (isAuthenticated) {
+            authApi.me().then(setUser).catch(() => {});
+        }
+    }, [isAuthenticated, setUser]);
 
     if (!isAuthenticated) {
         return <Navigate to="/login" replace />;

+ 2 - 2
webui/src/components/layout/Header.tsx

@@ -51,7 +51,7 @@ export function Header() {
                     onClick={() => setWsOpen((o) => !o)}
                     className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
                 >
-                    {activeWorkspace?.company_name ?? "Select workspace"}
+                    {activeWorkspace?.name || activeWorkspace?.company_name || "Select workspace"}
                     <ChevronDown className="h-4 w-4 text-gray-400" />
                 </button>
 
@@ -67,7 +67,7 @@ export function Header() {
                                         : "text-gray-700 hover:bg-gray-50"
                                 }`}
                             >
-                                {ws.company_name}
+                                {ws.name || ws.company_name}
                             </button>
                         ))}
                     </div>

+ 2 - 2
webui/src/pages/members/MembersList.tsx

@@ -122,10 +122,10 @@ export function MembersList() {
                                         className="hover:bg-gray-50 transition-colors"
                                     >
                                         <td className="px-6 py-4 font-medium text-gray-900">
-                                            {member.display_name}
+                                            {member.user?.display_name ?? "—"}
                                         </td>
                                         <td className="px-6 py-4 text-gray-500">
-                                            {member.email}
+                                            {member.user?.email ?? "—"}
                                         </td>
                                         <td className="px-6 py-4">
                                             {member.role === "owner" ? (

+ 6 - 16
webui/src/pages/settings/GlobalSettings.tsx

@@ -60,13 +60,8 @@ export function GlobalSettings() {
     const smtpTestMutation = useMutation({
         mutationFn: () => settingsApi.testSmtp(),
         onSuccess: (result) => {
-            if (result.success) {
-                setSmtpSuccess(result.message || "SMTP test successful!");
-                setSmtpError("");
-            } else {
-                setSmtpError(result.message || "SMTP test failed.");
-                setSmtpSuccess("");
-            }
+            setSmtpSuccess(result.message || "SMTP test successful!");
+            setSmtpError("");
             setTimeout(() => {
                 setSmtpSuccess("");
                 setSmtpError("");
@@ -95,15 +90,10 @@ export function GlobalSettings() {
     const calleraiTestMutation = useMutation({
         mutationFn: () => settingsApi.testCallerAi(),
         onSuccess: (result) => {
-            if (result.success) {
-                setCalleraiSuccess(
-                    result.message || "CallerAI test successful!",
-                );
-                setCalleraiError("");
-            } else {
-                setCalleraiError(result.message || "CallerAI test failed.");
-                setCalleraiSuccess("");
-            }
+            setCalleraiSuccess(
+                result.message || "CallerAI test successful!",
+            );
+            setCalleraiError("");
             setTimeout(() => {
                 setCalleraiSuccess("");
                 setCalleraiError("");

+ 1 - 1
webui/src/stores/authStore.ts

@@ -61,7 +61,7 @@ export const useAuthStore = create<AuthStore>()(
                     const res = await authApi.refresh(rt);
                     set({
                         accessToken: res.access_token,
-                        refreshToken: res.refresh_token,
+                        // Keep existing refresh token — backend only returns a new access token
                     });
                     return true;
                 } catch {

+ 14 - 7
webui/src/types/index.ts

@@ -33,22 +33,29 @@ export interface AuthState {
 
 export interface Workspace {
     id: string;
+    name: string;
     company_name: string;
     address: string;
     phone: string;
-    owner_id: string;
-    created_at: string;
-    updated_at: string;
+    settings: Record<string, unknown>;
+    created_at: number;
+    updated_at: number;
 }
 
 export interface WorkspaceMember {
     id: string;
     user_id: string;
     workspace_id: string;
-    role: "owner" | "admin" | "member";
-    email: string;
-    display_name: string;
-    created_at: string;
+    role: "owner" | "admin" | "member" | "viewer";
+    invited_by: string;
+    joined_at: number;
+    user?: {
+        id: string;
+        email: string;
+        display_name: string;
+        role: string;
+        is_owner: boolean;
+    };
 }
 
 export interface Invitation {