Selaa lähdekoodia

feat: implement comprehensive RBAC system with webserver services and React WebUI

RBAC System:
- Add permissions.hpp with permission constants (system, workspace, collection, field scopes)
- Add authorization_service for permission checking with wildcard support and caching
- Update Group model with is_system and parent_group_id for inheritance
- Add document ownership tracking (_created_by, _updated_by)
- Add field-level permissions to CollectionSettings

Webserver Services:
- Add auth_service for JWT authentication
- Add user_service, workspace_service, group_service, membership_service
- Add collection_service, document_service, view_service, api_key_service
- Add ws_handler for WebSocket real-time events
- Integrate AuthorizationService into HttpServer

React WebUI:
- Add complete SPA with React, TypeScript, Vite, TailwindCSS
- Add authentication flow with JWT token management
- Add workspace selector and dashboard layout
- Add pages: Users, Workspaces, Groups, Collections, Documents, Views, API Keys
- Add PermissionEditor component for group permission management
- Add FieldPermissionEditor component for collection field-level permissions
- Add WebSocket context for real-time notifications
Fszontagh 6 kuukautta sitten
vanhempi
sitoutus
6925a4e41e
71 muutettua tiedostoa jossa 17330 lisäystä ja 5 poistoa
  1. 14 0
      .gitignore
  2. 336 0
      README.md
  3. 2 0
      webserver/CMakeLists.txt
  4. 144 0
      webserver/include/smartbotic/webserver/api_key_service.hpp
  5. 111 0
      webserver/include/smartbotic/webserver/auth_service.hpp
  6. 278 0
      webserver/include/smartbotic/webserver/authorization_service.hpp
  7. 150 0
      webserver/include/smartbotic/webserver/collection_service.hpp
  8. 141 0
      webserver/include/smartbotic/webserver/document_service.hpp
  9. 178 0
      webserver/include/smartbotic/webserver/group_service.hpp
  10. 7 2
      webserver/include/smartbotic/webserver/http_server.hpp
  11. 134 0
      webserver/include/smartbotic/webserver/membership_service.hpp
  12. 171 0
      webserver/include/smartbotic/webserver/permissions.hpp
  13. 131 0
      webserver/include/smartbotic/webserver/user_service.hpp
  14. 173 0
      webserver/include/smartbotic/webserver/view_service.hpp
  15. 130 0
      webserver/include/smartbotic/webserver/workspace_service.hpp
  16. 154 0
      webserver/include/smartbotic/webserver/ws_handler.hpp
  17. 507 0
      webserver/src/api_key_service.cpp
  18. 292 0
      webserver/src/auth_service.cpp
  19. 483 0
      webserver/src/authorization_service.cpp
  20. 485 0
      webserver/src/collection_service.cpp
  21. 340 0
      webserver/src/document_service.cpp
  22. 717 0
      webserver/src/group_service.cpp
  23. 18 3
      webserver/src/http_server.cpp
  24. 504 0
      webserver/src/membership_service.cpp
  25. 194 0
      webserver/src/permissions.cpp
  26. 413 0
      webserver/src/user_service.cpp
  27. 572 0
      webserver/src/view_service.cpp
  28. 409 0
      webserver/src/workspace_service.cpp
  29. 295 0
      webserver/src/ws_handler.cpp
  30. 26 0
      webui/.gitignore
  31. 8 0
      webui/.prettierrc
  32. 31 0
      webui/eslint.config.js
  33. 13 0
      webui/index.html
  34. 40 0
      webui/package.json
  35. 2646 0
      webui/pnpm-lock.yaml
  36. 6 0
      webui/postcss.config.js
  37. 10 0
      webui/public/favicon.svg
  38. 74 0
      webui/src/App.tsx
  39. 32 0
      webui/src/api/auth.ts
  40. 67 0
      webui/src/api/client.ts
  41. 65 0
      webui/src/components/Button.tsx
  42. 311 0
      webui/src/components/FieldPermissionEditor.tsx
  43. 35 0
      webui/src/components/Input.tsx
  44. 206 0
      webui/src/components/NotificationBell.tsx
  45. 322 0
      webui/src/components/PermissionEditor.tsx
  46. 52 0
      webui/src/components/ProtectedRoute.tsx
  47. 196 0
      webui/src/components/Toast.tsx
  48. 30 0
      webui/src/components/layout/DashboardLayout.tsx
  49. 229 0
      webui/src/components/layout/Sidebar.tsx
  50. 106 0
      webui/src/components/layout/TopBar.tsx
  51. 5 0
      webui/src/components/layout/index.ts
  52. 214 0
      webui/src/contexts/AuthContext.tsx
  53. 291 0
      webui/src/contexts/WebSocketContext.tsx
  54. 103 0
      webui/src/contexts/WorkspaceContext.tsx
  55. 17 0
      webui/src/index.css
  56. 25 0
      webui/src/main.tsx
  57. 479 0
      webui/src/pages/ApiKeys.tsx
  58. 611 0
      webui/src/pages/Collections.tsx
  59. 44 0
      webui/src/pages/Dashboard.tsx
  60. 1000 0
      webui/src/pages/Documents.tsx
  61. 483 0
      webui/src/pages/Groups.tsx
  62. 119 0
      webui/src/pages/Login.tsx
  63. 378 0
      webui/src/pages/Users.tsx
  64. 938 0
      webui/src/pages/Views.tsx
  65. 332 0
      webui/src/pages/Workspaces.tsx
  66. 189 0
      webui/src/types/index.ts
  67. 1 0
      webui/src/vite-env.d.ts
  68. 27 0
      webui/tailwind.config.js
  69. 32 0
      webui/tsconfig.json
  70. 24 0
      webui/tsconfig.node.json
  71. 30 0
      webui/vite.config.ts

+ 14 - 0
.gitignore

@@ -38,3 +38,17 @@ build
 # OS files
 .DS_Store
 Thumbs.db
+
+# Runtime and local config
+.cache/
+.ralph-tui/
+config/
+data/
+tasks/
+scripts/
+smartbotic-server.json
+*.local.json
+
+# Node modules
+node_modules/
+webui/dist/

+ 336 - 0
README.md

@@ -0,0 +1,336 @@
+# SmartBotic CRM
+
+A flexible, schema-driven CRM foundation built with C++20 and React.
+
+## Overview
+
+SmartBotic CRM consists of three main components:
+
+1. **Database Node**: An in-memory document database with gRPC interface, supporting collections, JSON documents with metadata, relations, encryption, TTL, and persistent snapshots.
+
+2. **Web Server Node**: A REST API server with WebSocket support that communicates with the database via gRPC, handling authentication (JWT + API keys), workspace/user management, and real-time updates.
+
+3. **Web UI**: A React + TypeScript + Vite + TailwindCSS admin dashboard for managing workspaces, users, collections, and schema-defined views.
+
+## Features
+
+- Multi-tenant workspace support
+- JWT and API key authentication
+- Schema-driven dynamic forms and views
+- Real-time updates via WebSocket
+- Field-level encryption support
+- Persistent snapshots for data durability
+- Role-based access control
+
+## Requirements
+
+- GCC 11+ or Clang 14+ (C++20 support)
+- CMake 3.20+
+- Ninja build system
+- Node.js 18+ with pnpm
+- libwebsockets development package
+
+### Installing Dependencies (Ubuntu/Debian)
+
+```bash
+# Build tools
+sudo apt install cmake ninja-build build-essential pkg-config
+
+# libwebsockets
+sudo apt install libwebsockets-dev
+
+# Node.js (via nvm recommended)
+curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
+nvm install 18
+npm install -g pnpm
+```
+
+## Quick Start
+
+### 1. Setup and Build
+
+```bash
+# Clone the repository
+git clone https://github.com/smartbotic/smartbotic-crm.git
+cd smartbotic-crm
+
+# Run setup script
+./scripts/setup.sh
+```
+
+### 2. Start Services
+
+Open three terminal windows:
+
+**Terminal 1 - Database:**
+```bash
+./scripts/run-db.sh
+```
+
+**Terminal 2 - Webserver:**
+```bash
+./scripts/run-server.sh
+```
+
+**Terminal 3 - WebUI Development:**
+```bash
+cd webui
+pnpm dev
+```
+
+### 3. Bootstrap the System
+
+On first run, create the admin user:
+
+```bash
+curl -X POST http://localhost:18080/api/bootstrap \
+  -H "Content-Type: application/json" \
+  -d '{"email": "admin@example.com", "password": "your-password", "name": "Admin"}'
+```
+
+### 4. Access the Application
+
+- **WebUI**: http://localhost:3200
+- **API**: http://localhost:18080
+- **WebSocket**: ws://localhost:18081/ws
+
+## Development with Systemd User Services
+
+For persistent local testing, use systemd user services instead of terminal scripts.
+
+### Setup User Services
+
+1. Create the config directory:
+```bash
+mkdir -p ~/.config/systemd/user ~/.config/smartbotic-crm ~/.local/share/smartbotic-crm
+```
+
+2. Copy and customize the service files:
+```bash
+# Database service
+cat > ~/.config/systemd/user/smartbotic-crm-database.service << 'EOF'
+[Unit]
+Description=SmartBotic CRM Database (Development)
+
+[Service]
+Type=simple
+WorkingDirectory=/data/smartbotic-crm
+ExecStart=/data/smartbotic-crm/build/database/smartbotic-crm-database --data-dir %h/.local/share/smartbotic-crm
+Restart=on-failure
+Environment="SMARTBOTIC_DB_PORT=50151"
+
+[Install]
+WantedBy=default.target
+EOF
+
+# Webserver service
+cat > ~/.config/systemd/user/smartbotic-crm-webserver.service << 'EOF'
+[Unit]
+Description=SmartBotic CRM Webserver (Development)
+After=smartbotic-crm-database.service
+Requires=smartbotic-crm-database.service
+
+[Service]
+Type=simple
+WorkingDirectory=/data/smartbotic-crm
+ExecStart=/data/smartbotic-crm/build/webserver/smartbotic-crm-webserver
+EnvironmentFile=%h/.config/smartbotic-crm/server.env
+Restart=on-failure
+
+[Install]
+WantedBy=default.target
+EOF
+```
+
+3. Create the config file:
+```bash
+cat > ~/.config/smartbotic-crm/server.env << 'EOF'
+SMARTBOTIC_SERVER_JWT_SECRET=dev-testing-secret-change-in-production
+SMARTBOTIC_SERVER_STATIC_PATH=/data/smartbotic-crm/webui/dist
+EOF
+```
+
+4. Enable and start services:
+```bash
+systemctl --user daemon-reload
+systemctl --user enable smartbotic-crm-database smartbotic-crm-webserver
+systemctl --user start smartbotic-crm-database smartbotic-crm-webserver
+```
+
+5. Check status:
+```bash
+systemctl --user status smartbotic-crm-database smartbotic-crm-webserver
+```
+
+6. View logs:
+```bash
+journalctl --user -u smartbotic-crm-database -f
+journalctl --user -u smartbotic-crm-webserver -f
+```
+
+### Data Locations (User Services)
+
+- **Config**: `~/.config/smartbotic-crm/`
+- **Data**: `~/.local/share/smartbotic-crm/`
+- **Logs**: `journalctl --user`
+
+## Project Structure
+
+```
+smartbotic-crm/
+├── database/           # Database node (C++)
+│   ├── include/        # Header files
+│   └── src/            # Source files
+├── webserver/          # Webserver node (C++)
+│   ├── include/        # Header files
+│   └── src/            # Source files
+├── webui/              # Web UI (React)
+│   ├── src/            # React components
+│   └── dist/           # Built static files
+├── proto/              # Protocol Buffer definitions
+├── common/             # Shared utilities
+├── scripts/            # Utility scripts
+├── systemd/            # Systemd service files
+├── build/              # CMake build output
+└── data/               # Runtime data directory
+```
+
+## Configuration
+
+### Database Node
+
+Environment variables:
+- `SMARTBOTIC_DB_PORT` - gRPC port (default: 50151)
+- `SMARTBOTIC_DB_DATA_DIR` - Data directory path
+
+### Webserver Node
+
+Environment variables:
+- `SMARTBOTIC_SERVER_HTTP_PORT` - HTTP port (default: 18080)
+- `SMARTBOTIC_SERVER_WS_PORT` - WebSocket port (default: 18081)
+- `SMARTBOTIC_SERVER_DATABASE_HOST` - Database host (default: localhost)
+- `SMARTBOTIC_SERVER_DATABASE_PORT` - Database port (default: 50151)
+- `SMARTBOTIC_SERVER_JWT_SECRET` - JWT signing secret
+- `SMARTBOTIC_SERVER_STATIC_PATH` - Static files directory
+
+## API Endpoints
+
+### Authentication
+- `POST /api/auth/login` - Login with email/password
+- `POST /api/auth/refresh` - Refresh access token
+- `POST /api/auth/logout` - Logout
+
+### Users
+- `POST /api/users` - Create user (superadmin)
+- `GET /api/users` - List users
+- `GET /api/users/:id` - Get user
+- `PATCH /api/users/:id` - Update user
+- `DELETE /api/users/:id` - Delete user
+
+### Workspaces
+- `POST /api/workspaces` - Create workspace
+- `GET /api/workspaces` - List workspaces
+- `GET /api/workspaces/:id` - Get workspace
+- `PATCH /api/workspaces/:id` - Update workspace
+- `DELETE /api/workspaces/:id` - Delete workspace
+
+### Collections
+- `POST /api/workspaces/:wid/collections` - Create collection
+- `GET /api/workspaces/:wid/collections` - List collections
+- `GET /api/workspaces/:wid/collections/:name` - Get collection
+- `PUT /api/workspaces/:wid/collections/:name` - Update collection
+- `DELETE /api/workspaces/:wid/collections/:name` - Delete collection
+
+### Documents
+- `POST /api/workspaces/:wid/collections/:name/documents` - Create document
+- `GET /api/workspaces/:wid/collections/:name/documents` - List documents
+- `GET /api/workspaces/:wid/collections/:name/documents/:id` - Get document
+- `PUT /api/workspaces/:wid/collections/:name/documents/:id` - Update document
+- `DELETE /api/workspaces/:wid/collections/:name/documents/:id` - Delete document
+
+### Views
+- `POST /api/workspaces/:wid/views` - Create view
+- `GET /api/workspaces/:wid/views` - List views
+- `GET /api/workspaces/:wid/views/:id` - Get view
+- `PATCH /api/workspaces/:wid/views/:id` - Update view
+- `DELETE /api/workspaces/:wid/views/:id` - Delete view
+
+## WebSocket Protocol
+
+Connect to `ws://localhost:18081/ws` and authenticate:
+
+```json
+{ "type": "auth", "token": "your-jwt-token" }
+```
+
+Subscribe to collection changes:
+
+```json
+{ "type": "subscribe", "workspace_id": "...", "collection": "name" }
+```
+
+Receive document events:
+
+```json
+{
+  "type": "document",
+  "action": "create|update|delete",
+  "workspace_id": "...",
+  "collection": "...",
+  "document_id": "...",
+  "data": { ... }
+}
+```
+
+## Development
+
+### Building
+
+```bash
+# Configure
+cmake --preset release
+
+# Build
+cmake --build build -j$(nproc)
+```
+
+### Code Quality
+
+```bash
+# C++ formatting
+clang-format -i database/src/*.cpp webserver/src/*.cpp
+
+# C++ linting
+clang-tidy database/src/*.cpp webserver/src/*.cpp
+
+# WebUI
+cd webui
+pnpm typecheck
+pnpm lint
+```
+
+## Production Deployment
+
+### Using Systemd
+
+Copy the service files and customize:
+
+```bash
+sudo cp systemd/*.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable smartbotic-database smartbotic-webserver
+sudo systemctl start smartbotic-database smartbotic-webserver
+```
+
+### Environment Variables
+
+Create `/etc/smartbotic/server.env`:
+
+```bash
+SMARTBOTIC_SERVER_JWT_SECRET=your-secure-secret-key
+SMARTBOTIC_SERVER_STATIC_PATH=/opt/smartbotic-crm/webui/dist
+```
+
+## License
+
+Proprietary - All rights reserved.

+ 2 - 0
webserver/CMakeLists.txt

@@ -26,6 +26,8 @@ add_library(smartbotic_webserver STATIC
     src/document_service.cpp
     src/ws_handler.cpp
     src/view_service.cpp
+    src/permissions.cpp
+    src/authorization_service.cpp
 )
 
 target_include_directories(smartbotic_webserver

+ 144 - 0
webserver/include/smartbotic/webserver/api_key_service.hpp

@@ -0,0 +1,144 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// API Key data structure (stored version - key is hashed)
+struct ApiKey {
+    std::string id;
+    std::string user_id;
+    std::string name;           // User-defined name for the key
+    std::string key_prefix;     // First 8 chars for identification (e.g., "sk_abc12...")
+    std::string key_hash;       // SHA256 hash of the full key
+    std::vector<std::string> permissions;  // Permissions granted to this key
+    std::string created_at;
+    std::string updated_at;
+    std::string deleted_at;     // Empty if not revoked (soft delete)
+    std::string last_used_at;   // Last time the key was used
+    std::string expires_at;     // Optional expiration time
+
+    /// Check if key is revoked (soft delete)
+    [[nodiscard]] auto IsRevoked() const -> bool { return !deleted_at.empty(); }
+
+    /// Check if key is expired
+    [[nodiscard]] auto IsExpired() const -> bool;
+};
+
+/// Request to create an API key
+struct CreateApiKeyRequest {
+    std::string user_id;
+    std::string name;
+    std::vector<std::string> permissions;
+    std::string expires_at;  // Optional: ISO 8601 timestamp
+};
+
+/// Result of creating an API key (includes the raw key ONCE)
+struct CreateApiKeyResult {
+    bool success = false;
+    std::string error;
+    std::optional<ApiKey> api_key;
+    std::string raw_key;  // The actual key - only returned on creation!
+};
+
+/// Result of an API key operation
+struct ApiKeyResult {
+    bool success = false;
+    std::string error;
+    std::optional<ApiKey> api_key;
+};
+
+/// Result of listing API keys
+struct ApiKeyListResult {
+    bool success = false;
+    std::string error;
+    std::vector<ApiKey> api_keys;
+    int64_t total_count = 0;
+};
+
+/// Result of validating an API key
+struct ValidateApiKeyResult {
+    bool valid = false;
+    std::string error;
+    std::optional<ApiKey> api_key;
+};
+
+/// Service for managing API keys in the _api_keys collection
+class ApiKeyService {
+public:
+    static constexpr const char* kCollectionName = "_api_keys";
+    static constexpr const char* kKeyPrefix = "sk_";  // Secret key prefix
+    static constexpr size_t kKeyLength = 32;  // 32 bytes = 64 hex chars
+
+    explicit ApiKeyService(DatabaseClient& db_client);
+    ~ApiKeyService();
+
+    // Disable copy
+    ApiKeyService(const ApiKeyService&) = delete;
+    auto operator=(const ApiKeyService&) -> ApiKeyService& = delete;
+
+    // Enable move
+    ApiKeyService(ApiKeyService&&) noexcept;
+    auto operator=(ApiKeyService&&) noexcept -> ApiKeyService&;
+
+    /// Initialize the _api_keys collection (create if doesn't exist)
+    /// Should be called on startup
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Create a new API key for a user
+    /// @param request Key creation request with user_id, name, permissions
+    /// @return Result with created key info AND the raw key (only time it's returned!)
+    [[nodiscard]] auto CreateApiKey(const CreateApiKeyRequest& request) -> CreateApiKeyResult;
+
+    /// Get an API key by ID (does not include raw key)
+    /// @param id API key ID
+    /// @param include_revoked Include revoked keys
+    /// @return Result with key info
+    [[nodiscard]] auto GetApiKey(const std::string& id, bool include_revoked = false) -> ApiKeyResult;
+
+    /// List all API keys for a user (does not include raw keys or hashes)
+    /// @param user_id User ID
+    /// @param include_revoked Include revoked keys
+    /// @return Result with list of keys
+    [[nodiscard]] auto ListApiKeys(const std::string& user_id, bool include_revoked = false) -> ApiKeyListResult;
+
+    /// Revoke an API key (soft delete)
+    /// @param id API key ID
+    /// @param user_id User ID (for ownership verification)
+    /// @return Result indicating success or error
+    [[nodiscard]] auto RevokeApiKey(const std::string& id, const std::string& user_id) -> ApiKeyResult;
+
+    /// Validate an API key and return its info if valid
+    /// @param raw_key The raw API key to validate
+    /// @return Result with key info if valid
+    [[nodiscard]] auto ValidateApiKey(const std::string& raw_key) -> ValidateApiKeyResult;
+
+    /// Update last_used_at timestamp for a key
+    /// @param id API key ID
+    void UpdateLastUsed(const std::string& id);
+
+private:
+    /// Convert proto Document to ApiKey struct
+    [[nodiscard]] static auto DocumentToApiKey(const ::smartbotic::database::Document& doc) -> ApiKey;
+
+    /// Get current ISO 8601 timestamp
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+
+    /// Generate a random API key
+    [[nodiscard]] static auto GenerateRawKey() -> std::string;
+
+    /// Hash an API key using SHA256
+    [[nodiscard]] static auto HashKey(const std::string& raw_key) -> std::string;
+
+    /// Extract prefix from raw key for identification
+    [[nodiscard]] static auto GetKeyPrefix(const std::string& raw_key) -> std::string;
+
+    DatabaseClient& db_client_;
+    bool initialized_ = false;
+};
+
+}  // namespace smartbotic::webserver

+ 111 - 0
webserver/include/smartbotic/webserver/auth_service.hpp

@@ -0,0 +1,111 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <unordered_set>
+#include <vector>
+
+#include "smartbotic/webserver/config.hpp"
+
+namespace smartbotic::webserver {
+
+/// User information extracted from JWT token
+struct AuthUser {
+    std::string user_id;
+    std::string email;
+    std::vector<std::string> workspace_ids;
+    std::vector<std::string> groups;
+    std::vector<std::string> effective_permissions;  // Resolved from groups
+};
+
+/// Token pair returned after successful authentication
+struct TokenPair {
+    std::string access_token;
+    std::string refresh_token;
+    int access_expires_in;   // seconds until access token expires
+    int refresh_expires_in;  // seconds until refresh token expires
+};
+
+/// Result of token validation
+struct TokenValidation {
+    bool valid = false;
+    std::string error;
+    std::optional<AuthUser> user;
+};
+
+/// Authentication service handling JWT token operations
+class AuthService {
+public:
+    explicit AuthService(const JwtConfig& config);
+    ~AuthService();
+
+    // Disable copy
+    AuthService(const AuthService&) = delete;
+    auto operator=(const AuthService&) -> AuthService& = delete;
+
+    // Enable move
+    AuthService(AuthService&&) noexcept;
+    auto operator=(AuthService&&) noexcept -> AuthService&;
+
+    /// Generate a token pair for a user
+    /// @param user_id User's unique identifier
+    /// @param email User's email address
+    /// @param workspace_ids List of workspace IDs the user belongs to
+    /// @param groups List of groups the user belongs to
+    /// @return Token pair with access and refresh tokens
+    [[nodiscard]] auto GenerateTokens(const std::string& user_id, const std::string& email,
+                                       const std::vector<std::string>& workspace_ids,
+                                       const std::vector<std::string>& groups) -> TokenPair;
+
+    /// Validate an access token
+    /// @param token The access token to validate
+    /// @return Validation result with user info if valid
+    [[nodiscard]] auto ValidateAccessToken(const std::string& token) -> TokenValidation;
+
+    /// Validate a refresh token
+    /// @param token The refresh token to validate
+    /// @return Validation result with user info if valid
+    [[nodiscard]] auto ValidateRefreshToken(const std::string& token) -> TokenValidation;
+
+    /// Refresh access token using a valid refresh token
+    /// @param refresh_token The refresh token
+    /// @return New token pair if refresh token is valid, nullopt otherwise
+    [[nodiscard]] auto RefreshAccessToken(const std::string& refresh_token) -> std::optional<TokenPair>;
+
+    /// Invalidate a refresh token (logout)
+    /// @param refresh_token The refresh token to invalidate
+    /// @return True if token was invalidated, false if token was not found
+    auto InvalidateRefreshToken(const std::string& refresh_token) -> bool;
+
+    /// Check if a refresh token has been invalidated
+    /// @param jti The JWT ID of the refresh token
+    /// @return True if the token is invalidated
+    [[nodiscard]] auto IsTokenInvalidated(const std::string& jti) const -> bool;
+
+    /// Extract bearer token from Authorization header
+    /// @param auth_header The Authorization header value
+    /// @return The token if header is valid, nullopt otherwise
+    [[nodiscard]] static auto ExtractBearerToken(const std::string& auth_header) -> std::optional<std::string>;
+
+    /// Hash a password using Argon2id (via libsodium)
+    /// @param password The plain text password
+    /// @return The hashed password
+    [[nodiscard]] static auto HashPassword(const std::string& password) -> std::string;
+
+    /// Verify a password against a hash
+    /// @param password The plain text password
+    /// @param hash The password hash to verify against
+    /// @return True if password matches hash
+    [[nodiscard]] static auto VerifyPassword(const std::string& password, const std::string& hash) -> bool;
+
+private:
+    JwtConfig config_;
+
+    // Set of invalidated refresh token JTIs (stored until they expire)
+    std::unordered_set<std::string> invalidated_tokens_;
+
+    // Generate a unique JWT ID
+    [[nodiscard]] static auto GenerateJti() -> std::string;
+};
+
+}  // namespace smartbotic::webserver

+ 278 - 0
webserver/include/smartbotic/webserver/authorization_service.hpp

@@ -0,0 +1,278 @@
+#pragma once
+
+#include <chrono>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/auth_service.hpp"
+#include "smartbotic/webserver/collection_service.hpp"
+#include "smartbotic/webserver/group_service.hpp"
+
+namespace smartbotic::webserver {
+
+/// Authorization service for checking user permissions
+class AuthorizationService {
+public:
+    /// Cache entry TTL in seconds
+    static constexpr int kCacheTtlSeconds = 300;  // 5 minutes
+
+    explicit AuthorizationService(GroupService& group_service,
+                                   CollectionService& collection_service);
+    ~AuthorizationService();
+
+    // Disable copy
+    AuthorizationService(const AuthorizationService&) = delete;
+    auto operator=(const AuthorizationService&) -> AuthorizationService& = delete;
+
+    // Enable move
+    AuthorizationService(AuthorizationService&&) noexcept;
+    auto operator=(AuthorizationService&&) noexcept -> AuthorizationService&;
+
+    /// Initialize the authorization service (create system groups if needed)
+    [[nodiscard]] auto Initialize() -> bool;
+
+    // =========================================================================
+    // Core Permission Checks
+    // =========================================================================
+
+    /// Check if user has a specific permission
+    /// Supports wildcards in both user permissions and required permission
+    /// @param user The authenticated user
+    /// @param permission The required permission (e.g., "system:users:read")
+    /// @return True if user has the permission
+    [[nodiscard]] auto HasPermission(const AuthUser& user,
+                                      std::string_view permission) -> bool;
+
+    /// Check if user has any of the specified permissions
+    /// @param user The authenticated user
+    /// @param permissions List of permissions (any match = true)
+    /// @return True if user has at least one of the permissions
+    [[nodiscard]] auto HasAnyPermission(const AuthUser& user,
+                                         const std::vector<std::string>& permissions) -> bool;
+
+    /// Check if user has all of the specified permissions
+    /// @param user The authenticated user
+    /// @param permissions List of permissions (all must match)
+    /// @return True if user has all the permissions
+    [[nodiscard]] auto HasAllPermissions(const AuthUser& user,
+                                          const std::vector<std::string>& permissions) -> bool;
+
+    // =========================================================================
+    // System Permissions
+    // =========================================================================
+
+    /// Check if user can login to the system
+    /// @param user The authenticated user
+    /// @return True if user has system:login:allow and not system:login:deny
+    [[nodiscard]] auto CanLogin(const AuthUser& user) -> bool;
+
+    /// Check if user is effectively a superadmin (has wildcard permission)
+    /// @param user The authenticated user
+    /// @return True if user has "*" permission (can do anything)
+    [[nodiscard]] auto IsSuperadmin(const AuthUser& user) -> bool;
+
+    // =========================================================================
+    // Collection Permissions
+    // =========================================================================
+
+    /// Check if user can read documents in a collection
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @return True if user can read (either read_all or read_own)
+    [[nodiscard]] auto CanReadCollection(const AuthUser& user,
+                                          const std::string& workspace_id,
+                                          const std::string& collection) -> bool;
+
+    /// Check if user can read all documents in a collection
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @return True if user has collection:ws:col:read_all
+    [[nodiscard]] auto CanReadAllDocuments(const AuthUser& user,
+                                            const std::string& workspace_id,
+                                            const std::string& collection) -> bool;
+
+    /// Check if user can read a specific document (checks ownership if needed)
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param doc_owner User ID of document owner (from _created_by)
+    /// @return True if user can read this document
+    [[nodiscard]] auto CanReadDocument(const AuthUser& user,
+                                        const std::string& workspace_id,
+                                        const std::string& collection,
+                                        const std::string& doc_owner) -> bool;
+
+    /// Check if user can create documents in a collection
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @return True if user can create
+    [[nodiscard]] auto CanCreateDocument(const AuthUser& user,
+                                          const std::string& workspace_id,
+                                          const std::string& collection) -> bool;
+
+    /// Check if user can write (update) a document
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param doc_owner User ID of document owner (from _created_by)
+    /// @return True if user can update this document
+    [[nodiscard]] auto CanWriteDocument(const AuthUser& user,
+                                         const std::string& workspace_id,
+                                         const std::string& collection,
+                                         const std::string& doc_owner) -> bool;
+
+    /// Check if user can delete a document
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param doc_owner User ID of document owner (from _created_by)
+    /// @return True if user can delete this document
+    [[nodiscard]] auto CanDeleteDocument(const AuthUser& user,
+                                          const std::string& workspace_id,
+                                          const std::string& collection,
+                                          const std::string& doc_owner) -> bool;
+
+    /// Check if user can manage a collection (modify settings)
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @return True if user can manage the collection
+    [[nodiscard]] auto CanManageCollection(const AuthUser& user,
+                                            const std::string& workspace_id,
+                                            const std::string& collection) -> bool;
+
+    // =========================================================================
+    // Field Filtering
+    // =========================================================================
+
+    /// Filter document fields based on user's field-level permissions
+    /// Modifies the document in-place, removing fields the user cannot see
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param document The document to filter (modified in-place)
+    auto FilterDocumentFields(const AuthUser& user,
+                               const std::string& workspace_id,
+                               const std::string& collection,
+                               nlohmann::json& document) -> void;
+
+    /// Check if user can read a specific field
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param field Field name
+    /// @return True if user can read this field
+    [[nodiscard]] auto CanReadField(const AuthUser& user,
+                                     const std::string& workspace_id,
+                                     const std::string& collection,
+                                     const std::string& field) -> bool;
+
+    /// Check if user can write a specific field
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param field Field name
+    /// @return True if user can write this field
+    [[nodiscard]] auto CanWriteField(const AuthUser& user,
+                                      const std::string& workspace_id,
+                                      const std::string& collection,
+                                      const std::string& field) -> bool;
+
+    // =========================================================================
+    // Workspace Permissions
+    // =========================================================================
+
+    /// Check if user is admin of a workspace
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @return True if user has workspace:ws:admin permission
+    [[nodiscard]] auto IsWorkspaceAdmin(const AuthUser& user,
+                                         const std::string& workspace_id) -> bool;
+
+    /// Check if user can manage workspace members
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @return True if user can manage members
+    [[nodiscard]] auto CanManageWorkspaceMembers(const AuthUser& user,
+                                                  const std::string& workspace_id) -> bool;
+
+    /// Check if user can manage workspace groups
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @return True if user can manage groups
+    [[nodiscard]] auto CanManageWorkspaceGroups(const AuthUser& user,
+                                                 const std::string& workspace_id) -> bool;
+
+    // =========================================================================
+    // Group Visibility
+    // =========================================================================
+
+    /// Get groups visible to the user in a workspace
+    /// Users without system:system_groups:read cannot see system groups
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @return List of visible groups
+    [[nodiscard]] auto GetVisibleGroups(const AuthUser& user,
+                                         const std::string& workspace_id) -> std::vector<Group>;
+
+    /// Check if user can see system groups
+    /// @param user The authenticated user
+    /// @return True if user can see system groups
+    [[nodiscard]] auto CanSeeSystemGroups(const AuthUser& user) -> bool;
+
+    // =========================================================================
+    // Permission Resolution
+    // =========================================================================
+
+    /// Get user's effective permissions (resolved from all groups)
+    /// Results are cached for performance
+    /// @param user The authenticated user
+    /// @return Vector of all effective permission strings
+    [[nodiscard]] auto GetEffectivePermissions(const AuthUser& user)
+        -> std::vector<std::string>;
+
+    /// Invalidate the permission cache for a user
+    /// Call this when a user's groups change or when groups are modified
+    /// @param user_id User ID to invalidate
+    auto InvalidateCache(const std::string& user_id) -> void;
+
+    /// Invalidate all cached permissions
+    /// Call this when global permission changes occur
+    auto InvalidateAllCaches() -> void;
+
+private:
+    /// Check if a required permission is granted by any of the user's permissions
+    [[nodiscard]] auto CheckPermission(const std::vector<std::string>& user_permissions,
+                                        std::string_view required) -> bool;
+
+    /// Resolve all permissions from a list of group IDs
+    [[nodiscard]] auto ResolveGroupPermissions(const std::vector<std::string>& group_ids)
+        -> std::vector<std::string>;
+
+    /// Get field permissions for a collection
+    [[nodiscard]] auto GetFieldPermissions(const std::string& workspace_id,
+                                            const std::string& collection)
+        -> std::vector<FieldPermission>;
+
+    /// Check if user is member of any of the specified groups
+    [[nodiscard]] auto IsMemberOfAnyGroup(const AuthUser& user,
+                                           const std::vector<std::string>& group_ids) -> bool;
+
+    GroupService& group_service_;
+    CollectionService& collection_service_;
+
+    /// Permission cache: user_id -> (permissions, expiry_time)
+    struct CacheEntry {
+        std::vector<std::string> permissions;
+        std::chrono::steady_clock::time_point expiry;
+    };
+    std::unordered_map<std::string, CacheEntry> permission_cache_;
+};
+
+}  // namespace smartbotic::webserver

+ 150 - 0
webserver/include/smartbotic/webserver/collection_service.hpp

@@ -0,0 +1,150 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// Field-level permission configuration
+struct FieldPermission {
+    std::string field_name;      // Field name (e.g., "email", "phone", "*" for all)
+    std::vector<std::string> read_groups;   // Groups that can read this field
+    std::vector<std::string> write_groups;  // Groups that can write this field
+};
+
+/// Collection settings structure
+struct CollectionSettings {
+    std::string schema;           // JSON Schema for document validation
+    bool is_system = false;       // System collections can't be modified by users
+    std::vector<std::string> encrypted_fields;  // Fields to encrypt
+    int64_t ttl_seconds = 0;      // 0 = no TTL
+    std::vector<FieldPermission> field_permissions;  // Per-field permission rules
+};
+
+/// Collection info returned to clients
+struct CollectionInfo {
+    std::string name;             // Display name (without workspace prefix)
+    std::string workspace_id;
+    int64_t document_count = 0;
+    int64_t size_bytes = 0;
+    std::string created_at;
+    std::string updated_at;
+    CollectionSettings settings;
+};
+
+/// Request to create a collection
+struct CreateCollectionRequest {
+    std::string workspace_id;
+    std::string name;             // Display name
+    CollectionSettings settings;
+};
+
+/// Result of a collection operation
+struct CollectionResult {
+    bool success = false;
+    std::string error;
+    std::optional<CollectionInfo> collection;
+};
+
+/// Result of listing collections
+struct CollectionListResult {
+    bool success = false;
+    std::string error;
+    std::vector<CollectionInfo> collections;
+    int64_t total_count = 0;
+};
+
+/// Service for managing collections within workspaces
+class CollectionService {
+public:
+    /// Prefix for user collections (workspace-scoped)
+    static constexpr const char* kCollectionPrefix = "ws_";
+
+    explicit CollectionService(DatabaseClient& db_client);
+    ~CollectionService();
+
+    // Disable copy
+    CollectionService(const CollectionService&) = delete;
+    auto operator=(const CollectionService&) -> CollectionService& = delete;
+
+    // Enable move
+    CollectionService(CollectionService&&) noexcept;
+    auto operator=(CollectionService&&) noexcept -> CollectionService&;
+
+    /// Create a new collection in a workspace
+    /// @param request Collection creation request
+    /// @return Result with created collection info
+    [[nodiscard]] auto CreateCollection(const CreateCollectionRequest& request) -> CollectionResult;
+
+    /// Get a collection by name within a workspace
+    /// @param workspace_id Workspace ID
+    /// @param name Collection name (display name, not internal)
+    /// @return Result with collection info
+    [[nodiscard]] auto GetCollection(const std::string& workspace_id, const std::string& name) -> CollectionResult;
+
+    /// List all collections in a workspace
+    /// @param workspace_id Workspace ID
+    /// @return Result with list of collections
+    [[nodiscard]] auto ListCollections(const std::string& workspace_id) -> CollectionListResult;
+
+    /// List all global system collections (for superadmin)
+    /// @return Result with list of system collections
+    [[nodiscard]] auto ListSystemCollections() -> CollectionListResult;
+
+    /// Update collection settings
+    /// @param workspace_id Workspace ID
+    /// @param name Collection name
+    /// @param settings New settings
+    /// @return Result indicating success or error
+    [[nodiscard]] auto UpdateCollection(const std::string& workspace_id, const std::string& name,
+                                        const CollectionSettings& settings) -> CollectionResult;
+
+    /// Drop (delete) a collection
+    /// @param workspace_id Workspace ID
+    /// @param name Collection name
+    /// @param force Force drop even if not empty
+    /// @return Result indicating success or error
+    [[nodiscard]] auto DropCollection(const std::string& workspace_id, const std::string& name,
+                                      bool force = false) -> CollectionResult;
+
+    /// Check if a collection name is valid
+    /// @param name Collection name to validate
+    /// @return True if valid
+    [[nodiscard]] static auto IsValidCollectionName(const std::string& name) -> bool;
+
+    /// Check if a collection is a system collection
+    /// @param name Collection name
+    /// @return True if system collection
+    [[nodiscard]] static auto IsSystemCollection(const std::string& name) -> bool;
+
+private:
+    /// Get the internal collection name (with workspace prefix)
+    [[nodiscard]] static auto GetInternalName(const std::string& workspace_id, const std::string& name) -> std::string;
+
+    /// Extract display name from internal name
+    [[nodiscard]] static auto GetDisplayName(const std::string& workspace_id, const std::string& internal_name) -> std::string;
+
+    /// Convert proto CollectionMetadata to CollectionInfo
+    [[nodiscard]] auto MetadataToInfo(const std::string& workspace_id,
+                                      const ::smartbotic::database::CollectionMetadata& meta) -> CollectionInfo;
+
+    /// Get current ISO 8601 timestamp
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+
+    /// Store collection settings in metadata collection
+    auto StoreSettings(const std::string& workspace_id, const std::string& name,
+                       const CollectionSettings& settings) -> bool;
+
+    /// Load collection settings from metadata collection
+    [[nodiscard]] auto LoadSettings(const std::string& workspace_id, const std::string& name) -> CollectionSettings;
+
+    /// Delete collection settings from metadata collection
+    auto DeleteSettings(const std::string& workspace_id, const std::string& name) -> bool;
+
+    DatabaseClient& db_client_;
+};
+
+}  // namespace smartbotic::webserver

+ 141 - 0
webserver/include/smartbotic/webserver/document_service.hpp

@@ -0,0 +1,141 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// Document info returned to clients
+struct DocumentInfo {
+    std::string id;
+    std::string collection;
+    std::string workspace_id;
+    nlohmann::json data;
+    std::string created_at;
+    std::string updated_at;
+    int64_t version = 0;
+};
+
+/// Request to create a document
+struct CreateDocumentRequest {
+    std::string workspace_id;
+    std::string collection;
+    std::string id;  // Optional - auto-generated if empty
+    nlohmann::json data;
+    std::string user_id;  // User creating the document (for _created_by)
+};
+
+/// Request to update a document
+struct UpdateDocumentRequest {
+    std::string workspace_id;
+    std::string collection;
+    std::string id;
+    nlohmann::json data;
+    bool merge = true;  // If true, merge with existing data; if false, replace entirely
+    int64_t expected_version = 0;  // For optimistic locking; 0 means no version check
+    std::string user_id;  // User updating the document (for _updated_by)
+};
+
+/// Query parameters for listing documents
+struct DocumentQuery {
+    std::string workspace_id;
+    std::string collection;
+    nlohmann::json filter;  // Filter conditions
+    std::string sort_field;
+    bool sort_ascending = true;
+    int32_t limit = 100;
+    int32_t offset = 0;
+    std::string owner_filter;  // If set, filter to documents created by this user (_created_by)
+};
+
+/// Result of a document operation
+struct DocumentResult {
+    bool success = false;
+    std::string error;
+    std::optional<DocumentInfo> document;
+};
+
+/// Result of listing documents
+struct DocumentListResult {
+    bool success = false;
+    std::string error;
+    std::vector<DocumentInfo> documents;
+    int64_t total_count = 0;
+};
+
+/// Service for managing documents within workspace collections
+class DocumentService {
+public:
+    explicit DocumentService(DatabaseClient& db_client);
+    ~DocumentService();
+
+    // Disable copy
+    DocumentService(const DocumentService&) = delete;
+    auto operator=(const DocumentService&) -> DocumentService& = delete;
+
+    // Enable move
+    DocumentService(DocumentService&&) noexcept;
+    auto operator=(DocumentService&&) noexcept -> DocumentService&;
+
+    /// Create a new document in a collection
+    /// @param request Document creation request
+    /// @return Result with created document info
+    [[nodiscard]] auto CreateDocument(const CreateDocumentRequest& request) -> DocumentResult;
+
+    /// Get a document by ID
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name (display name)
+    /// @param id Document ID
+    /// @return Result with document info
+    [[nodiscard]] auto GetDocument(const std::string& workspace_id, const std::string& collection,
+                                   const std::string& id) -> DocumentResult;
+
+    /// List/query documents in a collection
+    /// @param query Query parameters
+    /// @return Result with list of documents
+    [[nodiscard]] auto ListDocuments(const DocumentQuery& query) -> DocumentListResult;
+
+    /// Update a document
+    /// @param request Update request
+    /// @return Result with updated document info
+    [[nodiscard]] auto UpdateDocument(const UpdateDocumentRequest& request) -> DocumentResult;
+
+    /// Delete a document
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param id Document ID
+    /// @return Result indicating success or error
+    [[nodiscard]] auto DeleteDocument(const std::string& workspace_id, const std::string& collection,
+                                      const std::string& id) -> DocumentResult;
+
+private:
+    /// Get the internal collection name (with workspace prefix)
+    [[nodiscard]] static auto GetInternalCollectionName(const std::string& workspace_id,
+                                                         const std::string& collection) -> std::string;
+
+    /// Convert proto Document to DocumentInfo
+    [[nodiscard]] static auto ProtoToDocumentInfo(const std::string& workspace_id,
+                                                   const std::string& display_collection,
+                                                   const ::smartbotic::database::Document& doc) -> DocumentInfo;
+
+    /// Convert nlohmann::json to proto MapValue
+    [[nodiscard]] static auto JsonToMapValue(const nlohmann::json& json) -> ::smartbotic::database::MapValue;
+
+    /// Convert proto MapValue to nlohmann::json
+    [[nodiscard]] static auto MapValueToJson(const ::smartbotic::database::MapValue& map_value) -> nlohmann::json;
+
+    /// Convert proto Value to nlohmann::json
+    [[nodiscard]] static auto ValueToJson(const ::smartbotic::database::Value& value) -> nlohmann::json;
+
+    /// Convert nlohmann::json to proto Value
+    [[nodiscard]] static auto JsonToValue(const nlohmann::json& json) -> ::smartbotic::database::Value;
+
+    DatabaseClient& db_client_;
+};
+
+}  // namespace smartbotic::webserver

+ 178 - 0
webserver/include/smartbotic/webserver/group_service.hpp

@@ -0,0 +1,178 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// Group data structure
+struct Group {
+    std::string id;
+    std::string workspace_id;  // Empty for global groups like "superadmin"
+    std::string name;
+    std::vector<std::string> permissions;
+    bool is_system = false;         // System groups have special handling
+    std::string parent_group_id;    // Optional parent for permission inheritance
+    std::string created_at;
+    std::string updated_at;
+    std::string deleted_at;  // Empty if not deleted (soft delete)
+
+    /// Check if group is deleted (soft delete)
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
+
+    /// Check if this is a global group (not workspace-specific)
+    [[nodiscard]] auto IsGlobal() const -> bool { return workspace_id.empty(); }
+
+    /// Check if this is a system group
+    [[nodiscard]] auto IsSystemGroup() const -> bool { return is_system; }
+
+    /// Check if this group has a parent
+    [[nodiscard]] auto HasParent() const -> bool { return !parent_group_id.empty(); }
+};
+
+/// Request to create a group
+struct CreateGroupRequest {
+    std::string workspace_id;
+    std::string name;
+    std::vector<std::string> permissions;
+    bool is_system = false;         // Only superadmin can create system groups
+    std::string parent_group_id;    // Optional parent for permission inheritance
+};
+
+/// Request to update a group
+struct UpdateGroupRequest {
+    std::optional<std::string> name;
+    std::optional<std::vector<std::string>> permissions;
+    std::optional<std::string> parent_group_id;
+    // Note: is_system cannot be changed after creation
+};
+
+/// Result of a group operation
+struct GroupResult {
+    bool success = false;
+    std::string error;
+    std::optional<Group> group;
+};
+
+/// Result of listing groups
+struct GroupListResult {
+    bool success = false;
+    std::string error;
+    std::vector<Group> groups;
+    int64_t total_count = 0;
+};
+
+/// Service for managing groups in the _groups collection
+class GroupService {
+public:
+    static constexpr const char* kCollectionName = "_groups";
+    static constexpr const char* kSuperadminGroupName = "superadmin";
+
+    explicit GroupService(DatabaseClient& db_client);
+    ~GroupService();
+
+    // Disable copy
+    GroupService(const GroupService&) = delete;
+    auto operator=(const GroupService&) -> GroupService& = delete;
+
+    // Enable move
+    GroupService(GroupService&&) noexcept;
+    auto operator=(GroupService&&) noexcept -> GroupService&;
+
+    /// Initialize the _groups collection (create if doesn't exist)
+    /// Also creates the global superadmin group if it doesn't exist
+    /// Should be called on startup
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Create a new group in a workspace
+    /// @param request Group creation request with workspace_id, name, permissions
+    /// @return Result with created group or error
+    [[nodiscard]] auto CreateGroup(const CreateGroupRequest& request) -> GroupResult;
+
+    /// Get a group by ID
+    /// @param id Group ID
+    /// @param include_deleted Include soft-deleted groups
+    /// @return Result with group or error
+    [[nodiscard]] auto GetGroup(const std::string& id, bool include_deleted = false) -> GroupResult;
+
+    /// Get a group by name within a workspace
+    /// @param workspace_id Workspace ID (empty for global groups)
+    /// @param name Group name
+    /// @param include_deleted Include soft-deleted groups
+    /// @return Result with group or error
+    [[nodiscard]] auto GetGroupByName(const std::string& workspace_id, const std::string& name, bool include_deleted = false) -> GroupResult;
+
+    /// List all groups in a workspace
+    /// @param workspace_id Workspace ID
+    /// @param limit Maximum number of groups to return
+    /// @param offset Number of groups to skip
+    /// @param include_deleted Include soft-deleted groups
+    /// @return Result with list of groups
+    [[nodiscard]] auto ListGroups(const std::string& workspace_id, int limit = 100, int offset = 0, bool include_deleted = false) -> GroupListResult;
+
+    /// List groups by IDs
+    /// @param group_ids List of group IDs to fetch
+    /// @param include_deleted Include soft-deleted groups
+    /// @return Result with list of groups
+    [[nodiscard]] auto ListGroupsByIds(const std::vector<std::string>& group_ids, bool include_deleted = false) -> GroupListResult;
+
+    /// Update a group
+    /// @param id Group ID
+    /// @param request Update request with fields to update
+    /// @return Result with updated group or error
+    [[nodiscard]] auto UpdateGroup(const std::string& id, const UpdateGroupRequest& request) -> GroupResult;
+
+    /// Soft delete a group (sets deleted_at timestamp)
+    /// @param id Group ID
+    /// @return Result indicating success or error
+    [[nodiscard]] auto DeleteGroup(const std::string& id) -> GroupResult;
+
+    /// Check if a group with the given name exists in a workspace
+    /// @param workspace_id Workspace ID (empty for global groups)
+    /// @param name Group name
+    /// @return True if group exists
+    [[nodiscard]] auto NameExistsInWorkspace(const std::string& workspace_id, const std::string& name) -> bool;
+
+    /// Get the superadmin group
+    /// @return Result with superadmin group or error
+    [[nodiscard]] auto GetSuperadminGroup() -> GroupResult;
+
+    /// List all system groups (global groups with is_system=true)
+    /// @param include_deleted Include soft-deleted groups
+    /// @return Result with list of system groups
+    [[nodiscard]] auto ListSystemGroups(bool include_deleted = false) -> GroupListResult;
+
+    /// List all global groups (groups with empty workspace_id)
+    /// @param include_deleted Include soft-deleted groups
+    /// @return Result with list of global groups
+    [[nodiscard]] auto ListGlobalGroups(bool include_deleted = false) -> GroupListResult;
+
+    /// Get all permissions for a group, including inherited permissions from parent groups
+    /// @param group_id Group ID
+    /// @return Vector of all effective permissions
+    [[nodiscard]] auto GetEffectivePermissions(const std::string& group_id) -> std::vector<std::string>;
+
+    /// Create a system group (only called during initialization)
+    /// @param name Group name
+    /// @param permissions List of permissions
+    /// @return True if created successfully
+    [[nodiscard]] auto CreateSystemGroup(const std::string& name, const std::vector<std::string>& permissions) -> bool;
+
+private:
+    /// Convert proto Document to Group struct
+    [[nodiscard]] static auto DocumentToGroup(const ::smartbotic::database::Document& doc) -> Group;
+
+    /// Get current ISO 8601 timestamp
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+
+    /// Create the global superadmin group
+    [[nodiscard]] auto CreateSuperadminGroup() -> bool;
+
+    DatabaseClient& db_client_;
+    bool initialized_ = false;
+};
+
+}  // namespace smartbotic::webserver

+ 7 - 2
webserver/include/smartbotic/webserver/http_server.hpp

@@ -15,6 +15,7 @@
 
 #include "smartbotic/webserver/api_key_service.hpp"
 #include "smartbotic/webserver/auth_service.hpp"
+#include "smartbotic/webserver/authorization_service.hpp"
 #include "smartbotic/webserver/collection_service.hpp"
 #include "smartbotic/webserver/config.hpp"
 #include "smartbotic/webserver/database_client.hpp"
@@ -182,6 +183,9 @@ public:
     /// Get the view service
     [[nodiscard]] auto GetViewService() -> ViewService& { return *viewService_; }
 
+    /// Get the authorization service
+    [[nodiscard]] auto GetAuthorizationService() -> AuthorizationService& { return *authorizationService_; }
+
     /// Get the WebSocket handler
     [[nodiscard]] auto GetWsHandler() -> WsHandler& { return *wsHandler_; }
 
@@ -275,8 +279,8 @@ private:
     // Authentication middleware helper
     [[nodiscard]] auto AuthenticateRequest(const httplib::Request& req) -> std::optional<AuthUser>;
 
-    // Check if user has superadmin privileges
-    [[nodiscard]] static auto IsSuperadmin(const AuthUser& user) -> bool;
+    // Check if user has superadmin privileges (uses authorization service)
+    [[nodiscard]] auto IsSuperadmin(const AuthUser& user) -> bool;
 
     // Static file serving
     [[nodiscard]] static auto GetMimeType(const std::string& path) -> std::string;
@@ -296,6 +300,7 @@ private:
     std::unique_ptr<CollectionService> collectionService_;
     std::unique_ptr<DocumentService> documentService_;
     std::unique_ptr<ViewService> viewService_;
+    std::unique_ptr<AuthorizationService> authorizationService_;
     std::unique_ptr<WsHandler> wsHandler_;
 
     std::thread httpThread_;

+ 134 - 0
webserver/include/smartbotic/webserver/membership_service.hpp

@@ -0,0 +1,134 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// Workspace membership data structure
+struct WorkspaceMember {
+    std::string id;
+    std::string workspace_id;
+    std::string user_id;
+    std::vector<std::string> group_ids;
+    std::string created_at;
+    std::string updated_at;
+    std::string deleted_at;  // Empty if not deleted (soft delete)
+
+    /// Check if membership is deleted (soft delete)
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
+};
+
+/// Request to add a user to a workspace
+struct AddMemberRequest {
+    std::string workspace_id;
+    std::string user_id;
+    std::vector<std::string> group_ids;
+};
+
+/// Request to update a membership
+struct UpdateMemberRequest {
+    std::optional<std::vector<std::string>> group_ids;
+};
+
+/// Result of a membership operation
+struct MembershipResult {
+    bool success = false;
+    std::string error;
+    std::optional<WorkspaceMember> member;
+};
+
+/// Result of listing memberships
+struct MembershipListResult {
+    bool success = false;
+    std::string error;
+    std::vector<WorkspaceMember> members;
+    int64_t total_count = 0;
+};
+
+/// Service for managing workspace memberships in the _workspace_members collection
+class MembershipService {
+public:
+    static constexpr const char* kCollectionName = "_workspace_members";
+
+    explicit MembershipService(DatabaseClient& db_client);
+    ~MembershipService();
+
+    // Disable copy
+    MembershipService(const MembershipService&) = delete;
+    auto operator=(const MembershipService&) -> MembershipService& = delete;
+
+    // Enable move
+    MembershipService(MembershipService&&) noexcept;
+    auto operator=(MembershipService&&) noexcept -> MembershipService&;
+
+    /// Initialize the _workspace_members collection (create if doesn't exist)
+    /// Should be called on startup
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Add a user to a workspace with specified groups
+    /// @param request Membership creation request with workspace_id, user_id, group_ids
+    /// @return Result with created membership or error
+    [[nodiscard]] auto AddMember(const AddMemberRequest& request) -> MembershipResult;
+
+    /// Get a membership by ID
+    /// @param id Membership ID
+    /// @param include_deleted Include soft-deleted memberships
+    /// @return Result with membership or error
+    [[nodiscard]] auto GetMembership(const std::string& id, bool include_deleted = false) -> MembershipResult;
+
+    /// Get a membership by workspace and user ID
+    /// @param workspace_id Workspace ID
+    /// @param user_id User ID
+    /// @param include_deleted Include soft-deleted memberships
+    /// @return Result with membership or error
+    [[nodiscard]] auto GetMembershipByUser(const std::string& workspace_id, const std::string& user_id, bool include_deleted = false) -> MembershipResult;
+
+    /// List all members in a workspace
+    /// @param workspace_id Workspace ID
+    /// @param limit Maximum number of members to return
+    /// @param offset Number of members to skip
+    /// @param include_deleted Include soft-deleted memberships
+    /// @return Result with list of memberships
+    [[nodiscard]] auto ListMembers(const std::string& workspace_id, int limit = 100, int offset = 0, bool include_deleted = false) -> MembershipListResult;
+
+    /// List all workspace memberships for a user
+    /// @param user_id User ID
+    /// @param include_deleted Include soft-deleted memberships
+    /// @return Result with list of memberships
+    [[nodiscard]] auto ListUserMemberships(const std::string& user_id, bool include_deleted = false) -> MembershipListResult;
+
+    /// Update a membership (change group assignments)
+    /// @param workspace_id Workspace ID
+    /// @param user_id User ID
+    /// @param request Update request with fields to update
+    /// @return Result with updated membership or error
+    [[nodiscard]] auto UpdateMembership(const std::string& workspace_id, const std::string& user_id, const UpdateMemberRequest& request) -> MembershipResult;
+
+    /// Soft delete a membership (remove user from workspace)
+    /// @param workspace_id Workspace ID
+    /// @param user_id User ID
+    /// @return Result indicating success or error
+    [[nodiscard]] auto RemoveMember(const std::string& workspace_id, const std::string& user_id) -> MembershipResult;
+
+    /// Check if a user is a member of a workspace
+    /// @param workspace_id Workspace ID
+    /// @param user_id User ID
+    /// @return True if user is a member
+    [[nodiscard]] auto IsMember(const std::string& workspace_id, const std::string& user_id) -> bool;
+
+private:
+    /// Convert proto Document to WorkspaceMember struct
+    [[nodiscard]] static auto DocumentToMember(const ::smartbotic::database::Document& doc) -> WorkspaceMember;
+
+    /// Get current ISO 8601 timestamp
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+
+    DatabaseClient& db_client_;
+    bool initialized_ = false;
+};
+
+}  // namespace smartbotic::webserver

+ 171 - 0
webserver/include/smartbotic/webserver/permissions.hpp

@@ -0,0 +1,171 @@
+#pragma once
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+namespace smartbotic::permissions {
+
+// ============================================================================
+// SYSTEM SCOPE - Global system operations
+// ============================================================================
+
+// Authentication
+constexpr std::string_view kLoginAllow = "system:login:allow";
+constexpr std::string_view kLoginDeny = "system:login:deny";  // Overrides allow
+
+// User management
+constexpr std::string_view kUsersRead = "system:users:read";
+constexpr std::string_view kUsersCreate = "system:users:create";
+constexpr std::string_view kUsersUpdate = "system:users:update";
+constexpr std::string_view kUsersDelete = "system:users:delete";
+
+// Group management
+constexpr std::string_view kGroupsRead = "system:groups:read";
+constexpr std::string_view kGroupsCreate = "system:groups:create";
+constexpr std::string_view kGroupsUpdate = "system:groups:update";
+constexpr std::string_view kGroupsDelete = "system:groups:delete";
+
+// System group management (special - for managing system groups)
+constexpr std::string_view kSystemGroupsRead = "system:system_groups:read";
+constexpr std::string_view kSystemGroupsCreate = "system:system_groups:create";
+constexpr std::string_view kSystemGroupsUpdate = "system:system_groups:update";
+constexpr std::string_view kSystemGroupsDelete = "system:system_groups:delete";
+
+// Workspace management (system-level)
+constexpr std::string_view kWorkspacesRead = "system:workspaces:read";
+constexpr std::string_view kWorkspacesCreate = "system:workspaces:create";
+constexpr std::string_view kWorkspacesUpdate = "system:workspaces:update";
+constexpr std::string_view kWorkspacesDelete = "system:workspaces:delete";
+
+// Collection management (system-level)
+constexpr std::string_view kCollectionsRead = "system:collections:read";
+constexpr std::string_view kCollectionsCreate = "system:collections:create";
+constexpr std::string_view kCollectionsUpdate = "system:collections:update";
+constexpr std::string_view kCollectionsDelete = "system:collections:delete";
+
+// API key management (system-level)
+constexpr std::string_view kApiKeysRead = "system:api_keys:read";
+constexpr std::string_view kApiKeysCreate = "system:api_keys:create";
+constexpr std::string_view kApiKeysUpdate = "system:api_keys:update";
+constexpr std::string_view kApiKeysDelete = "system:api_keys:delete";
+
+// Membership management (system-level)
+constexpr std::string_view kMembershipsRead = "system:memberships:read";
+constexpr std::string_view kMembershipsCreate = "system:memberships:create";
+constexpr std::string_view kMembershipsUpdate = "system:memberships:update";
+constexpr std::string_view kMembershipsDelete = "system:memberships:delete";
+
+// View management (system-level)
+constexpr std::string_view kViewsRead = "system:views:read";
+constexpr std::string_view kViewsCreate = "system:views:create";
+constexpr std::string_view kViewsUpdate = "system:views:update";
+constexpr std::string_view kViewsDelete = "system:views:delete";
+
+// ============================================================================
+// WORKSPACE SCOPE - Per-workspace operations
+// Format: workspace:{workspace_id}:{action}
+// ============================================================================
+
+// Workspace admin - full access to a workspace
+constexpr std::string_view kWorkspaceAdmin = "workspace:*:admin";
+
+// Workspace member management
+constexpr std::string_view kWorkspaceManageMembers = "workspace:*:manage_members";
+
+// Workspace group management
+constexpr std::string_view kWorkspaceManageGroups = "workspace:*:manage_groups";
+
+// Workspace collection management
+constexpr std::string_view kWorkspaceManageCollections = "workspace:*:manage_collections";
+
+// Workspace view management
+constexpr std::string_view kWorkspaceManageViews = "workspace:*:manage_views";
+
+// ============================================================================
+// COLLECTION SCOPE - Per-collection operations
+// Format: collection:{workspace_id}:{collection_name}:{action}
+// Use * for wildcards
+// ============================================================================
+
+// Read permissions
+constexpr std::string_view kCollectionReadAll = "collection:*:*:read_all";   // Read any document
+constexpr std::string_view kCollectionReadOwn = "collection:*:*:read_own";   // Read own documents only
+
+// Write permissions
+constexpr std::string_view kCollectionWriteAll = "collection:*:*:write_all"; // Write any document
+constexpr std::string_view kCollectionWriteOwn = "collection:*:*:write_own"; // Write own documents only
+constexpr std::string_view kCollectionCreate = "collection:*:*:create";       // Create new documents
+
+// Delete permissions
+constexpr std::string_view kCollectionDeleteAll = "collection:*:*:delete_all"; // Delete any document
+constexpr std::string_view kCollectionDeleteOwn = "collection:*:*:delete_own"; // Delete own documents only
+
+// Collection management
+constexpr std::string_view kCollectionManage = "collection:*:*:manage";       // Modify collection settings
+
+// ============================================================================
+// FIELD SCOPE - Per-field operations
+// Format: field:{workspace_id}:{collection_name}:{field_path}:{action}
+// Use * for wildcards
+// ============================================================================
+
+constexpr std::string_view kFieldRead = "field:*:*:*:read";   // Read field
+constexpr std::string_view kFieldWrite = "field:*:*:*:write"; // Write field
+
+// ============================================================================
+// SPECIAL
+// ============================================================================
+
+constexpr std::string_view kWildcard = "*";  // Superadmin - all permissions
+
+// ============================================================================
+// System group names
+// ============================================================================
+
+constexpr std::string_view kSuperadminGroupName = "superadmin";
+constexpr std::string_view kSystemReadonlyGroupName = "system_readonly";
+constexpr std::string_view kAuthenticatedGroupName = "authenticated";
+
+// ============================================================================
+// Helper functions
+// ============================================================================
+
+/// Build a specific workspace permission
+/// e.g., BuildWorkspacePermission("ws1", "admin") -> "workspace:ws1:admin"
+[[nodiscard]] auto BuildWorkspacePermission(std::string_view workspace_id,
+                                             std::string_view action) -> std::string;
+
+/// Build a specific collection permission
+/// e.g., BuildCollectionPermission("ws1", "customers", "read_all") -> "collection:ws1:customers:read_all"
+[[nodiscard]] auto BuildCollectionPermission(std::string_view workspace_id,
+                                              std::string_view collection,
+                                              std::string_view action) -> std::string;
+
+/// Build a specific field permission
+/// e.g., BuildFieldPermission("ws1", "customers", "email", "read") -> "field:ws1:customers:email:read"
+[[nodiscard]] auto BuildFieldPermission(std::string_view workspace_id,
+                                         std::string_view collection,
+                                         std::string_view field,
+                                         std::string_view action) -> std::string;
+
+/// Check if a permission matches a pattern (with wildcard support)
+/// e.g., MatchesPermission("collection:ws1:customers:read_all", "collection:*:*:read_all") -> true
+/// e.g., MatchesPermission("system:users:read", "*") -> true
+[[nodiscard]] auto MatchesPermission(std::string_view required,
+                                      std::string_view granted) -> bool;
+
+/// Parse a permission string into its components
+/// Returns empty vector if invalid
+[[nodiscard]] auto ParsePermission(std::string_view permission) -> std::vector<std::string>;
+
+/// Get all defined system permissions (for UI enumeration)
+[[nodiscard]] auto GetAllSystemPermissions() -> std::vector<std::string_view>;
+
+/// Get all defined collection actions (for UI enumeration)
+[[nodiscard]] auto GetAllCollectionActions() -> std::vector<std::string_view>;
+
+/// Get all defined field actions (for UI enumeration)
+[[nodiscard]] auto GetAllFieldActions() -> std::vector<std::string_view>;
+
+}  // namespace smartbotic::permissions

+ 131 - 0
webserver/include/smartbotic/webserver/user_service.hpp

@@ -0,0 +1,131 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// User data structure
+struct User {
+    std::string id;
+    std::string email;
+    std::string password_hash;
+    std::string name;
+    std::string created_at;
+    std::string updated_at;
+    std::string deleted_at;  // Empty if not deleted (soft delete)
+
+    /// Check if user is deleted (soft delete)
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
+};
+
+/// Request to create a user
+struct CreateUserRequest {
+    std::string email;
+    std::string password;  // Plain text, will be hashed
+    std::string name;
+};
+
+/// Request to update a user
+struct UpdateUserRequest {
+    std::optional<std::string> email;
+    std::optional<std::string> password;  // Plain text, will be hashed
+    std::optional<std::string> name;
+};
+
+/// Result of a user operation
+struct UserResult {
+    bool success = false;
+    std::string error;
+    std::optional<User> user;
+};
+
+/// Result of listing users
+struct UserListResult {
+    bool success = false;
+    std::string error;
+    std::vector<User> users;
+    int64_t total_count = 0;
+};
+
+/// Service for managing users in the _users collection
+class UserService {
+public:
+    static constexpr const char* kCollectionName = "_users";
+
+    explicit UserService(DatabaseClient& db_client);
+    ~UserService();
+
+    // Disable copy
+    UserService(const UserService&) = delete;
+    auto operator=(const UserService&) -> UserService& = delete;
+
+    // Enable move
+    UserService(UserService&&) noexcept;
+    auto operator=(UserService&&) noexcept -> UserService&;
+
+    /// Initialize the _users collection (create if doesn't exist)
+    /// Should be called on startup
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Create a new user
+    /// @param request User creation request with email, password, name
+    /// @return Result with created user or error
+    [[nodiscard]] auto CreateUser(const CreateUserRequest& request) -> UserResult;
+
+    /// Get a user by ID
+    /// @param id User ID
+    /// @param include_deleted Include soft-deleted users
+    /// @return Result with user or error
+    [[nodiscard]] auto GetUser(const std::string& id, bool include_deleted = false) -> UserResult;
+
+    /// Get a user by email
+    /// @param email User email
+    /// @param include_deleted Include soft-deleted users
+    /// @return Result with user or error
+    [[nodiscard]] auto GetUserByEmail(const std::string& email, bool include_deleted = false) -> UserResult;
+
+    /// List all users
+    /// @param limit Maximum number of users to return
+    /// @param offset Number of users to skip
+    /// @param include_deleted Include soft-deleted users
+    /// @return Result with list of users
+    [[nodiscard]] auto ListUsers(int limit = 100, int offset = 0, bool include_deleted = false) -> UserListResult;
+
+    /// Update a user
+    /// @param id User ID
+    /// @param request Update request with fields to update
+    /// @return Result with updated user or error
+    [[nodiscard]] auto UpdateUser(const std::string& id, const UpdateUserRequest& request) -> UserResult;
+
+    /// Soft delete a user (sets deleted_at timestamp)
+    /// @param id User ID
+    /// @return Result indicating success or error
+    [[nodiscard]] auto DeleteUser(const std::string& id) -> UserResult;
+
+    /// Verify user credentials
+    /// @param email User email
+    /// @param password Plain text password
+    /// @return Result with user if credentials are valid
+    [[nodiscard]] auto VerifyCredentials(const std::string& email, const std::string& password) -> UserResult;
+
+    /// Check if a user with the given email exists
+    /// @param email User email
+    /// @return True if user exists
+    [[nodiscard]] auto EmailExists(const std::string& email) -> bool;
+
+private:
+    /// Convert proto Document to User struct
+    [[nodiscard]] static auto DocumentToUser(const ::smartbotic::database::Document& doc) -> User;
+
+    /// Get current ISO 8601 timestamp
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+
+    DatabaseClient& db_client_;
+    bool initialized_ = false;
+};
+
+}  // namespace smartbotic::webserver

+ 173 - 0
webserver/include/smartbotic/webserver/view_service.hpp

@@ -0,0 +1,173 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// Field type for schema definitions
+enum class FieldType {
+    Text,
+    Number,
+    Date,
+    Boolean,
+    Select,
+    Reference,
+    Computed
+};
+
+/// Convert field type to/from string
+auto FieldTypeToString(FieldType type) -> std::string;
+auto StringToFieldType(const std::string& str) -> FieldType;
+
+/// Schema field definition
+struct SchemaField {
+    std::string name;
+    FieldType type = FieldType::Text;
+    std::string label;
+    std::string description;
+    bool required = false;
+    nlohmann::json default_value;
+    int display_order = 0;
+    std::string widget;  // UI widget hint (e.g., "textarea", "datepicker")
+    std::string group;   // Field grouping for UI
+
+    // Type-specific options
+    nlohmann::json options;  // For Select: array of {value, label}
+    std::string reference_collection;  // For Reference type
+    std::string computed_expression;   // For Computed type (deferred)
+};
+
+/// View schema with UI extensions
+struct ViewSchema {
+    std::vector<SchemaField> fields;
+    std::string title;
+    std::string description;
+    nlohmann::json layout;  // UI layout hints
+};
+
+/// View settings
+struct ViewSettings {
+    bool is_default = false;
+    bool show_in_sidebar = true;
+    std::string icon;
+    nlohmann::json filters;  // Default filters
+    nlohmann::json sort;     // Default sort
+};
+
+/// View information
+struct ViewInfo {
+    std::string id;
+    std::string workspace_id;
+    std::string name;
+    std::string collection_name;
+    ViewSchema schema;
+    ViewSettings settings;
+    std::string created_at;
+    std::string updated_at;
+};
+
+/// Request to create a view
+struct CreateViewRequest {
+    std::string workspace_id;
+    std::string name;
+    std::string collection_name;
+    ViewSchema schema;
+    ViewSettings settings;
+};
+
+/// Request to update a view
+struct UpdateViewRequest {
+    std::string workspace_id;
+    std::string id;
+    std::optional<std::string> name;
+    std::optional<std::string> collection_name;
+    std::optional<ViewSchema> schema;
+    std::optional<ViewSettings> settings;
+};
+
+/// Result of a view operation
+struct ViewResult {
+    bool success = false;
+    std::string error;
+    std::optional<ViewInfo> view;
+};
+
+/// Result of listing views
+struct ViewListResult {
+    bool success = false;
+    std::string error;
+    std::vector<ViewInfo> views;
+};
+
+/// Service for managing views/schema definitions
+class ViewService {
+public:
+    explicit ViewService(DatabaseClient& db_client);
+    ~ViewService();
+
+    // Disable copy
+    ViewService(const ViewService&) = delete;
+    auto operator=(const ViewService&) -> ViewService& = delete;
+
+    // Enable move
+    ViewService(ViewService&&) noexcept;
+    auto operator=(ViewService&&) noexcept -> ViewService&;
+
+    /// Initialize the service (create system collection if needed)
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Create a new view
+    [[nodiscard]] auto CreateView(const CreateViewRequest& request) -> ViewResult;
+
+    /// Get a view by ID
+    [[nodiscard]] auto GetView(const std::string& workspace_id, const std::string& id) -> ViewResult;
+
+    /// Get a view by name
+    [[nodiscard]] auto GetViewByName(const std::string& workspace_id,
+                                      const std::string& name) -> ViewResult;
+
+    /// List all views in a workspace
+    [[nodiscard]] auto ListViews(const std::string& workspace_id) -> ViewListResult;
+
+    /// List views for a specific collection
+    [[nodiscard]] auto ListViewsForCollection(const std::string& workspace_id,
+                                               const std::string& collection_name) -> ViewListResult;
+
+    /// Update a view
+    [[nodiscard]] auto UpdateView(const UpdateViewRequest& request) -> ViewResult;
+
+    /// Delete a view
+    [[nodiscard]] auto DeleteView(const std::string& workspace_id, const std::string& id) -> ViewResult;
+
+private:
+    /// System collection name for views
+    static constexpr const char* kViewsCollection = "_views";
+
+    /// Convert ViewInfo to JSON for storage
+    [[nodiscard]] static auto ViewToJson(const ViewInfo& view) -> nlohmann::json;
+
+    /// Convert JSON from storage to ViewInfo
+    [[nodiscard]] static auto JsonToView(const nlohmann::json& json) -> ViewInfo;
+
+    /// Convert ViewSchema to JSON
+    [[nodiscard]] static auto SchemaToJson(const ViewSchema& schema) -> nlohmann::json;
+
+    /// Convert JSON to ViewSchema
+    [[nodiscard]] static auto JsonToSchema(const nlohmann::json& json) -> ViewSchema;
+
+    /// Convert ViewSettings to JSON
+    [[nodiscard]] static auto SettingsToJson(const ViewSettings& settings) -> nlohmann::json;
+
+    /// Convert JSON to ViewSettings
+    [[nodiscard]] static auto JsonToSettings(const nlohmann::json& json) -> ViewSettings;
+
+    DatabaseClient& db_client_;
+};
+
+}  // namespace smartbotic::webserver

+ 130 - 0
webserver/include/smartbotic/webserver/workspace_service.hpp

@@ -0,0 +1,130 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// Workspace data structure
+struct Workspace {
+    std::string id;
+    std::string name;
+    nlohmann::json settings;  // Flexible settings object
+    std::string created_at;
+    std::string updated_at;
+    std::string deleted_at;  // Empty if not deleted (soft delete)
+
+    /// Check if workspace is deleted (soft delete)
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
+};
+
+/// Request to create a workspace
+struct CreateWorkspaceRequest {
+    std::string name;
+    nlohmann::json settings = nlohmann::json::object();  // Optional settings
+};
+
+/// Request to update a workspace
+struct UpdateWorkspaceRequest {
+    std::optional<std::string> name;
+    std::optional<nlohmann::json> settings;
+};
+
+/// Result of a workspace operation
+struct WorkspaceResult {
+    bool success = false;
+    std::string error;
+    std::optional<Workspace> workspace;
+};
+
+/// Result of listing workspaces
+struct WorkspaceListResult {
+    bool success = false;
+    std::string error;
+    std::vector<Workspace> workspaces;
+    int64_t total_count = 0;
+};
+
+/// Service for managing workspaces in the _workspaces collection
+class WorkspaceService {
+public:
+    static constexpr const char* kCollectionName = "_workspaces";
+
+    explicit WorkspaceService(DatabaseClient& db_client);
+    ~WorkspaceService();
+
+    // Disable copy
+    WorkspaceService(const WorkspaceService&) = delete;
+    auto operator=(const WorkspaceService&) -> WorkspaceService& = delete;
+
+    // Enable move
+    WorkspaceService(WorkspaceService&&) noexcept;
+    auto operator=(WorkspaceService&&) noexcept -> WorkspaceService&;
+
+    /// Initialize the _workspaces collection (create if doesn't exist)
+    /// Should be called on startup
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Create a new workspace
+    /// @param request Workspace creation request with name and optional settings
+    /// @return Result with created workspace or error
+    [[nodiscard]] auto CreateWorkspace(const CreateWorkspaceRequest& request) -> WorkspaceResult;
+
+    /// Get a workspace by ID
+    /// @param id Workspace ID
+    /// @param include_deleted Include soft-deleted workspaces
+    /// @return Result with workspace or error
+    [[nodiscard]] auto GetWorkspace(const std::string& id, bool include_deleted = false) -> WorkspaceResult;
+
+    /// Get a workspace by name
+    /// @param name Workspace name
+    /// @param include_deleted Include soft-deleted workspaces
+    /// @return Result with workspace or error
+    [[nodiscard]] auto GetWorkspaceByName(const std::string& name, bool include_deleted = false) -> WorkspaceResult;
+
+    /// List all workspaces
+    /// @param limit Maximum number of workspaces to return
+    /// @param offset Number of workspaces to skip
+    /// @param include_deleted Include soft-deleted workspaces
+    /// @return Result with list of workspaces
+    [[nodiscard]] auto ListWorkspaces(int limit = 100, int offset = 0, bool include_deleted = false) -> WorkspaceListResult;
+
+    /// List workspaces by IDs (for filtering by user membership)
+    /// @param workspace_ids List of workspace IDs to fetch
+    /// @param include_deleted Include soft-deleted workspaces
+    /// @return Result with list of workspaces
+    [[nodiscard]] auto ListWorkspacesByIds(const std::vector<std::string>& workspace_ids, bool include_deleted = false) -> WorkspaceListResult;
+
+    /// Update a workspace
+    /// @param id Workspace ID
+    /// @param request Update request with fields to update
+    /// @return Result with updated workspace or error
+    [[nodiscard]] auto UpdateWorkspace(const std::string& id, const UpdateWorkspaceRequest& request) -> WorkspaceResult;
+
+    /// Soft delete a workspace (sets deleted_at timestamp)
+    /// @param id Workspace ID
+    /// @return Result indicating success or error
+    [[nodiscard]] auto DeleteWorkspace(const std::string& id) -> WorkspaceResult;
+
+    /// Check if a workspace with the given name exists
+    /// @param name Workspace name
+    /// @return True if workspace exists
+    [[nodiscard]] auto NameExists(const std::string& name) -> bool;
+
+private:
+    /// Convert proto Document to Workspace struct
+    [[nodiscard]] static auto DocumentToWorkspace(const ::smartbotic::database::Document& doc) -> Workspace;
+
+    /// Get current ISO 8601 timestamp
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+
+    DatabaseClient& db_client_;
+    bool initialized_ = false;
+};
+
+}  // namespace smartbotic::webserver

+ 154 - 0
webserver/include/smartbotic/webserver/ws_handler.hpp

@@ -0,0 +1,154 @@
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <set>
+#include <string>
+#include <unordered_map>
+#include <unordered_set>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/auth_service.hpp"
+
+namespace smartbotic::webserver {
+
+// Forward declaration
+struct WsConnection;
+
+/// WebSocket message types
+enum class WsMessageType {
+    Auth,           // Authentication message
+    Subscribe,      // Subscribe to collection
+    Unsubscribe,    // Unsubscribe from collection
+    Document,       // Document change event
+    Notification,   // System notification
+    Error,          // Error response
+    Pong,           // Ping/pong response
+    Unknown
+};
+
+/// Document action types for events
+enum class DocumentAction {
+    Create,
+    Update,
+    Delete
+};
+
+/// Notification levels
+enum class NotificationLevel {
+    Info,
+    Warning,
+    Error
+};
+
+/// Parse message type from string
+auto ParseMessageType(const std::string& type) -> WsMessageType;
+
+/// Convert message type to string
+auto MessageTypeToString(WsMessageType type) -> std::string;
+
+/// Convert document action to string
+auto DocumentActionToString(DocumentAction action) -> std::string;
+
+/// Convert notification level to string
+auto NotificationLevelToString(NotificationLevel level) -> std::string;
+
+/// WebSocket message handler for real-time updates
+class WsHandler {
+public:
+    /// Callback type for sending messages to a connection
+    using SendCallback = std::function<void(WsConnection*, const std::string&)>;
+
+    explicit WsHandler(AuthService& auth_service);
+    ~WsHandler();
+
+    // Disable copy
+    WsHandler(const WsHandler&) = delete;
+    auto operator=(const WsHandler&) -> WsHandler& = delete;
+
+    // Enable move
+    WsHandler(WsHandler&&) noexcept;
+    auto operator=(WsHandler&&) noexcept -> WsHandler&;
+
+    /// Handle incoming WebSocket message
+    /// @param conn The connection that sent the message
+    /// @param message The raw message string (JSON)
+    /// @param send_callback Callback to send response
+    void HandleMessage(WsConnection* conn, const std::string& message,
+                       const SendCallback& send_callback);
+
+    /// Authenticate connection from query parameter token
+    /// @param conn The connection to authenticate
+    /// @param token JWT token from query parameter
+    /// @return true if authentication successful
+    [[nodiscard]] auto AuthenticateFromToken(WsConnection* conn, const std::string& token) -> bool;
+
+    /// Broadcast a document event to subscribed connections
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    /// @param action Document action (create/update/delete)
+    /// @param document_id Document ID
+    /// @param data Document data (optional, for create/update)
+    /// @param send_callback Callback to send to all connections
+    void BroadcastDocumentEvent(const std::string& workspace_id,
+                                 const std::string& collection,
+                                 DocumentAction action,
+                                 const std::string& document_id,
+                                 const nlohmann::json& data,
+                                 const std::function<void(const std::string&)>& broadcast_to_subscribed);
+
+    /// Send a notification to a specific connection
+    /// @param conn The connection
+    /// @param message Notification message
+    /// @param level Notification level
+    /// @param send_callback Callback to send message
+    static void SendNotification(WsConnection* conn, const std::string& message,
+                                  NotificationLevel level, const SendCallback& send_callback);
+
+    /// Send an error to a connection
+    /// @param conn The connection
+    /// @param error Error message
+    /// @param send_callback Callback to send message
+    static void SendError(WsConnection* conn, const std::string& error,
+                          const SendCallback& send_callback);
+
+    /// Check if a connection is subscribed to a collection
+    /// @param conn The connection
+    /// @param workspace_id Workspace ID
+    /// @param collection Collection name
+    [[nodiscard]] static auto IsSubscribed(const WsConnection* conn,
+                                            const std::string& workspace_id,
+                                            const std::string& collection) -> bool;
+
+    /// Build subscription key from workspace and collection
+    [[nodiscard]] static auto BuildSubscriptionKey(const std::string& workspace_id,
+                                                     const std::string& collection) -> std::string;
+
+private:
+    /// Handle authentication message
+    void HandleAuth(WsConnection* conn, const nlohmann::json& payload,
+                    const SendCallback& send_callback);
+
+    /// Handle subscribe message
+    void HandleSubscribe(WsConnection* conn, const nlohmann::json& payload,
+                         const SendCallback& send_callback);
+
+    /// Handle unsubscribe message
+    void HandleUnsubscribe(WsConnection* conn, const nlohmann::json& payload,
+                           const SendCallback& send_callback);
+
+    /// Handle ping message
+    static void HandlePing(WsConnection* conn, const SendCallback& send_callback);
+
+    /// Validate user has access to workspace/collection
+    [[nodiscard]] auto ValidateAccess(const WsConnection* conn,
+                                       const std::string& workspace_id,
+                                       const std::string& collection) const -> bool;
+
+    AuthService& auth_service_;
+};
+
+}  // namespace smartbotic::webserver

+ 507 - 0
webserver/src/api_key_service.cpp

@@ -0,0 +1,507 @@
+#include "smartbotic/webserver/api_key_service.hpp"
+
+#include <spdlog/spdlog.h>
+#include <sodium.h>
+
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+auto SetStringValue(::smartbotic::database::MapValue* map, const std::string& key, const std::string& value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_string_value(value);
+}
+
+auto SetArrayValue(::smartbotic::database::MapValue* map, const std::string& key, const std::vector<std::string>& values) {
+    auto* field = &(*map->mutable_fields())[key];
+    auto* array = field->mutable_array_value();
+    for (const auto& value : values) {
+        array->add_values()->set_string_value(value);
+    }
+}
+
+auto GetStringValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::string {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_string_value()) {
+        return it->second.string_value();
+    }
+    return "";
+}
+
+auto GetArrayValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::vector<std::string> {
+    std::vector<std::string> result;
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_array_value()) {
+        for (const auto& value : it->second.array_value().values()) {
+            if (value.has_string_value()) {
+                result.push_back(value.string_value());
+            }
+        }
+    }
+    return result;
+}
+
+}  // namespace
+
+auto ApiKey::IsExpired() const -> bool {
+    if (expires_at.empty()) {
+        return false;  // No expiration set
+    }
+
+    // Parse ISO 8601 timestamp and compare with current time
+    auto now = std::chrono::system_clock::now();
+    auto now_time_t = std::chrono::system_clock::to_time_t(now);
+
+    std::tm tm = {};
+    std::istringstream ss(expires_at);
+    ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    if (ss.fail()) {
+        return false;  // Invalid format, assume not expired
+    }
+
+    auto expires_time_t = timegm(&tm);
+    return now_time_t > expires_time_t;
+}
+
+ApiKeyService::ApiKeyService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+ApiKeyService::~ApiKeyService() = default;
+
+// Move operations not supported due to reference member
+ApiKeyService::ApiKeyService(ApiKeyService&& other) noexcept
+    : db_client_(other.db_client_), initialized_(other.initialized_) {}
+
+auto ApiKeyService::operator=(ApiKeyService&& /*other*/) noexcept -> ApiKeyService& {
+    // Cannot reassign reference member, so this is essentially a no-op
+    return *this;
+}
+
+auto ApiKeyService::GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time_t = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        now.time_since_epoch()) % 1000;
+
+    std::ostringstream oss;
+    oss << std::put_time(std::gmtime(&time_t), "%Y-%m-%dT%H:%M:%S");
+    oss << '.' << std::setfill('0') << std::setw(3) << ms.count() << 'Z';
+    return oss.str();
+}
+
+auto ApiKeyService::GenerateRawKey() -> std::string {
+    // Generate random bytes
+    std::vector<unsigned char> random_bytes(kKeyLength);
+    randombytes_buf(random_bytes.data(), random_bytes.size());
+
+    // Convert to hex string
+    std::ostringstream oss;
+    oss << kKeyPrefix;
+    for (unsigned char byte : random_bytes) {
+        oss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(byte);
+    }
+
+    return oss.str();
+}
+
+auto ApiKeyService::HashKey(const std::string& raw_key) -> std::string {
+    // Use SHA256 for hashing API keys (fast verification is important)
+    unsigned char hash[crypto_hash_sha256_BYTES];
+    crypto_hash_sha256(hash, reinterpret_cast<const unsigned char*>(raw_key.data()), raw_key.size());
+
+    // Convert to hex string
+    std::ostringstream oss;
+    for (unsigned char byte : hash) {
+        oss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(byte);
+    }
+
+    return oss.str();
+}
+
+auto ApiKeyService::GetKeyPrefix(const std::string& raw_key) -> std::string {
+    // Return first 12 characters (e.g., "sk_abc12345")
+    if (raw_key.length() > 12) {
+        return raw_key.substr(0, 12) + "...";
+    }
+    return raw_key;
+}
+
+auto ApiKeyService::DocumentToApiKey(const ::smartbotic::database::Document& doc) -> ApiKey {
+    ApiKey key;
+    key.id = doc.id();
+
+    if (doc.has_data()) {
+        const auto& data = doc.data();
+        key.user_id = GetStringValue(data, "user_id");
+        key.name = GetStringValue(data, "name");
+        key.key_prefix = GetStringValue(data, "key_prefix");
+        key.key_hash = GetStringValue(data, "key_hash");
+        key.permissions = GetArrayValue(data, "permissions");
+        key.created_at = GetStringValue(data, "created_at");
+        key.updated_at = GetStringValue(data, "updated_at");
+        key.deleted_at = GetStringValue(data, "deleted_at");
+        key.last_used_at = GetStringValue(data, "last_used_at");
+        key.expires_at = GetStringValue(data, "expires_at");
+    }
+
+    return key;
+}
+
+auto ApiKeyService::Initialize() -> bool {
+    if (initialized_) {
+        return true;
+    }
+
+    auto* collection_service = db_client_.GetCollectionService();
+    if (collection_service == nullptr) {
+        spdlog::error("Collection service not available");
+        return false;
+    }
+
+    // Check if collection exists
+    ::smartbotic::database::GetCollectionMetadataRequest get_req;
+    get_req.set_name(kCollectionName);
+
+    grpc::ClientContext get_ctx;
+    ::smartbotic::database::CollectionMetadata metadata;
+    auto status = collection_service->GetCollectionMetadata(&get_ctx, get_req, &metadata);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kCollectionName);
+    } else {
+        // Create the collection
+        ::smartbotic::database::CreateCollectionRequest create_req;
+        create_req.set_name(kCollectionName);
+
+        // Add index on user_id for listing user's keys
+        auto* user_index = create_req.add_indexes();
+        user_index->set_collection(kCollectionName);
+        user_index->set_index_name("user_id_idx");
+        user_index->add_fields("user_id");
+        user_index->set_unique(false);
+        user_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+        // Add index on key_hash for validation lookups
+        auto* hash_index = create_req.add_indexes();
+        hash_index->set_collection(kCollectionName);
+        hash_index->set_index_name("key_hash_unique");
+        hash_index->add_fields("key_hash");
+        hash_index->set_unique(true);
+        hash_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+        grpc::ClientContext create_ctx;
+        ::smartbotic::database::CollectionMetadata created_metadata;
+        status = collection_service->CreateCollection(&create_ctx, create_req, &created_metadata);
+
+        if (!status.ok()) {
+            spdlog::error("Failed to create {} collection: {}", kCollectionName, status.error_message());
+            return false;
+        }
+
+        spdlog::info("Created system collection {}", kCollectionName);
+    }
+
+    initialized_ = true;
+    return true;
+}
+
+auto ApiKeyService::CreateApiKey(const CreateApiKeyRequest& request) -> CreateApiKeyResult {
+    if (!initialized_) {
+        return CreateApiKeyResult{.success = false, .error = "ApiKeyService not initialized", .api_key = std::nullopt, .raw_key = ""};
+    }
+
+    // Validate request
+    if (request.user_id.empty()) {
+        return CreateApiKeyResult{.success = false, .error = "User ID is required", .api_key = std::nullopt, .raw_key = ""};
+    }
+
+    if (request.name.empty()) {
+        return CreateApiKeyResult{.success = false, .error = "Name is required", .api_key = std::nullopt, .raw_key = ""};
+    }
+
+    // Generate the raw key
+    std::string raw_key = GenerateRawKey();
+    std::string key_hash = HashKey(raw_key);
+    std::string key_prefix = GetKeyPrefix(raw_key);
+
+    // Create the document
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return CreateApiKeyResult{.success = false, .error = "Document service not available", .api_key = std::nullopt, .raw_key = ""};
+    }
+
+    ::smartbotic::database::CreateDocumentRequest create_req;
+    create_req.set_collection(kCollectionName);
+
+    auto timestamp = GetCurrentTimestamp();
+    auto* data = create_req.mutable_data();
+    SetStringValue(data, "user_id", request.user_id);
+    SetStringValue(data, "name", request.name);
+    SetStringValue(data, "key_prefix", key_prefix);
+    SetStringValue(data, "key_hash", key_hash);
+    SetArrayValue(data, "permissions", request.permissions);
+    SetStringValue(data, "created_at", timestamp);
+    SetStringValue(data, "updated_at", timestamp);
+    SetStringValue(data, "deleted_at", "");
+    SetStringValue(data, "last_used_at", "");
+    SetStringValue(data, "expires_at", request.expires_at);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document created_doc;
+    auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create API key: {}", status.error_message());
+        return CreateApiKeyResult{.success = false, .error = status.error_message(), .api_key = std::nullopt, .raw_key = ""};
+    }
+
+    spdlog::info("Created API key {} for user {}", created_doc.id(), request.user_id);
+
+    auto api_key = DocumentToApiKey(created_doc);
+    // Don't include hash in response
+    api_key.key_hash = "";
+
+    return CreateApiKeyResult{.success = true, .error = "", .api_key = api_key, .raw_key = raw_key};
+}
+
+auto ApiKeyService::GetApiKey(const std::string& id, bool include_revoked) -> ApiKeyResult {
+    if (!initialized_) {
+        return ApiKeyResult{.success = false, .error = "ApiKeyService not initialized", .api_key = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return ApiKeyResult{.success = false, .error = "Document service not available", .api_key = std::nullopt};
+    }
+
+    ::smartbotic::database::GetDocumentRequest get_req;
+    get_req.set_collection(kCollectionName);
+    get_req.set_id(id);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document doc;
+    auto status = doc_service->GetDocument(&ctx, get_req, &doc);
+
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return ApiKeyResult{.success = false, .error = "API key not found", .api_key = std::nullopt};
+        }
+        return ApiKeyResult{.success = false, .error = status.error_message(), .api_key = std::nullopt};
+    }
+
+    auto api_key = DocumentToApiKey(doc);
+
+    if (!include_revoked && api_key.IsRevoked()) {
+        return ApiKeyResult{.success = false, .error = "API key not found", .api_key = std::nullopt};
+    }
+
+    // Don't include hash in response
+    api_key.key_hash = "";
+
+    return ApiKeyResult{.success = true, .error = "", .api_key = api_key};
+}
+
+auto ApiKeyService::ListApiKeys(const std::string& user_id, bool include_revoked) -> ApiKeyListResult {
+    if (!initialized_) {
+        return ApiKeyListResult{.success = false, .error = "ApiKeyService not initialized", .api_keys = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return ApiKeyListResult{.success = false, .error = "Query service not available", .api_keys = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(100);
+
+    // Build filter for user
+    auto* filter = query_req.mutable_filter();
+
+    if (!include_revoked) {
+        // Filter: user_id = X AND deleted_at = ""
+        auto* composite = filter->mutable_composite();
+        composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+        auto* user_filter = composite->add_filters()->mutable_field();
+        user_filter->set_field("user_id");
+        user_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        user_filter->mutable_value()->set_string_value(user_id);
+
+        auto* del_filter = composite->add_filters()->mutable_field();
+        del_filter->set_field("deleted_at");
+        del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        del_filter->mutable_value()->set_string_value("");
+    } else {
+        // Filter: user_id = X
+        auto* user_filter = filter->mutable_field();
+        user_filter->set_field("user_id");
+        user_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        user_filter->mutable_value()->set_string_value(user_id);
+    }
+
+    // Order by created_at descending (newest first)
+    auto* order = query_req.add_order_by();
+    order->set_field("created_at");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_DESCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return ApiKeyListResult{.success = false, .error = status.error_message(), .api_keys = {}, .total_count = 0};
+    }
+
+    ApiKeyListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        auto api_key = DocumentToApiKey(doc);
+        // Don't include hash in listing
+        api_key.key_hash = "";
+        result.api_keys.push_back(api_key);
+    }
+
+    return result;
+}
+
+auto ApiKeyService::RevokeApiKey(const std::string& id, const std::string& user_id) -> ApiKeyResult {
+    if (!initialized_) {
+        return ApiKeyResult{.success = false, .error = "ApiKeyService not initialized", .api_key = std::nullopt};
+    }
+
+    // First get the key to verify ownership
+    auto existing = GetApiKey(id, false);
+    if (!existing.success || !existing.api_key) {
+        return ApiKeyResult{.success = false, .error = "API key not found", .api_key = std::nullopt};
+    }
+
+    if (existing.api_key->user_id != user_id) {
+        return ApiKeyResult{.success = false, .error = "API key does not belong to this user", .api_key = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return ApiKeyResult{.success = false, .error = "Document service not available", .api_key = std::nullopt};
+    }
+
+    auto timestamp = GetCurrentTimestamp();
+
+    // Soft delete by setting deleted_at
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);
+
+    auto* data = update_req.mutable_data();
+    SetStringValue(data, "deleted_at", timestamp);
+    SetStringValue(data, "updated_at", timestamp);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return ApiKeyResult{.success = false, .error = status.error_message(), .api_key = std::nullopt};
+    }
+
+    auto api_key = *existing.api_key;
+    api_key.deleted_at = timestamp;
+    api_key.updated_at = timestamp;
+
+    spdlog::info("Revoked API key {} for user {}", id, user_id);
+    return ApiKeyResult{.success = true, .error = "", .api_key = api_key};
+}
+
+auto ApiKeyService::ValidateApiKey(const std::string& raw_key) -> ValidateApiKeyResult {
+    if (!initialized_) {
+        return ValidateApiKeyResult{.valid = false, .error = "ApiKeyService not initialized", .api_key = std::nullopt};
+    }
+
+    // Check key format
+    if (raw_key.length() < 3 || raw_key.substr(0, 3) != kKeyPrefix) {
+        return ValidateApiKeyResult{.valid = false, .error = "Invalid API key format", .api_key = std::nullopt};
+    }
+
+    // Hash the key
+    std::string key_hash = HashKey(raw_key);
+
+    // Look up by hash
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return ValidateApiKeyResult{.valid = false, .error = "Query service not available", .api_key = std::nullopt};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(1);
+
+    // Filter: key_hash = X AND deleted_at = ""
+    auto* filter = query_req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* hash_filter = composite->add_filters()->mutable_field();
+    hash_filter->set_field("key_hash");
+    hash_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    hash_filter->mutable_value()->set_string_value(key_hash);
+
+    auto* del_filter = composite->add_filters()->mutable_field();
+    del_filter->set_field("deleted_at");
+    del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    del_filter->mutable_value()->set_string_value("");
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return ValidateApiKeyResult{.valid = false, .error = status.error_message(), .api_key = std::nullopt};
+    }
+
+    if (response.documents().empty()) {
+        return ValidateApiKeyResult{.valid = false, .error = "Invalid API key", .api_key = std::nullopt};
+    }
+
+    auto api_key = DocumentToApiKey(response.documents(0));
+
+    // Check expiration
+    if (api_key.IsExpired()) {
+        return ValidateApiKeyResult{.valid = false, .error = "API key has expired", .api_key = std::nullopt};
+    }
+
+    // Update last_used_at asynchronously (don't wait for result)
+    UpdateLastUsed(api_key.id);
+
+    // Don't include hash in response
+    api_key.key_hash = "";
+
+    return ValidateApiKeyResult{.valid = true, .error = "", .api_key = api_key};
+}
+
+void ApiKeyService::UpdateLastUsed(const std::string& id) {
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return;
+    }
+
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);
+
+    auto* data = update_req.mutable_data();
+    SetStringValue(data, "last_used_at", GetCurrentTimestamp());
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    // Fire and forget - don't check result
+    doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+}
+
+}  // namespace smartbotic::webserver

+ 292 - 0
webserver/src/auth_service.cpp

@@ -0,0 +1,292 @@
+#include "smartbotic/webserver/auth_service.hpp"
+
+#include <jwt-cpp/traits/nlohmann-json/traits.h>
+#include <sodium.h>
+#include <spdlog/spdlog.h>
+
+#include <chrono>
+#include <random>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+auto GenerateUuid() -> std::string {
+    static std::random_device rd;
+    static std::mt19937 gen(rd());
+    static std::uniform_int_distribution<> dis(0, 15);
+    static const char* hex_chars = "0123456789abcdef";
+
+    std::string uuid;
+    uuid.reserve(36);
+    for (int i = 0; i < 36; ++i) {
+        if (i == 8 || i == 13 || i == 18 || i == 23) {
+            uuid += '-';
+        } else {
+            uuid += hex_chars[dis(gen)];
+        }
+    }
+    return uuid;
+}
+
+}  // namespace
+
+AuthService::AuthService(const JwtConfig& config) : config_(config) {
+    // Initialize libsodium if not already done
+    if (sodium_init() < 0) {
+        spdlog::warn("libsodium initialization failed or already initialized");
+    }
+}
+
+AuthService::~AuthService() = default;
+
+AuthService::AuthService(AuthService&&) noexcept = default;
+auto AuthService::operator=(AuthService&&) noexcept -> AuthService& = default;
+
+auto AuthService::GenerateTokens(const std::string& user_id, const std::string& email,
+                                  const std::vector<std::string>& workspace_ids,
+                                  const std::vector<std::string>& groups) -> TokenPair {
+    using traits = jwt::traits::nlohmann_json;
+
+    auto now = std::chrono::system_clock::now();
+    auto access_exp = now + std::chrono::seconds(config_.access_token_expiry);
+    auto refresh_exp = now + std::chrono::seconds(config_.refresh_token_expiry);
+
+    std::string access_jti = GenerateJti();
+    std::string refresh_jti = GenerateJti();
+
+    // Build workspace_ids and groups arrays for JWT
+    nlohmann::json ws_json = workspace_ids;
+    nlohmann::json groups_json = groups;
+
+    // Create access token
+    auto access_token = jwt::create<traits>()
+                            .set_issuer(config_.issuer)
+                            .set_subject(user_id)
+                            .set_type("JWT")
+                            .set_id(access_jti)
+                            .set_issued_at(now)
+                            .set_expires_at(access_exp)
+                            .set_payload_claim("email", jwt::basic_claim<traits>(email))
+                            .set_payload_claim("workspace_ids", jwt::basic_claim<traits>(ws_json))
+                            .set_payload_claim("groups", jwt::basic_claim<traits>(groups_json))
+                            .set_payload_claim("token_type", jwt::basic_claim<traits>(std::string("access")))
+                            .sign(jwt::algorithm::hs256{config_.secret});
+
+    // Create refresh token (contains user info for renewal)
+    auto refresh_token = jwt::create<traits>()
+                             .set_issuer(config_.issuer)
+                             .set_subject(user_id)
+                             .set_type("JWT")
+                             .set_id(refresh_jti)
+                             .set_issued_at(now)
+                             .set_expires_at(refresh_exp)
+                             .set_payload_claim("email", jwt::basic_claim<traits>(email))
+                             .set_payload_claim("workspace_ids", jwt::basic_claim<traits>(ws_json))
+                             .set_payload_claim("groups", jwt::basic_claim<traits>(groups_json))
+                             .set_payload_claim("token_type", jwt::basic_claim<traits>(std::string("refresh")))
+                             .sign(jwt::algorithm::hs256{config_.secret});
+
+    spdlog::debug("Generated token pair for user {} (access_jti: {}, refresh_jti: {})",
+                  user_id, access_jti, refresh_jti);
+
+    return TokenPair{
+        .access_token = access_token,
+        .refresh_token = refresh_token,
+        .access_expires_in = config_.access_token_expiry,
+        .refresh_expires_in = config_.refresh_token_expiry};
+}
+
+auto AuthService::ValidateAccessToken(const std::string& token) -> TokenValidation {
+    using traits = jwt::traits::nlohmann_json;
+
+    try {
+        auto decoded = jwt::decode<traits>(token);
+
+        // Create verifier
+        auto verifier = jwt::verify<traits>()
+                            .allow_algorithm(jwt::algorithm::hs256{config_.secret})
+                            .with_issuer(config_.issuer);
+
+        // Verify token
+        verifier.verify(decoded);
+
+        // Check token type
+        auto token_type = decoded.get_payload_claim("token_type").as_string();
+        if (token_type != "access") {
+            return TokenValidation{.valid = false, .error = "Not an access token", .user = std::nullopt};
+        }
+
+        // Check if token is invalidated
+        auto jti = decoded.get_id();
+        if (IsTokenInvalidated(jti)) {
+            return TokenValidation{.valid = false, .error = "Token has been invalidated", .user = std::nullopt};
+        }
+
+        // Extract user info
+        AuthUser user;
+        user.user_id = decoded.get_subject();
+        user.email = decoded.get_payload_claim("email").as_string();
+
+        auto ws_claim = decoded.get_payload_claim("workspace_ids").to_json();
+        if (ws_claim.is_array()) {
+            for (const auto& ws : ws_claim) {
+                user.workspace_ids.push_back(ws.get<std::string>());
+            }
+        }
+
+        auto groups_claim = decoded.get_payload_claim("groups").to_json();
+        if (groups_claim.is_array()) {
+            for (const auto& g : groups_claim) {
+                user.groups.push_back(g.get<std::string>());
+            }
+        }
+
+        return TokenValidation{.valid = true, .error = "", .user = user};
+    } catch (const jwt::error::token_verification_exception& e) {
+        spdlog::debug("Token verification failed: {}", e.what());
+        return TokenValidation{.valid = false, .error = e.what(), .user = std::nullopt};
+    } catch (const std::exception& e) {
+        spdlog::debug("Token parsing failed: {}", e.what());
+        return TokenValidation{.valid = false, .error = "Invalid token format", .user = std::nullopt};
+    }
+}
+
+auto AuthService::ValidateRefreshToken(const std::string& token) -> TokenValidation {
+    using traits = jwt::traits::nlohmann_json;
+
+    try {
+        auto decoded = jwt::decode<traits>(token);
+
+        // Create verifier
+        auto verifier = jwt::verify<traits>()
+                            .allow_algorithm(jwt::algorithm::hs256{config_.secret})
+                            .with_issuer(config_.issuer);
+
+        // Verify token
+        verifier.verify(decoded);
+
+        // Check token type
+        auto token_type = decoded.get_payload_claim("token_type").as_string();
+        if (token_type != "refresh") {
+            return TokenValidation{.valid = false, .error = "Not a refresh token", .user = std::nullopt};
+        }
+
+        // Check if token is invalidated
+        auto jti = decoded.get_id();
+        if (IsTokenInvalidated(jti)) {
+            return TokenValidation{.valid = false, .error = "Token has been invalidated", .user = std::nullopt};
+        }
+
+        // Extract user info
+        AuthUser user;
+        user.user_id = decoded.get_subject();
+        user.email = decoded.get_payload_claim("email").as_string();
+
+        auto ws_claim = decoded.get_payload_claim("workspace_ids").to_json();
+        if (ws_claim.is_array()) {
+            for (const auto& ws : ws_claim) {
+                user.workspace_ids.push_back(ws.get<std::string>());
+            }
+        }
+
+        auto groups_claim = decoded.get_payload_claim("groups").to_json();
+        if (groups_claim.is_array()) {
+            for (const auto& g : groups_claim) {
+                user.groups.push_back(g.get<std::string>());
+            }
+        }
+
+        return TokenValidation{.valid = true, .error = "", .user = user};
+    } catch (const jwt::error::token_verification_exception& e) {
+        spdlog::debug("Refresh token verification failed: {}", e.what());
+        return TokenValidation{.valid = false, .error = e.what(), .user = std::nullopt};
+    } catch (const std::exception& e) {
+        spdlog::debug("Refresh token parsing failed: {}", e.what());
+        return TokenValidation{.valid = false, .error = "Invalid token format", .user = std::nullopt};
+    }
+}
+
+auto AuthService::RefreshAccessToken(const std::string& refresh_token) -> std::optional<TokenPair> {
+    using traits = jwt::traits::nlohmann_json;
+
+    auto validation = ValidateRefreshToken(refresh_token);
+    if (!validation.valid || !validation.user) {
+        spdlog::debug("Refresh token validation failed: {}", validation.error);
+        return std::nullopt;
+    }
+
+    const auto& user = *validation.user;
+
+    // Invalidate the old refresh token (token rotation)
+    try {
+        auto decoded = jwt::decode<traits>(refresh_token);
+        invalidated_tokens_.insert(decoded.get_id());
+    } catch (...) {
+        // Ignore decoding errors here since we already validated
+    }
+
+    // Generate new token pair
+    return GenerateTokens(user.user_id, user.email, user.workspace_ids, user.groups);
+}
+
+auto AuthService::InvalidateRefreshToken(const std::string& refresh_token) -> bool {
+    using traits = jwt::traits::nlohmann_json;
+
+    try {
+        auto decoded = jwt::decode<traits>(refresh_token);
+        auto jti = decoded.get_id();
+        invalidated_tokens_.insert(jti);
+        spdlog::debug("Invalidated refresh token: {}", jti);
+        return true;
+    } catch (const std::exception& e) {
+        spdlog::debug("Failed to invalidate token: {}", e.what());
+        return false;
+    }
+}
+
+auto AuthService::IsTokenInvalidated(const std::string& jti) const -> bool {
+    return invalidated_tokens_.contains(jti);
+}
+
+auto AuthService::ExtractBearerToken(const std::string& auth_header) -> std::optional<std::string> {
+    const std::string prefix = "Bearer ";
+    if (auth_header.size() <= prefix.size()) {
+        return std::nullopt;
+    }
+    if (auth_header.substr(0, prefix.size()) != prefix) {
+        return std::nullopt;
+    }
+    return auth_header.substr(prefix.size());
+}
+
+auto AuthService::HashPassword(const std::string& password) -> std::string {
+    // Ensure libsodium is initialized
+    if (sodium_init() < 0) {
+        throw std::runtime_error("Failed to initialize libsodium");
+    }
+
+    char hash[crypto_pwhash_STRBYTES];
+    if (crypto_pwhash_str(hash, password.c_str(), password.length(),
+                          crypto_pwhash_OPSLIMIT_INTERACTIVE,
+                          crypto_pwhash_MEMLIMIT_INTERACTIVE) != 0) {
+        throw std::runtime_error("Password hashing failed (out of memory)");
+    }
+
+    return std::string(hash);
+}
+
+auto AuthService::VerifyPassword(const std::string& password, const std::string& hash) -> bool {
+    // Ensure libsodium is initialized
+    if (sodium_init() < 0) {
+        return false;
+    }
+
+    return crypto_pwhash_str_verify(hash.c_str(), password.c_str(), password.length()) == 0;
+}
+
+auto AuthService::GenerateJti() -> std::string {
+    return GenerateUuid();
+}
+
+}  // namespace smartbotic::webserver

+ 483 - 0
webserver/src/authorization_service.cpp

@@ -0,0 +1,483 @@
+#include "smartbotic/webserver/authorization_service.hpp"
+#include "smartbotic/webserver/permissions.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include <algorithm>
+#include <unordered_set>
+
+namespace smartbotic::webserver {
+
+AuthorizationService::AuthorizationService(GroupService& group_service,
+                                             CollectionService& collection_service)
+    : group_service_(group_service), collection_service_(collection_service) {}
+
+AuthorizationService::~AuthorizationService() = default;
+
+AuthorizationService::AuthorizationService(AuthorizationService&& other) noexcept
+    : group_service_(other.group_service_),
+      collection_service_(other.collection_service_),
+      permission_cache_(std::move(other.permission_cache_)) {}
+
+auto AuthorizationService::operator=(AuthorizationService&& /*other*/) noexcept -> AuthorizationService& {
+    // Cannot reassign reference members
+    return *this;
+}
+
+auto AuthorizationService::Initialize() -> bool {
+    // Create additional system groups if they don't exist
+    // (superadmin is created by GroupService::Initialize)
+
+    // system_readonly - can read all system resources
+    if (!group_service_.CreateSystemGroup(
+            std::string(permissions::kSystemReadonlyGroupName),
+            {"system:*:read"})) {
+        spdlog::warn("Failed to create system_readonly group");
+    }
+
+    // authenticated - basic permissions for any logged-in user
+    if (!group_service_.CreateSystemGroup(
+            std::string(permissions::kAuthenticatedGroupName),
+            {std::string(permissions::kLoginAllow)})) {
+        spdlog::warn("Failed to create authenticated group");
+    }
+
+    spdlog::info("AuthorizationService initialized with system groups");
+    return true;
+}
+
+// ============================================================================
+// Core Permission Checks
+// ============================================================================
+
+auto AuthorizationService::HasPermission(const AuthUser& user,
+                                          std::string_view permission) -> bool {
+    auto permissions = GetEffectivePermissions(user);
+    return CheckPermission(permissions, permission);
+}
+
+auto AuthorizationService::HasAnyPermission(const AuthUser& user,
+                                             const std::vector<std::string>& permissions) -> bool {
+    auto user_perms = GetEffectivePermissions(user);
+    for (const auto& perm : permissions) {
+        if (CheckPermission(user_perms, perm)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+auto AuthorizationService::HasAllPermissions(const AuthUser& user,
+                                              const std::vector<std::string>& permissions) -> bool {
+    auto user_perms = GetEffectivePermissions(user);
+    for (const auto& perm : permissions) {
+        if (!CheckPermission(user_perms, perm)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+auto AuthorizationService::CheckPermission(const std::vector<std::string>& user_permissions,
+                                            std::string_view required) -> bool {
+    for (const auto& granted : user_permissions) {
+        if (permissions::MatchesPermission(required, granted)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+// ============================================================================
+// System Permissions
+// ============================================================================
+
+auto AuthorizationService::CanLogin(const AuthUser& user) -> bool {
+    auto perms = GetEffectivePermissions(user);
+
+    // Check for explicit deny first
+    if (CheckPermission(perms, permissions::kLoginDeny)) {
+        return false;
+    }
+
+    // Check for allow
+    return CheckPermission(perms, permissions::kLoginAllow);
+}
+
+auto AuthorizationService::IsSuperadmin(const AuthUser& user) -> bool {
+    auto perms = GetEffectivePermissions(user);
+    return CheckPermission(perms, permissions::kWildcard);
+}
+
+// ============================================================================
+// Collection Permissions
+// ============================================================================
+
+auto AuthorizationService::CanReadCollection(const AuthUser& user,
+                                              const std::string& workspace_id,
+                                              const std::string& collection) -> bool {
+    // Check for read_all or read_own permission
+    auto read_all = permissions::BuildCollectionPermission(workspace_id, collection, "read_all");
+    auto read_own = permissions::BuildCollectionPermission(workspace_id, collection, "read_own");
+
+    return HasAnyPermission(user, {read_all, read_own});
+}
+
+auto AuthorizationService::CanReadAllDocuments(const AuthUser& user,
+                                                const std::string& workspace_id,
+                                                const std::string& collection) -> bool {
+    auto perm = permissions::BuildCollectionPermission(workspace_id, collection, "read_all");
+    return HasPermission(user, perm);
+}
+
+auto AuthorizationService::CanReadDocument(const AuthUser& user,
+                                            const std::string& workspace_id,
+                                            const std::string& collection,
+                                            const std::string& doc_owner) -> bool {
+    // Check read_all first
+    if (CanReadAllDocuments(user, workspace_id, collection)) {
+        return true;
+    }
+
+    // Check read_own if user is the owner
+    if (user.user_id == doc_owner) {
+        auto perm = permissions::BuildCollectionPermission(workspace_id, collection, "read_own");
+        return HasPermission(user, perm);
+    }
+
+    return false;
+}
+
+auto AuthorizationService::CanCreateDocument(const AuthUser& user,
+                                              const std::string& workspace_id,
+                                              const std::string& collection) -> bool {
+    auto perm = permissions::BuildCollectionPermission(workspace_id, collection, "create");
+    return HasPermission(user, perm);
+}
+
+auto AuthorizationService::CanWriteDocument(const AuthUser& user,
+                                             const std::string& workspace_id,
+                                             const std::string& collection,
+                                             const std::string& doc_owner) -> bool {
+    // Check write_all first
+    auto write_all = permissions::BuildCollectionPermission(workspace_id, collection, "write_all");
+    if (HasPermission(user, write_all)) {
+        return true;
+    }
+
+    // Check write_own if user is the owner
+    if (user.user_id == doc_owner) {
+        auto write_own = permissions::BuildCollectionPermission(workspace_id, collection, "write_own");
+        return HasPermission(user, write_own);
+    }
+
+    return false;
+}
+
+auto AuthorizationService::CanDeleteDocument(const AuthUser& user,
+                                              const std::string& workspace_id,
+                                              const std::string& collection,
+                                              const std::string& doc_owner) -> bool {
+    // Check delete_all first
+    auto delete_all = permissions::BuildCollectionPermission(workspace_id, collection, "delete_all");
+    if (HasPermission(user, delete_all)) {
+        return true;
+    }
+
+    // Check delete_own if user is the owner
+    if (user.user_id == doc_owner) {
+        auto delete_own = permissions::BuildCollectionPermission(workspace_id, collection, "delete_own");
+        return HasPermission(user, delete_own);
+    }
+
+    return false;
+}
+
+auto AuthorizationService::CanManageCollection(const AuthUser& user,
+                                                const std::string& workspace_id,
+                                                const std::string& collection) -> bool {
+    auto perm = permissions::BuildCollectionPermission(workspace_id, collection, "manage");
+    return HasPermission(user, perm);
+}
+
+// ============================================================================
+// Field Filtering
+// ============================================================================
+
+auto AuthorizationService::GetFieldPermissions(const std::string& workspace_id,
+                                                const std::string& collection)
+    -> std::vector<FieldPermission> {
+    auto result = collection_service_.GetCollection(workspace_id, collection);
+    if (result.success && result.collection) {
+        return result.collection->settings.field_permissions;
+    }
+    return {};
+}
+
+auto AuthorizationService::IsMemberOfAnyGroup(const AuthUser& user,
+                                               const std::vector<std::string>& group_ids) -> bool {
+    for (const auto& group_id : group_ids) {
+        for (const auto& user_group : user.groups) {
+            if (user_group == group_id) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+auto AuthorizationService::FilterDocumentFields(const AuthUser& user,
+                                                  const std::string& workspace_id,
+                                                  const std::string& collection,
+                                                  nlohmann::json& document) -> void {
+    // Superadmins see everything
+    if (IsSuperadmin(user)) {
+        return;
+    }
+
+    auto field_perms = GetFieldPermissions(workspace_id, collection);
+    if (field_perms.empty()) {
+        // No field restrictions defined
+        return;
+    }
+
+    // Check each field permission rule
+    std::vector<std::string> fields_to_remove;
+    for (auto& [key, value] : document.items()) {
+        // Skip system fields (always visible)
+        if (!key.empty() && key[0] == '_') {
+            continue;
+        }
+
+        bool can_read = true;
+        for (const auto& fp : field_perms) {
+            if (fp.field_name == key || fp.field_name == "*") {
+                // Found a rule for this field
+                if (!fp.read_groups.empty()) {
+                    // If read_groups is specified, user must be in one of them
+                    can_read = IsMemberOfAnyGroup(user, fp.read_groups);
+                }
+                if (fp.field_name == key) {
+                    break;  // Exact match, stop looking
+                }
+            }
+        }
+
+        if (!can_read) {
+            fields_to_remove.push_back(key);
+        }
+    }
+
+    // Remove fields user cannot see
+    for (const auto& field : fields_to_remove) {
+        document.erase(field);
+    }
+}
+
+auto AuthorizationService::CanReadField(const AuthUser& user,
+                                         const std::string& workspace_id,
+                                         const std::string& collection,
+                                         const std::string& field) -> bool {
+    // Superadmins can read all
+    if (IsSuperadmin(user)) {
+        return true;
+    }
+
+    // Check field permission
+    auto perm = permissions::BuildFieldPermission(workspace_id, collection, field, "read");
+    if (HasPermission(user, perm)) {
+        return true;
+    }
+
+    // Check wildcard field permission
+    auto wildcard_perm = permissions::BuildFieldPermission(workspace_id, collection, "*", "read");
+    if (HasPermission(user, wildcard_perm)) {
+        return true;
+    }
+
+    // Check field-level permissions defined on collection
+    auto field_perms = GetFieldPermissions(workspace_id, collection);
+    for (const auto& fp : field_perms) {
+        if (fp.field_name == field || fp.field_name == "*") {
+            if (fp.read_groups.empty()) {
+                return true;  // No restrictions
+            }
+            return IsMemberOfAnyGroup(user, fp.read_groups);
+        }
+    }
+
+    return true;  // No restrictions found, allow by default
+}
+
+auto AuthorizationService::CanWriteField(const AuthUser& user,
+                                          const std::string& workspace_id,
+                                          const std::string& collection,
+                                          const std::string& field) -> bool {
+    // Superadmins can write all
+    if (IsSuperadmin(user)) {
+        return true;
+    }
+
+    // Check field permission
+    auto perm = permissions::BuildFieldPermission(workspace_id, collection, field, "write");
+    if (HasPermission(user, perm)) {
+        return true;
+    }
+
+    // Check wildcard field permission
+    auto wildcard_perm = permissions::BuildFieldPermission(workspace_id, collection, "*", "write");
+    if (HasPermission(user, wildcard_perm)) {
+        return true;
+    }
+
+    // Check field-level permissions defined on collection
+    auto field_perms = GetFieldPermissions(workspace_id, collection);
+    for (const auto& fp : field_perms) {
+        if (fp.field_name == field || fp.field_name == "*") {
+            if (fp.write_groups.empty()) {
+                return true;  // No restrictions
+            }
+            return IsMemberOfAnyGroup(user, fp.write_groups);
+        }
+    }
+
+    return true;  // No restrictions found, allow by default
+}
+
+// ============================================================================
+// Workspace Permissions
+// ============================================================================
+
+auto AuthorizationService::IsWorkspaceAdmin(const AuthUser& user,
+                                             const std::string& workspace_id) -> bool {
+    auto perm = permissions::BuildWorkspacePermission(workspace_id, "admin");
+    return HasPermission(user, perm);
+}
+
+auto AuthorizationService::CanManageWorkspaceMembers(const AuthUser& user,
+                                                      const std::string& workspace_id) -> bool {
+    // Workspace admin can manage members
+    if (IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    auto perm = permissions::BuildWorkspacePermission(workspace_id, "manage_members");
+    return HasPermission(user, perm);
+}
+
+auto AuthorizationService::CanManageWorkspaceGroups(const AuthUser& user,
+                                                     const std::string& workspace_id) -> bool {
+    // Workspace admin can manage groups
+    if (IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    auto perm = permissions::BuildWorkspacePermission(workspace_id, "manage_groups");
+    return HasPermission(user, perm);
+}
+
+// ============================================================================
+// Group Visibility
+// ============================================================================
+
+auto AuthorizationService::GetVisibleGroups(const AuthUser& user,
+                                             const std::string& workspace_id) -> std::vector<Group> {
+    std::vector<Group> visible_groups;
+
+    // Get workspace groups
+    auto ws_groups = group_service_.ListGroups(workspace_id);
+    if (ws_groups.success) {
+        for (const auto& group : ws_groups.groups) {
+            visible_groups.push_back(group);
+        }
+    }
+
+    // Get global groups (if user has permission to see them)
+    auto global_groups = group_service_.ListGlobalGroups();
+    if (global_groups.success) {
+        bool can_see_system = CanSeeSystemGroups(user);
+        for (const auto& group : global_groups.groups) {
+            // Filter out system groups if user can't see them
+            if (group.IsSystemGroup() && !can_see_system) {
+                continue;
+            }
+            visible_groups.push_back(group);
+        }
+    }
+
+    return visible_groups;
+}
+
+auto AuthorizationService::CanSeeSystemGroups(const AuthUser& user) -> bool {
+    return HasPermission(user, permissions::kSystemGroupsRead);
+}
+
+// ============================================================================
+// Permission Resolution
+// ============================================================================
+
+auto AuthorizationService::GetEffectivePermissions(const AuthUser& user)
+    -> std::vector<std::string> {
+    auto now = std::chrono::steady_clock::now();
+
+    // Check cache
+    auto cache_it = permission_cache_.find(user.user_id);
+    if (cache_it != permission_cache_.end()) {
+        if (now < cache_it->second.expiry) {
+            return cache_it->second.permissions;
+        }
+        // Cache expired, remove it
+        permission_cache_.erase(cache_it);
+    }
+
+    // Resolve permissions from groups
+    auto permissions = ResolveGroupPermissions(user.groups);
+
+    // Cache the result
+    CacheEntry entry;
+    entry.permissions = permissions;
+    entry.expiry = now + std::chrono::seconds(kCacheTtlSeconds);
+    permission_cache_[user.user_id] = std::move(entry);
+
+    return permissions;
+}
+
+auto AuthorizationService::ResolveGroupPermissions(const std::vector<std::string>& group_ids)
+    -> std::vector<std::string> {
+    std::vector<std::string> all_permissions;
+    std::unordered_set<std::string> seen_groups;
+
+    for (const auto& group_id : group_ids) {
+        if (seen_groups.contains(group_id)) {
+            continue;
+        }
+
+        auto group_perms = group_service_.GetEffectivePermissions(group_id);
+        for (const auto& perm : group_perms) {
+            all_permissions.push_back(perm);
+        }
+        seen_groups.insert(group_id);
+    }
+
+    // Remove duplicates while preserving order
+    std::vector<std::string> unique_permissions;
+    std::unordered_set<std::string> seen_perms;
+    for (const auto& perm : all_permissions) {
+        if (!seen_perms.contains(perm)) {
+            unique_permissions.push_back(perm);
+            seen_perms.insert(perm);
+        }
+    }
+
+    return unique_permissions;
+}
+
+auto AuthorizationService::InvalidateCache(const std::string& user_id) -> void {
+    permission_cache_.erase(user_id);
+}
+
+auto AuthorizationService::InvalidateAllCaches() -> void {
+    permission_cache_.clear();
+}
+
+}  // namespace smartbotic::webserver

+ 485 - 0
webserver/src/collection_service.cpp

@@ -0,0 +1,485 @@
+#include "smartbotic/webserver/collection_service.hpp"
+
+#include <chrono>
+#include <functional>
+#include <iomanip>
+#include <regex>
+#include <sstream>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+/// System collection for storing collection settings/metadata
+constexpr const char* kSettingsCollection = "_collection_settings";
+
+/// Convert timestamp to ISO 8601 string
+auto TimestampToString(const ::smartbotic::database::Timestamp& ts) -> std::string {
+    if (ts.seconds() == 0 && ts.nanos() == 0) {
+        return "";
+    }
+
+    std::time_t time = ts.seconds();
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+
+    if (ts.nanos() > 0) {
+        oss << "." << std::setfill('0') << std::setw(3) << (ts.nanos() / 1000000);
+    }
+    oss << "Z";
+
+    return oss.str();
+}
+
+}  // namespace
+
+CollectionService::CollectionService(DatabaseClient& db_client) : db_client_(db_client) {
+    // Ensure settings collection exists
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateCollectionRequest req;
+    ::smartbotic::database::CollectionMetadata resp;
+
+    req.set_name(kSettingsCollection);
+
+    auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp);
+    if (status.ok()) {
+        spdlog::info("Created system collection {}", kSettingsCollection);
+    } else if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) {
+        spdlog::debug("System collection {} already exists", kSettingsCollection);
+    } else {
+        spdlog::warn("Failed to ensure {} collection: {}", kSettingsCollection, status.error_message());
+    }
+}
+
+CollectionService::~CollectionService() = default;
+
+CollectionService::CollectionService(CollectionService&&) noexcept = default;
+auto CollectionService::operator=(CollectionService&& /*other*/) noexcept -> CollectionService& {
+    // Cannot reassign reference member, so this is essentially a no-op
+    return *this;
+}
+
+auto CollectionService::CreateCollection(const CreateCollectionRequest& request) -> CollectionResult {
+    // Validate collection name
+    if (!IsValidCollectionName(request.name)) {
+        return {.success = false, .error = "Invalid collection name. Must be alphanumeric with underscores, 1-64 chars.", .collection = std::nullopt};
+    }
+
+    // System collection names are reserved
+    if (IsSystemCollection(request.name)) {
+        return {.success = false, .error = "Collection name is reserved for system use", .collection = std::nullopt};
+    }
+
+    std::string internal_name = GetInternalName(request.workspace_id, request.name);
+
+    // Create the collection via gRPC
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateCollectionRequest req;
+    ::smartbotic::database::CollectionMetadata resp;
+
+    req.set_name(internal_name);
+
+    auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) {
+            return {.success = false, .error = "Collection already exists", .collection = std::nullopt};
+        }
+        spdlog::error("Failed to create collection {}: {}", internal_name, status.error_message());
+        return {.success = false, .error = "Failed to create collection: " + status.error_message(), .collection = std::nullopt};
+    }
+
+    // Store settings in metadata collection
+    if (!StoreSettings(request.workspace_id, request.name, request.settings)) {
+        spdlog::warn("Collection {} created but failed to store settings", internal_name);
+    }
+
+    auto info = MetadataToInfo(request.workspace_id, resp);
+    info.settings = request.settings;
+
+    spdlog::info("Created collection {} in workspace {}", request.name, request.workspace_id);
+    return {.success = true, .error = "", .collection = info};
+}
+
+auto CollectionService::GetCollection(const std::string& workspace_id, const std::string& name) -> CollectionResult {
+    std::string internal_name = GetInternalName(workspace_id, name);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetCollectionMetadataRequest req;
+    ::smartbotic::database::CollectionMetadata resp;
+
+    req.set_name(internal_name);
+
+    auto status = db_client_.GetCollectionService()->GetCollectionMetadata(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Collection not found", .collection = std::nullopt};
+        }
+        spdlog::error("Failed to get collection {}: {}", internal_name, status.error_message());
+        return {.success = false, .error = "Failed to get collection: " + status.error_message(), .collection = std::nullopt};
+    }
+
+    auto info = MetadataToInfo(workspace_id, resp);
+    info.settings = LoadSettings(workspace_id, name);
+
+    return {.success = true, .error = "", .collection = info};
+}
+
+auto CollectionService::ListCollections(const std::string& workspace_id) -> CollectionListResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::ListCollectionsRequest req;
+    ::smartbotic::database::ListCollectionsResponse resp;
+
+    req.set_page_size(1000);  // Get all collections
+
+    auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("Failed to list collections: {}", status.error_message());
+        return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0};
+    }
+
+    std::string prefix = GetInternalName(workspace_id, "");
+    std::vector<CollectionInfo> collections;
+
+    for (const auto& meta : resp.collections()) {
+        // Only include collections that belong to this workspace
+        if (meta.name().rfind(prefix, 0) == 0) {
+            auto info = MetadataToInfo(workspace_id, meta);
+            info.settings = LoadSettings(workspace_id, info.name);
+            collections.push_back(std::move(info));
+        }
+    }
+
+    int64_t count = static_cast<int64_t>(collections.size());
+    return {
+        .success = true,
+        .error = "",
+        .collections = std::move(collections),
+        .total_count = count
+    };
+}
+
+auto CollectionService::ListSystemCollections() -> CollectionListResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::ListCollectionsRequest req;
+    ::smartbotic::database::ListCollectionsResponse resp;
+
+    req.set_page_size(1000);
+
+    auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("Failed to list system collections: {}", status.error_message());
+        return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0};
+    }
+
+    std::vector<CollectionInfo> collections;
+
+    for (const auto& meta : resp.collections()) {
+        // System collections start with underscore and are not workspace-prefixed
+        if (!meta.name().empty() && meta.name()[0] == '_') {
+            CollectionInfo info;
+            info.name = meta.name();
+            info.workspace_id = "";  // Global collection
+            info.document_count = meta.document_count();
+            info.size_bytes = meta.size_bytes();
+            info.created_at = TimestampToString(meta.created_at());
+            info.updated_at = TimestampToString(meta.updated_at());
+            collections.push_back(std::move(info));
+        }
+    }
+
+    int64_t count = static_cast<int64_t>(collections.size());
+    return {
+        .success = true,
+        .error = "",
+        .collections = std::move(collections),
+        .total_count = count
+    };
+}
+
+auto CollectionService::UpdateCollection(const std::string& workspace_id, const std::string& name,
+                                          const CollectionSettings& settings) -> CollectionResult {
+    // First verify the collection exists
+    auto get_result = GetCollection(workspace_id, name);
+    if (!get_result.success) {
+        return get_result;
+    }
+
+    // Update settings in metadata collection
+    if (!StoreSettings(workspace_id, name, settings)) {
+        return {.success = false, .error = "Failed to update collection settings", .collection = std::nullopt};
+    }
+
+    // Return updated info
+    auto info = *get_result.collection;
+    info.settings = settings;
+
+    spdlog::info("Updated collection {} in workspace {}", name, workspace_id);
+    return {.success = true, .error = "", .collection = info};
+}
+
+auto CollectionService::DropCollection(const std::string& workspace_id, const std::string& name,
+                                        bool force) -> CollectionResult {
+    std::string internal_name = GetInternalName(workspace_id, name);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::DropCollectionRequest req;
+    ::smartbotic::database::DropCollectionResponse resp;
+
+    req.set_name(internal_name);
+    req.set_force(force);
+
+    auto status = db_client_.GetCollectionService()->DropCollection(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Collection not found", .collection = std::nullopt};
+        }
+        if (status.error_code() == grpc::StatusCode::FAILED_PRECONDITION) {
+            return {.success = false, .error = "Collection is not empty. Use force=true to drop anyway.", .collection = std::nullopt};
+        }
+        spdlog::error("Failed to drop collection {}: {}", internal_name, status.error_message());
+        return {.success = false, .error = "Failed to drop collection: " + status.error_message(), .collection = std::nullopt};
+    }
+
+    // Delete settings
+    DeleteSettings(workspace_id, name);
+
+    spdlog::info("Dropped collection {} from workspace {}", name, workspace_id);
+    return {.success = true, .error = "", .collection = std::nullopt};
+}
+
+auto CollectionService::IsValidCollectionName(const std::string& name) -> bool {
+    if (name.empty() || name.length() > 64) {
+        return false;
+    }
+
+    // Must start with letter or underscore, contain only alphanumeric and underscores
+    static const std::regex pattern("^[a-zA-Z_][a-zA-Z0-9_]*$");
+    return std::regex_match(name, pattern);
+}
+
+auto CollectionService::IsSystemCollection(const std::string& name) -> bool {
+    return !name.empty() && name[0] == '_';
+}
+
+auto CollectionService::GetInternalName(const std::string& workspace_id, const std::string& name) -> std::string {
+    return std::string(kCollectionPrefix) + workspace_id + "_" + name;
+}
+
+auto CollectionService::GetDisplayName(const std::string& workspace_id, const std::string& internal_name) -> std::string {
+    std::string prefix = GetInternalName(workspace_id, "");
+    if (internal_name.rfind(prefix, 0) == 0) {
+        return internal_name.substr(prefix.length());
+    }
+    return internal_name;
+}
+
+auto CollectionService::MetadataToInfo(const std::string& workspace_id,
+                                        const ::smartbotic::database::CollectionMetadata& meta) -> CollectionInfo {
+    CollectionInfo info;
+    info.name = GetDisplayName(workspace_id, meta.name());
+    info.workspace_id = workspace_id;
+    info.document_count = meta.document_count();
+    info.size_bytes = meta.size_bytes();
+    info.created_at = TimestampToString(meta.created_at());
+    info.updated_at = TimestampToString(meta.updated_at());
+    return info;
+}
+
+auto CollectionService::GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time_t = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
+
+    std::tm tm{};
+    gmtime_r(&time_t, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
+
+    return oss.str();
+}
+
+auto CollectionService::StoreSettings(const std::string& workspace_id, const std::string& name,
+                                       const CollectionSettings& settings) -> bool {
+    std::string doc_id = workspace_id + "_" + name;
+
+    nlohmann::json json_settings;
+    json_settings["workspace_id"] = workspace_id;
+    json_settings["collection_name"] = name;
+    json_settings["schema"] = settings.schema;
+    json_settings["is_system"] = settings.is_system;
+    json_settings["encrypted_fields"] = settings.encrypted_fields;
+    json_settings["ttl_seconds"] = settings.ttl_seconds;
+    json_settings["updated_at"] = GetCurrentTimestamp();
+
+    // Serialize field_permissions
+    nlohmann::json field_perms_json = nlohmann::json::array();
+    for (const auto& fp : settings.field_permissions) {
+        nlohmann::json fp_json;
+        fp_json["field_name"] = fp.field_name;
+        fp_json["read_groups"] = fp.read_groups;
+        fp_json["write_groups"] = fp.write_groups;
+        field_perms_json.push_back(fp_json);
+    }
+    json_settings["field_permissions"] = field_perms_json;
+
+    // Build the proto document using a recursive lambda to handle nested structures
+    std::function<void(const nlohmann::json&, ::smartbotic::database::Value*)> json_to_proto;
+    json_to_proto = [&json_to_proto](const nlohmann::json& j, ::smartbotic::database::Value* val) {
+        if (j.is_string()) {
+            val->set_string_value(j.get<std::string>());
+        } else if (j.is_boolean()) {
+            val->set_bool_value(j.get<bool>());
+        } else if (j.is_number_integer()) {
+            val->set_int_value(j.get<int64_t>());
+        } else if (j.is_number_float()) {
+            val->set_double_value(j.get<double>());
+        } else if (j.is_array()) {
+            auto* arr = val->mutable_array_value();
+            for (const auto& item : j) {
+                json_to_proto(item, arr->add_values());
+            }
+        } else if (j.is_object()) {
+            auto* map = val->mutable_map_value();
+            for (const auto& [k, v] : j.items()) {
+                json_to_proto(v, &(*map->mutable_fields())[k]);
+            }
+        } else if (j.is_null()) {
+            val->set_null_value(true);
+        }
+    };
+
+    ::smartbotic::database::MapValue data;
+    for (const auto& [key, value] : json_settings.items()) {
+        json_to_proto(value, &(*data.mutable_fields())[key]);
+    }
+
+    // Try update first, then create
+    {
+        grpc::ClientContext ctx;
+        ::smartbotic::database::UpdateDocumentRequest req;
+        ::smartbotic::database::Document resp;
+
+        req.set_collection(kSettingsCollection);
+        req.set_id(doc_id);
+        req.set_merge(true);
+        *req.mutable_data() = data;
+
+        auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
+        if (status.ok()) {
+            return true;
+        }
+    }
+
+    // Create if update failed
+    {
+        grpc::ClientContext ctx;
+        ::smartbotic::database::CreateDocumentRequest req;
+        ::smartbotic::database::Document resp;
+
+        req.set_collection(kSettingsCollection);
+        req.set_id(doc_id);
+        *req.mutable_data() = data;
+
+        auto status = db_client_.GetDocumentService()->CreateDocument(&ctx, req, &resp);
+        if (!status.ok()) {
+            spdlog::error("Failed to store collection settings: {}", status.error_message());
+            return false;
+        }
+    }
+
+    return true;
+}
+
+auto CollectionService::LoadSettings(const std::string& workspace_id, const std::string& name) -> CollectionSettings {
+    std::string doc_id = workspace_id + "_" + name;
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kSettingsCollection);
+    req.set_id(doc_id);
+
+    auto status = db_client_.GetDocumentService()->GetDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {};  // Return defaults if not found
+    }
+
+    CollectionSettings settings;
+    const auto& fields = resp.data().fields();
+
+    if (auto it = fields.find("schema"); it != fields.end() && it->second.has_string_value()) {
+        settings.schema = it->second.string_value();
+    }
+    if (auto it = fields.find("is_system"); it != fields.end() && it->second.has_bool_value()) {
+        settings.is_system = it->second.bool_value();
+    }
+    if (auto it = fields.find("ttl_seconds"); it != fields.end() && it->second.has_int_value()) {
+        settings.ttl_seconds = it->second.int_value();
+    }
+    if (auto it = fields.find("encrypted_fields"); it != fields.end() && it->second.has_array_value()) {
+        for (const auto& val : it->second.array_value().values()) {
+            if (val.has_string_value()) {
+                settings.encrypted_fields.push_back(val.string_value());
+            }
+        }
+    }
+
+    // Load field_permissions
+    if (auto it = fields.find("field_permissions"); it != fields.end() && it->second.has_array_value()) {
+        for (const auto& fp_val : it->second.array_value().values()) {
+            if (fp_val.has_map_value()) {
+                FieldPermission fp;
+                const auto& fp_fields = fp_val.map_value().fields();
+
+                if (auto fn_it = fp_fields.find("field_name"); fn_it != fp_fields.end() && fn_it->second.has_string_value()) {
+                    fp.field_name = fn_it->second.string_value();
+                }
+
+                if (auto rg_it = fp_fields.find("read_groups"); rg_it != fp_fields.end() && rg_it->second.has_array_value()) {
+                    for (const auto& rg : rg_it->second.array_value().values()) {
+                        if (rg.has_string_value()) {
+                            fp.read_groups.push_back(rg.string_value());
+                        }
+                    }
+                }
+
+                if (auto wg_it = fp_fields.find("write_groups"); wg_it != fp_fields.end() && wg_it->second.has_array_value()) {
+                    for (const auto& wg : wg_it->second.array_value().values()) {
+                        if (wg.has_string_value()) {
+                            fp.write_groups.push_back(wg.string_value());
+                        }
+                    }
+                }
+
+                settings.field_permissions.push_back(std::move(fp));
+            }
+        }
+    }
+
+    return settings;
+}
+
+auto CollectionService::DeleteSettings(const std::string& workspace_id, const std::string& name) -> bool {
+    std::string doc_id = workspace_id + "_" + name;
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::DeleteDocumentRequest req;
+    ::smartbotic::database::DeleteDocumentResponse resp;
+
+    req.set_collection(kSettingsCollection);
+    req.set_id(doc_id);
+
+    auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
+    return status.ok();
+}
+
+}  // namespace smartbotic::webserver

+ 340 - 0
webserver/src/document_service.cpp

@@ -0,0 +1,340 @@
+#include "smartbotic/webserver/document_service.hpp"
+
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+/// Prefix for user collections (workspace-scoped) - must match collection_service
+constexpr const char* kCollectionPrefix = "ws_";
+
+/// Convert timestamp to ISO 8601 string
+auto TimestampToString(const ::smartbotic::database::Timestamp& ts) -> std::string {
+    if (ts.seconds() == 0 && ts.nanos() == 0) {
+        return "";
+    }
+
+    std::time_t time = ts.seconds();
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+
+    if (ts.nanos() > 0) {
+        oss << "." << std::setfill('0') << std::setw(3) << (ts.nanos() / 1000000);
+    }
+    oss << "Z";
+
+    return oss.str();
+}
+
+}  // namespace
+
+DocumentService::DocumentService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+DocumentService::~DocumentService() = default;
+
+DocumentService::DocumentService(DocumentService&&) noexcept = default;
+auto DocumentService::operator=(DocumentService&& /*other*/) noexcept -> DocumentService& {
+    // Cannot reassign reference member, so this is essentially a no-op
+    return *this;
+}
+
+auto DocumentService::CreateDocument(const CreateDocumentRequest& request) -> DocumentResult {
+    std::string internal_collection = GetInternalCollectionName(request.workspace_id, request.collection);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(internal_collection);
+    if (!request.id.empty()) {
+        req.set_id(request.id);
+    }
+
+    // Add ownership tracking fields to the data
+    nlohmann::json data_with_ownership = request.data;
+    if (!request.user_id.empty()) {
+        data_with_ownership["_created_by"] = request.user_id;
+        data_with_ownership["_updated_by"] = request.user_id;
+    }
+
+    *req.mutable_data() = JsonToMapValue(data_with_ownership);
+
+    auto status = db_client_.GetDocumentService()->CreateDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) {
+            return {.success = false, .error = "Document already exists", .document = std::nullopt};
+        }
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Collection not found", .document = std::nullopt};
+        }
+        spdlog::error("Failed to create document in {}: {}", internal_collection, status.error_message());
+        return {.success = false, .error = "Failed to create document: " + status.error_message(), .document = std::nullopt};
+    }
+
+    auto info = ProtoToDocumentInfo(request.workspace_id, request.collection, resp);
+    spdlog::debug("Created document {} in collection {}", info.id, request.collection);
+    return {.success = true, .error = "", .document = info};
+}
+
+auto DocumentService::GetDocument(const std::string& workspace_id, const std::string& collection,
+                                   const std::string& id) -> DocumentResult {
+    std::string internal_collection = GetInternalCollectionName(workspace_id, collection);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(internal_collection);
+    req.set_id(id);
+
+    auto status = db_client_.GetDocumentService()->GetDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Document not found", .document = std::nullopt};
+        }
+        spdlog::error("Failed to get document {} from {}: {}", id, internal_collection, status.error_message());
+        return {.success = false, .error = "Failed to get document: " + status.error_message(), .document = std::nullopt};
+    }
+
+    auto info = ProtoToDocumentInfo(workspace_id, collection, resp);
+    return {.success = true, .error = "", .document = info};
+}
+
+auto DocumentService::ListDocuments(const DocumentQuery& query) -> DocumentListResult {
+    std::string internal_collection = GetInternalCollectionName(query.workspace_id, query.collection);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(internal_collection);
+    req.set_limit(query.limit);
+    req.set_offset(query.offset);
+
+    // Add sorting if specified
+    if (!query.sort_field.empty()) {
+        auto* order = req.add_order_by();
+        order->set_field(query.sort_field);
+        order->set_direction(query.sort_ascending
+            ? ::smartbotic::database::SORT_DIRECTION_ASCENDING
+            : ::smartbotic::database::SORT_DIRECTION_DESCENDING);
+    }
+
+    // Build filter if provided
+    bool has_filter = (!query.filter.empty() && query.filter.is_object()) || !query.owner_filter.empty();
+    if (has_filter) {
+        auto* filter = req.mutable_filter();
+        auto* composite = filter->mutable_composite();
+        composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+        // Add user-provided filter conditions
+        if (!query.filter.empty() && query.filter.is_object()) {
+            for (auto it = query.filter.begin(); it != query.filter.end(); ++it) {
+                auto* field_filter = composite->add_filters()->mutable_field();
+                field_filter->set_field(it.key());
+                field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+                *field_filter->mutable_value() = JsonToValue(it.value());
+            }
+        }
+
+        // Add owner filter if specified (for read_own permission)
+        if (!query.owner_filter.empty()) {
+            auto* owner_field_filter = composite->add_filters()->mutable_field();
+            owner_field_filter->set_field("_created_by");
+            owner_field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+            owner_field_filter->mutable_value()->set_string_value(query.owner_filter);
+        }
+    }
+
+    auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("Failed to query documents in {}: {}", internal_collection, status.error_message());
+        return {.success = false, .error = "Failed to query documents: " + status.error_message(), .documents = {}, .total_count = 0};
+    }
+
+    std::vector<DocumentInfo> documents;
+    documents.reserve(resp.documents_size());
+    for (const auto& doc : resp.documents()) {
+        documents.push_back(ProtoToDocumentInfo(query.workspace_id, query.collection, doc));
+    }
+
+    return {
+        .success = true,
+        .error = "",
+        .documents = std::move(documents),
+        .total_count = resp.total_count() >= 0 ? resp.total_count() : static_cast<int64_t>(documents.size())
+    };
+}
+
+auto DocumentService::UpdateDocument(const UpdateDocumentRequest& request) -> DocumentResult {
+    std::string internal_collection = GetInternalCollectionName(request.workspace_id, request.collection);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(internal_collection);
+    req.set_id(request.id);
+    req.set_merge(request.merge);
+    if (request.expected_version > 0) {
+        req.set_expected_version(request.expected_version);
+    }
+
+    // Add _updated_by tracking field to the data
+    nlohmann::json data_with_tracking = request.data;
+    if (!request.user_id.empty()) {
+        data_with_tracking["_updated_by"] = request.user_id;
+    }
+
+    *req.mutable_data() = JsonToMapValue(data_with_tracking);
+
+    auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Document not found", .document = std::nullopt};
+        }
+        if (status.error_code() == grpc::StatusCode::ABORTED) {
+            return {.success = false, .error = "Version conflict - document was modified", .document = std::nullopt};
+        }
+        spdlog::error("Failed to update document {} in {}: {}", request.id, internal_collection, status.error_message());
+        return {.success = false, .error = "Failed to update document: " + status.error_message(), .document = std::nullopt};
+    }
+
+    auto info = ProtoToDocumentInfo(request.workspace_id, request.collection, resp);
+    spdlog::debug("Updated document {} in collection {}", info.id, request.collection);
+    return {.success = true, .error = "", .document = info};
+}
+
+auto DocumentService::DeleteDocument(const std::string& workspace_id, const std::string& collection,
+                                      const std::string& id) -> DocumentResult {
+    std::string internal_collection = GetInternalCollectionName(workspace_id, collection);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::DeleteDocumentRequest req;
+    ::smartbotic::database::DeleteDocumentResponse resp;
+
+    req.set_collection(internal_collection);
+    req.set_id(id);
+
+    auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Document not found", .document = std::nullopt};
+        }
+        spdlog::error("Failed to delete document {} from {}: {}", id, internal_collection, status.error_message());
+        return {.success = false, .error = "Failed to delete document: " + status.error_message(), .document = std::nullopt};
+    }
+
+    spdlog::debug("Deleted document {} from collection {}", id, collection);
+    return {.success = true, .error = "", .document = std::nullopt};
+}
+
+auto DocumentService::GetInternalCollectionName(const std::string& workspace_id,
+                                                 const std::string& collection) -> std::string {
+    return std::string(kCollectionPrefix) + workspace_id + "_" + collection;
+}
+
+auto DocumentService::ProtoToDocumentInfo(const std::string& workspace_id,
+                                           const std::string& display_collection,
+                                           const ::smartbotic::database::Document& doc) -> DocumentInfo {
+    DocumentInfo info;
+    info.id = doc.id();
+    info.collection = display_collection;
+    info.workspace_id = workspace_id;
+    info.data = MapValueToJson(doc.data());
+    info.created_at = TimestampToString(doc.created_at());
+    info.updated_at = TimestampToString(doc.updated_at());
+    info.version = doc.version();
+    return info;
+}
+
+auto DocumentService::JsonToMapValue(const nlohmann::json& json) -> ::smartbotic::database::MapValue {
+    ::smartbotic::database::MapValue map_value;
+
+    if (json.is_object()) {
+        for (auto it = json.begin(); it != json.end(); ++it) {
+            (*map_value.mutable_fields())[it.key()] = JsonToValue(it.value());
+        }
+    }
+
+    return map_value;
+}
+
+auto DocumentService::MapValueToJson(const ::smartbotic::database::MapValue& map_value) -> nlohmann::json {
+    nlohmann::json result = nlohmann::json::object();
+
+    for (const auto& [key, value] : map_value.fields()) {
+        result[key] = ValueToJson(value);
+    }
+
+    return result;
+}
+
+auto DocumentService::ValueToJson(const ::smartbotic::database::Value& value) -> nlohmann::json {
+    switch (value.kind_case()) {
+        case ::smartbotic::database::Value::kNullValue:
+            return nullptr;
+        case ::smartbotic::database::Value::kBoolValue:
+            return value.bool_value();
+        case ::smartbotic::database::Value::kIntValue:
+            return value.int_value();
+        case ::smartbotic::database::Value::kDoubleValue:
+            return value.double_value();
+        case ::smartbotic::database::Value::kStringValue:
+            return value.string_value();
+        case ::smartbotic::database::Value::kBytesValue:
+            // Return bytes as base64 or hex string (simplified: just return as string)
+            return value.bytes_value();
+        case ::smartbotic::database::Value::kTimestampValue:
+            return TimestampToString(value.timestamp_value());
+        case ::smartbotic::database::Value::kArrayValue: {
+            nlohmann::json arr = nlohmann::json::array();
+            for (const auto& item : value.array_value().values()) {
+                arr.push_back(ValueToJson(item));
+            }
+            return arr;
+        }
+        case ::smartbotic::database::Value::kMapValue:
+            return MapValueToJson(value.map_value());
+        default:
+            return nullptr;
+    }
+}
+
+auto DocumentService::JsonToValue(const nlohmann::json& json) -> ::smartbotic::database::Value {
+    ::smartbotic::database::Value value;
+
+    if (json.is_null()) {
+        value.set_null_value(true);
+    } else if (json.is_boolean()) {
+        value.set_bool_value(json.get<bool>());
+    } else if (json.is_number_integer()) {
+        value.set_int_value(json.get<int64_t>());
+    } else if (json.is_number_float()) {
+        value.set_double_value(json.get<double>());
+    } else if (json.is_string()) {
+        value.set_string_value(json.get<std::string>());
+    } else if (json.is_array()) {
+        auto* arr = value.mutable_array_value();
+        for (const auto& item : json) {
+            *arr->add_values() = JsonToValue(item);
+        }
+    } else if (json.is_object()) {
+        auto* map = value.mutable_map_value();
+        for (auto it = json.begin(); it != json.end(); ++it) {
+            (*map->mutable_fields())[it.key()] = JsonToValue(it.value());
+        }
+    }
+
+    return value;
+}
+
+}  // namespace smartbotic::webserver

+ 717 - 0
webserver/src/group_service.cpp

@@ -0,0 +1,717 @@
+#include "smartbotic/webserver/group_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include <chrono>
+#include <functional>
+#include <iomanip>
+#include <sstream>
+#include <unordered_set>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+auto SetStringValue(::smartbotic::database::MapValue* map, const std::string& key, const std::string& value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_string_value(value);
+}
+
+auto SetArrayValue(::smartbotic::database::MapValue* map, const std::string& key, const std::vector<std::string>& values) {
+    auto* field = &(*map->mutable_fields())[key];
+    auto* array = field->mutable_array_value();
+    for (const auto& value : values) {
+        array->add_values()->set_string_value(value);
+    }
+}
+
+auto GetStringValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::string {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_string_value()) {
+        return it->second.string_value();
+    }
+    return "";
+}
+
+auto GetArrayValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::vector<std::string> {
+    std::vector<std::string> result;
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_array_value()) {
+        for (const auto& value : it->second.array_value().values()) {
+            if (value.has_string_value()) {
+                result.push_back(value.string_value());
+            }
+        }
+    }
+    return result;
+}
+
+auto GetBoolValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> bool {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_bool_value()) {
+        return it->second.bool_value();
+    }
+    return false;
+}
+
+auto SetBoolValue(::smartbotic::database::MapValue* map, const std::string& key, bool value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_bool_value(value);
+}
+
+}  // namespace
+
+GroupService::GroupService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+GroupService::~GroupService() = default;
+
+// Move operations not supported due to reference member
+GroupService::GroupService(GroupService&& other) noexcept
+    : db_client_(other.db_client_), initialized_(other.initialized_) {}
+
+auto GroupService::operator=(GroupService&& /*other*/) noexcept -> GroupService& {
+    // Cannot reassign reference member, so this is essentially a no-op
+    return *this;
+}
+
+auto GroupService::Initialize() -> bool {
+    if (initialized_) {
+        return true;
+    }
+
+    auto* collection_service = db_client_.GetCollectionService();
+    if (collection_service == nullptr) {
+        spdlog::error("Collection service not available");
+        return false;
+    }
+
+    // Check if _groups collection exists
+    ::smartbotic::database::GetCollectionMetadataRequest get_req;
+    get_req.set_name(kCollectionName);
+
+    grpc::ClientContext get_ctx;
+    ::smartbotic::database::CollectionMetadata metadata;
+    auto status = collection_service->GetCollectionMetadata(&get_ctx, get_req, &metadata);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kCollectionName);
+    } else {
+        // Collection doesn't exist, create it
+        ::smartbotic::database::CreateCollectionRequest create_req;
+        create_req.set_name(kCollectionName);
+
+        // Add compound index on workspace_id + name for uniqueness within workspace
+        auto* name_index = create_req.add_indexes();
+        name_index->set_collection(kCollectionName);
+        name_index->set_index_name("workspace_name_unique");
+        name_index->add_fields("workspace_id");
+        name_index->add_fields("name");
+        name_index->set_unique(true);
+        name_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+        grpc::ClientContext create_ctx;
+        ::smartbotic::database::CollectionMetadata created_metadata;
+        status = collection_service->CreateCollection(&create_ctx, create_req, &created_metadata);
+
+        if (!status.ok()) {
+            spdlog::error("Failed to create {} collection: {}", kCollectionName, status.error_message());
+            return false;
+        }
+
+        spdlog::info("Created system collection {}", kCollectionName);
+    }
+
+    // Ensure superadmin group exists
+    auto superadmin_result = GetGroupByName("", kSuperadminGroupName, true);
+    if (!superadmin_result.success) {
+        if (!CreateSuperadminGroup()) {
+            spdlog::error("Failed to create superadmin group");
+            return false;
+        }
+    } else {
+        spdlog::info("Global superadmin group already exists");
+    }
+
+    initialized_ = true;
+    return true;
+}
+
+auto GroupService::CreateSuperadminGroup() -> bool {
+    return CreateSystemGroup(kSuperadminGroupName, {"*"});
+}
+
+auto GroupService::CreateSystemGroup(const std::string& name, const std::vector<std::string>& permissions) -> bool {
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return false;
+    }
+
+    // Check if group already exists
+    auto existing = GetGroupByName("", name, true);
+    if (existing.success) {
+        spdlog::info("System group {} already exists", name);
+        return true;
+    }
+
+    ::smartbotic::database::CreateDocumentRequest create_req;
+    create_req.set_collection(kCollectionName);
+
+    auto* data = create_req.mutable_data();
+    SetStringValue(data, "workspace_id", "");  // Global group
+    SetStringValue(data, "name", name);
+    SetArrayValue(data, "permissions", permissions);
+    SetBoolValue(data, "is_system", true);  // Mark as system group
+    SetStringValue(data, "parent_group_id", "");
+    SetStringValue(data, "created_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+    SetStringValue(data, "deleted_at", "");
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document created_doc;
+    auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create system group {}: {}", name, status.error_message());
+        return false;
+    }
+
+    spdlog::info("Created system group {} with ID {}", name, created_doc.id());
+    return true;
+}
+
+auto GroupService::CreateGroup(const CreateGroupRequest& request) -> GroupResult {
+    if (!initialized_) {
+        return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt};
+    }
+
+    // Validate request
+    if (request.name.empty()) {
+        return GroupResult{.success = false, .error = "Name is required", .group = std::nullopt};
+    }
+
+    if (request.workspace_id.empty()) {
+        return GroupResult{.success = false, .error = "Workspace ID is required for non-global groups", .group = std::nullopt};
+    }
+
+    // Prevent creating groups with reserved names
+    if (request.name == kSuperadminGroupName) {
+        return GroupResult{.success = false, .error = "Cannot create group with reserved name", .group = std::nullopt};
+    }
+
+    // Check if name already exists in workspace
+    if (NameExistsInWorkspace(request.workspace_id, request.name)) {
+        return GroupResult{.success = false, .error = "Group name already exists in workspace", .group = std::nullopt};
+    }
+
+    // Validate parent group if specified
+    if (!request.parent_group_id.empty()) {
+        auto parent_result = GetGroup(request.parent_group_id, false);
+        if (!parent_result.success) {
+            return GroupResult{.success = false, .error = "Parent group not found", .group = std::nullopt};
+        }
+    }
+
+    // Create the document
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt};
+    }
+
+    ::smartbotic::database::CreateDocumentRequest create_req;
+    create_req.set_collection(kCollectionName);
+
+    auto* data = create_req.mutable_data();
+    SetStringValue(data, "workspace_id", request.workspace_id);
+    SetStringValue(data, "name", request.name);
+    SetArrayValue(data, "permissions", request.permissions);
+    SetBoolValue(data, "is_system", request.is_system);
+    SetStringValue(data, "parent_group_id", request.parent_group_id);
+    SetStringValue(data, "created_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+    SetStringValue(data, "deleted_at", "");
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document created_doc;
+    auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create group: {}", status.error_message());
+        return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt};
+    }
+
+    spdlog::info("Created group {} with name {} in workspace {}", created_doc.id(), request.name, request.workspace_id);
+    return GroupResult{.success = true, .error = "", .group = DocumentToGroup(created_doc)};
+}
+
+auto GroupService::GetGroup(const std::string& id, bool include_deleted) -> GroupResult {
+    if (!initialized_) {
+        return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt};
+    }
+
+    ::smartbotic::database::GetDocumentRequest get_req;
+    get_req.set_collection(kCollectionName);
+    get_req.set_id(id);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document doc;
+    auto status = doc_service->GetDocument(&ctx, get_req, &doc);
+
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt};
+        }
+        return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt};
+    }
+
+    auto group = DocumentToGroup(doc);
+    if (!include_deleted && group.IsDeleted()) {
+        return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt};
+    }
+
+    return GroupResult{.success = true, .error = "", .group = group};
+}
+
+auto GroupService::GetGroupByName(const std::string& workspace_id, const std::string& name, bool include_deleted) -> GroupResult {
+    if (!initialized_ && name != kSuperadminGroupName) {
+        return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return GroupResult{.success = false, .error = "Query service not available", .group = std::nullopt};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(1);
+
+    // Build compound filter: workspace_id AND name
+    auto* filter = query_req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    auto* name_filter = composite->add_filters()->mutable_field();
+    name_filter->set_field("name");
+    name_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    name_filter->mutable_value()->set_string_value(name);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt};
+    }
+
+    if (response.documents().empty()) {
+        return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt};
+    }
+
+    auto group = DocumentToGroup(response.documents(0));
+    if (!include_deleted && group.IsDeleted()) {
+        return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt};
+    }
+
+    return GroupResult{.success = true, .error = "", .group = group};
+}
+
+auto GroupService::ListGroups(const std::string& workspace_id, int limit, int offset, bool include_deleted) -> GroupListResult {
+    if (!initialized_) {
+        return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return GroupListResult{.success = false, .error = "Query service not available", .groups = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(limit);
+    query_req.set_offset(offset);
+
+    // Build filter for workspace
+    auto* filter = query_req.mutable_filter();
+
+    if (!include_deleted) {
+        // Filter: workspace_id = X AND deleted_at = ""
+        auto* composite = filter->mutable_composite();
+        composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+        auto* ws_filter = composite->add_filters()->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value(workspace_id);
+
+        auto* del_filter = composite->add_filters()->mutable_field();
+        del_filter->set_field("deleted_at");
+        del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        del_filter->mutable_value()->set_string_value("");
+    } else {
+        // Filter: workspace_id = X
+        auto* ws_filter = filter->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value(workspace_id);
+    }
+
+    // Order by name ascending
+    auto* order = query_req.add_order_by();
+    order->set_field("name");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_ASCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return GroupListResult{.success = false, .error = status.error_message(), .groups = {}, .total_count = 0};
+    }
+
+    GroupListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        result.groups.push_back(DocumentToGroup(doc));
+    }
+
+    return result;
+}
+
+auto GroupService::ListGroupsByIds(const std::vector<std::string>& group_ids, bool include_deleted) -> GroupListResult {
+    if (!initialized_) {
+        return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0};
+    }
+
+    if (group_ids.empty()) {
+        return GroupListResult{.success = true, .error = "", .groups = {}, .total_count = 0};
+    }
+
+    // Fetch each group by ID
+    GroupListResult result;
+    result.success = true;
+
+    for (const auto& id : group_ids) {
+        auto grp_result = GetGroup(id, include_deleted);
+        if (grp_result.success && grp_result.group) {
+            result.groups.push_back(*grp_result.group);
+        }
+    }
+
+    result.total_count = static_cast<int64_t>(result.groups.size());
+    return result;
+}
+
+auto GroupService::UpdateGroup(const std::string& id, const UpdateGroupRequest& request) -> GroupResult {
+    if (!initialized_) {
+        return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt};
+    }
+
+    // First get the existing group
+    auto existing = GetGroup(id, false);
+    if (!existing.success) {
+        return existing;
+    }
+
+    // Cannot modify superadmin group
+    if (existing.group->name == kSuperadminGroupName && existing.group->IsGlobal()) {
+        return GroupResult{.success = false, .error = "Cannot modify superadmin group", .group = std::nullopt};
+    }
+
+    // Check if name is being updated and if it already exists
+    if (request.name && *request.name != existing.group->name) {
+        if (*request.name == kSuperadminGroupName) {
+            return GroupResult{.success = false, .error = "Cannot use reserved name", .group = std::nullopt};
+        }
+        if (NameExistsInWorkspace(existing.group->workspace_id, *request.name)) {
+            return GroupResult{.success = false, .error = "Group name already exists in workspace", .group = std::nullopt};
+        }
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt};
+    }
+
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);  // Merge with existing data
+
+    auto* data = update_req.mutable_data();
+
+    if (request.name) {
+        SetStringValue(data, "name", *request.name);
+    }
+    if (request.permissions) {
+        SetArrayValue(data, "permissions", *request.permissions);
+    }
+    if (request.parent_group_id) {
+        // Validate parent group if specified
+        if (!request.parent_group_id->empty()) {
+            auto parent_result = GetGroup(*request.parent_group_id, false);
+            if (!parent_result.success) {
+                return GroupResult{.success = false, .error = "Parent group not found", .group = std::nullopt};
+            }
+            // Prevent circular references
+            if (*request.parent_group_id == id) {
+                return GroupResult{.success = false, .error = "Group cannot be its own parent", .group = std::nullopt};
+            }
+        }
+        SetStringValue(data, "parent_group_id", *request.parent_group_id);
+    }
+
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt};
+    }
+
+    spdlog::info("Updated group {}", id);
+    return GroupResult{.success = true, .error = "", .group = DocumentToGroup(updated_doc)};
+}
+
+auto GroupService::DeleteGroup(const std::string& id) -> GroupResult {
+    if (!initialized_) {
+        return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt};
+    }
+
+    // First get the existing group
+    auto existing = GetGroup(id, false);
+    if (!existing.success) {
+        return existing;
+    }
+
+    // Cannot delete superadmin group
+    if (existing.group->name == kSuperadminGroupName && existing.group->IsGlobal()) {
+        return GroupResult{.success = false, .error = "Cannot delete superadmin group", .group = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt};
+    }
+
+    // Soft delete by setting deleted_at
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);
+
+    auto* data = update_req.mutable_data();
+    SetStringValue(data, "deleted_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt};
+    }
+
+    spdlog::info("Soft deleted group {}", id);
+    return GroupResult{.success = true, .error = "", .group = DocumentToGroup(updated_doc)};
+}
+
+auto GroupService::NameExistsInWorkspace(const std::string& workspace_id, const std::string& name) -> bool {
+    auto result = GetGroupByName(workspace_id, name, true);
+    return result.success;
+}
+
+auto GroupService::GetSuperadminGroup() -> GroupResult {
+    return GetGroupByName("", kSuperadminGroupName, false);
+}
+
+auto GroupService::DocumentToGroup(const ::smartbotic::database::Document& doc) -> Group {
+    Group group;
+    group.id = doc.id();
+    group.workspace_id = GetStringValue(doc.data(), "workspace_id");
+    group.name = GetStringValue(doc.data(), "name");
+    group.permissions = GetArrayValue(doc.data(), "permissions");
+    group.is_system = GetBoolValue(doc.data(), "is_system");
+    group.parent_group_id = GetStringValue(doc.data(), "parent_group_id");
+    group.created_at = GetStringValue(doc.data(), "created_at");
+    group.updated_at = GetStringValue(doc.data(), "updated_at");
+    group.deleted_at = GetStringValue(doc.data(), "deleted_at");
+    return group;
+}
+
+auto GroupService::ListSystemGroups(bool include_deleted) -> GroupListResult {
+    if (!initialized_) {
+        return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return GroupListResult{.success = false, .error = "Query service not available", .groups = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(100);
+
+    // Build filter: workspace_id = "" AND is_system = true
+    auto* filter = query_req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value("");
+
+    auto* sys_filter = composite->add_filters()->mutable_field();
+    sys_filter->set_field("is_system");
+    sys_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    sys_filter->mutable_value()->set_bool_value(true);
+
+    if (!include_deleted) {
+        auto* del_filter = composite->add_filters()->mutable_field();
+        del_filter->set_field("deleted_at");
+        del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        del_filter->mutable_value()->set_string_value("");
+    }
+
+    // Order by name ascending
+    auto* order = query_req.add_order_by();
+    order->set_field("name");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_ASCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return GroupListResult{.success = false, .error = status.error_message(), .groups = {}, .total_count = 0};
+    }
+
+    GroupListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        result.groups.push_back(DocumentToGroup(doc));
+    }
+
+    return result;
+}
+
+auto GroupService::ListGlobalGroups(bool include_deleted) -> GroupListResult {
+    if (!initialized_) {
+        return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return GroupListResult{.success = false, .error = "Query service not available", .groups = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(100);
+
+    // Build filter: workspace_id = ""
+    auto* filter = query_req.mutable_filter();
+
+    if (!include_deleted) {
+        auto* composite = filter->mutable_composite();
+        composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+        auto* ws_filter = composite->add_filters()->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value("");
+
+        auto* del_filter = composite->add_filters()->mutable_field();
+        del_filter->set_field("deleted_at");
+        del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        del_filter->mutable_value()->set_string_value("");
+    } else {
+        auto* ws_filter = filter->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value("");
+    }
+
+    // Order by name ascending
+    auto* order = query_req.add_order_by();
+    order->set_field("name");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_ASCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return GroupListResult{.success = false, .error = status.error_message(), .groups = {}, .total_count = 0};
+    }
+
+    GroupListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        result.groups.push_back(DocumentToGroup(doc));
+    }
+
+    return result;
+}
+
+auto GroupService::GetEffectivePermissions(const std::string& group_id) -> std::vector<std::string> {
+    std::vector<std::string> permissions;
+    std::unordered_set<std::string> visited;  // Prevent infinite loops
+
+    std::function<void(const std::string&)> collect_permissions = [&](const std::string& id) {
+        if (id.empty() || visited.contains(id)) {
+            return;
+        }
+        visited.insert(id);
+
+        auto result = GetGroup(id, false);
+        if (!result.success || !result.group) {
+            return;
+        }
+
+        // Add this group's permissions
+        for (const auto& perm : result.group->permissions) {
+            permissions.push_back(perm);
+        }
+
+        // Recursively get parent's permissions
+        if (!result.group->parent_group_id.empty()) {
+            collect_permissions(result.group->parent_group_id);
+        }
+    };
+
+    collect_permissions(group_id);
+    return permissions;
+}
+
+auto GroupService::GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
+
+    std::ostringstream oss;
+    oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%S");
+    oss << '.' << std::setfill('0') << std::setw(3) << ms.count() << 'Z';
+    return oss.str();
+}
+
+}  // namespace smartbotic::webserver

+ 18 - 3
webserver/src/http_server.cpp

@@ -1,4 +1,5 @@
 #include "smartbotic/webserver/http_server.hpp"
+#include "smartbotic/webserver/permissions.hpp"
 
 #include <nlohmann/json.hpp>
 #include <spdlog/spdlog.h>
@@ -528,6 +529,14 @@ auto HttpServer::InitializeServices() -> bool {
     }
     spdlog::info("ViewService initialized successfully");
 
+    // Initialize AuthorizationService
+    authorizationService_ = std::make_unique<AuthorizationService>(*groupService_, *collectionService_);
+    if (!authorizationService_->Initialize()) {
+        spdlog::error("Failed to initialize AuthorizationService");
+        return false;
+    }
+    spdlog::info("AuthorizationService initialized successfully");
+
     // Initialize WsHandler for WebSocket real-time updates
     wsHandler_ = std::make_unique<WsHandler>(*authService_);
     spdlog::info("WsHandler initialized successfully");
@@ -1104,6 +1113,12 @@ void HttpServer::SetupUserRoutes() {
 }
 
 auto HttpServer::IsSuperadmin(const AuthUser& user) -> bool {
+    // Delegate to authorization service for proper permission checking
+    // This maintains backward compatibility while using the new RBAC system
+    if (authorizationService_) {
+        return authorizationService_->IsSuperadmin(user);
+    }
+    // Fallback to old behavior if authorization service not available
     return std::find(user.groups.begin(), user.groups.end(), "superadmin") != user.groups.end();
 }
 
@@ -1116,10 +1131,10 @@ void HttpServer::HandleCreateUser(const httplib::Request& req, httplib::Response
         return;
     }
 
-    // Only superadmins can create users
-    if (!IsSuperadmin(*auth_user)) {
+    // Check permission to create users
+    if (!authorizationService_ || !authorizationService_->HasPermission(*auth_user, smartbotic::permissions::kUsersCreate)) {
         res.status = 403;
-        res.set_content(R"({"error":"Forbidden - superadmin required"})", "application/json");
+        res.set_content(R"({"error":"Forbidden - requires system:users:create permission"})", "application/json");
         return;
     }
 

+ 504 - 0
webserver/src/membership_service.cpp

@@ -0,0 +1,504 @@
+#include "smartbotic/webserver/membership_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+auto SetStringValue(::smartbotic::database::MapValue* map, const std::string& key, const std::string& value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_string_value(value);
+}
+
+auto SetArrayValue(::smartbotic::database::MapValue* map, const std::string& key, const std::vector<std::string>& values) {
+    auto* field = &(*map->mutable_fields())[key];
+    auto* array = field->mutable_array_value();
+    for (const auto& value : values) {
+        array->add_values()->set_string_value(value);
+    }
+}
+
+auto GetStringValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::string {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_string_value()) {
+        return it->second.string_value();
+    }
+    return "";
+}
+
+auto GetArrayValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::vector<std::string> {
+    std::vector<std::string> result;
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_array_value()) {
+        for (const auto& value : it->second.array_value().values()) {
+            if (value.has_string_value()) {
+                result.push_back(value.string_value());
+            }
+        }
+    }
+    return result;
+}
+
+}  // namespace
+
+MembershipService::MembershipService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+MembershipService::~MembershipService() = default;
+
+// Move operations not supported due to reference member
+MembershipService::MembershipService(MembershipService&& other) noexcept
+    : db_client_(other.db_client_), initialized_(other.initialized_) {}
+
+auto MembershipService::operator=(MembershipService&& /*other*/) noexcept -> MembershipService& {
+    // Cannot reassign reference member, so this is essentially a no-op
+    return *this;
+}
+
+auto MembershipService::GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time_t = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        now.time_since_epoch()) % 1000;
+
+    std::ostringstream oss;
+    oss << std::put_time(std::gmtime(&time_t), "%Y-%m-%dT%H:%M:%S");
+    oss << '.' << std::setfill('0') << std::setw(3) << ms.count() << 'Z';
+    return oss.str();
+}
+
+auto MembershipService::DocumentToMember(const ::smartbotic::database::Document& doc) -> WorkspaceMember {
+    WorkspaceMember member;
+    member.id = doc.id();
+
+    if (doc.has_data()) {
+        const auto& data = doc.data();
+        member.workspace_id = GetStringValue(data, "workspace_id");
+        member.user_id = GetStringValue(data, "user_id");
+        member.group_ids = GetArrayValue(data, "group_ids");
+        member.created_at = GetStringValue(data, "created_at");
+        member.updated_at = GetStringValue(data, "updated_at");
+        member.deleted_at = GetStringValue(data, "deleted_at");
+    }
+
+    return member;
+}
+
+auto MembershipService::Initialize() -> bool {
+    if (initialized_) {
+        return true;
+    }
+
+    auto* collection_service = db_client_.GetCollectionService();
+    if (collection_service == nullptr) {
+        spdlog::error("Collection service not available");
+        return false;
+    }
+
+    // Check if collection exists
+    ::smartbotic::database::GetCollectionMetadataRequest get_req;
+    get_req.set_name(kCollectionName);
+
+    grpc::ClientContext get_ctx;
+    ::smartbotic::database::CollectionMetadata metadata;
+    auto status = collection_service->GetCollectionMetadata(&get_ctx, get_req, &metadata);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kCollectionName);
+    } else {
+        // Create the collection
+        ::smartbotic::database::CreateCollectionRequest create_req;
+        create_req.set_name(kCollectionName);
+
+        // Add compound index on workspace_id + user_id for uniqueness
+        auto* unique_index = create_req.add_indexes();
+        unique_index->set_collection(kCollectionName);
+        unique_index->set_index_name("workspace_user_unique");
+        unique_index->add_fields("workspace_id");
+        unique_index->add_fields("user_id");
+        unique_index->set_unique(true);
+        unique_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+        grpc::ClientContext create_ctx;
+        ::smartbotic::database::CollectionMetadata created_metadata;
+        status = collection_service->CreateCollection(&create_ctx, create_req, &created_metadata);
+
+        if (!status.ok()) {
+            spdlog::error("Failed to create {} collection: {}", kCollectionName, status.error_message());
+            return false;
+        }
+
+        spdlog::info("Created system collection {}", kCollectionName);
+    }
+
+    initialized_ = true;
+    return true;
+}
+
+auto MembershipService::AddMember(const AddMemberRequest& request) -> MembershipResult {
+    if (!initialized_) {
+        return MembershipResult{.success = false, .error = "MembershipService not initialized", .member = std::nullopt};
+    }
+
+    // Validate request
+    if (request.workspace_id.empty()) {
+        return MembershipResult{.success = false, .error = "Workspace ID is required", .member = std::nullopt};
+    }
+
+    if (request.user_id.empty()) {
+        return MembershipResult{.success = false, .error = "User ID is required", .member = std::nullopt};
+    }
+
+    // Check if membership already exists
+    auto existing = GetMembershipByUser(request.workspace_id, request.user_id, true);
+    if (existing.success && existing.member) {
+        if (existing.member->IsDeleted()) {
+            // Re-activate the membership by updating it
+            UpdateMemberRequest update_req;
+            update_req.group_ids = request.group_ids;
+            return UpdateMembership(request.workspace_id, request.user_id, update_req);
+        }
+        return MembershipResult{.success = false, .error = "User is already a member of this workspace", .member = std::nullopt};
+    }
+
+    // Create the document
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return MembershipResult{.success = false, .error = "Document service not available", .member = std::nullopt};
+    }
+
+    ::smartbotic::database::CreateDocumentRequest create_req;
+    create_req.set_collection(kCollectionName);
+
+    auto* data = create_req.mutable_data();
+    SetStringValue(data, "workspace_id", request.workspace_id);
+    SetStringValue(data, "user_id", request.user_id);
+    SetArrayValue(data, "group_ids", request.group_ids);
+    SetStringValue(data, "created_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+    SetStringValue(data, "deleted_at", "");
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document created_doc;
+    auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to add member: {}", status.error_message());
+        return MembershipResult{.success = false, .error = status.error_message(), .member = std::nullopt};
+    }
+
+    spdlog::info("Added user {} to workspace {} with groups", request.user_id, request.workspace_id);
+    return MembershipResult{.success = true, .error = "", .member = DocumentToMember(created_doc)};
+}
+
+auto MembershipService::GetMembership(const std::string& id, bool include_deleted) -> MembershipResult {
+    if (!initialized_) {
+        return MembershipResult{.success = false, .error = "MembershipService not initialized", .member = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return MembershipResult{.success = false, .error = "Document service not available", .member = std::nullopt};
+    }
+
+    ::smartbotic::database::GetDocumentRequest get_req;
+    get_req.set_collection(kCollectionName);
+    get_req.set_id(id);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document doc;
+    auto status = doc_service->GetDocument(&ctx, get_req, &doc);
+
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return MembershipResult{.success = false, .error = "Membership not found", .member = std::nullopt};
+        }
+        return MembershipResult{.success = false, .error = status.error_message(), .member = std::nullopt};
+    }
+
+    auto member = DocumentToMember(doc);
+    if (!include_deleted && member.IsDeleted()) {
+        return MembershipResult{.success = false, .error = "Membership not found", .member = std::nullopt};
+    }
+
+    return MembershipResult{.success = true, .error = "", .member = member};
+}
+
+auto MembershipService::GetMembershipByUser(const std::string& workspace_id, const std::string& user_id, bool include_deleted) -> MembershipResult {
+    if (!initialized_) {
+        return MembershipResult{.success = false, .error = "MembershipService not initialized", .member = std::nullopt};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return MembershipResult{.success = false, .error = "Query service not available", .member = std::nullopt};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(1);
+
+    // Build compound filter: workspace_id AND user_id (AND NOT deleted if applicable)
+    auto* filter = query_req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    auto* user_filter = composite->add_filters()->mutable_field();
+    user_filter->set_field("user_id");
+    user_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    user_filter->mutable_value()->set_string_value(user_id);
+
+    if (!include_deleted) {
+        auto* del_filter = composite->add_filters()->mutable_field();
+        del_filter->set_field("deleted_at");
+        del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        del_filter->mutable_value()->set_string_value("");
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return MembershipResult{.success = false, .error = status.error_message(), .member = std::nullopt};
+    }
+
+    if (response.documents().empty()) {
+        return MembershipResult{.success = false, .error = "Membership not found", .member = std::nullopt};
+    }
+
+    return MembershipResult{.success = true, .error = "", .member = DocumentToMember(response.documents(0))};
+}
+
+auto MembershipService::ListMembers(const std::string& workspace_id, int limit, int offset, bool include_deleted) -> MembershipListResult {
+    if (!initialized_) {
+        return MembershipListResult{.success = false, .error = "MembershipService not initialized", .members = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return MembershipListResult{.success = false, .error = "Query service not available", .members = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(limit);
+    query_req.set_offset(offset);
+
+    // Build filter for workspace
+    auto* filter = query_req.mutable_filter();
+
+    if (!include_deleted) {
+        // Filter: workspace_id = X AND deleted_at = ""
+        auto* composite = filter->mutable_composite();
+        composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+        auto* ws_filter = composite->add_filters()->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value(workspace_id);
+
+        auto* del_filter = composite->add_filters()->mutable_field();
+        del_filter->set_field("deleted_at");
+        del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        del_filter->mutable_value()->set_string_value("");
+    } else {
+        // Filter: workspace_id = X
+        auto* ws_filter = filter->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value(workspace_id);
+    }
+
+    // Order by created_at ascending
+    auto* order = query_req.add_order_by();
+    order->set_field("created_at");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_ASCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return MembershipListResult{.success = false, .error = status.error_message(), .members = {}, .total_count = 0};
+    }
+
+    MembershipListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        result.members.push_back(DocumentToMember(doc));
+    }
+
+    return result;
+}
+
+auto MembershipService::ListUserMemberships(const std::string& user_id, bool include_deleted) -> MembershipListResult {
+    if (!initialized_) {
+        return MembershipListResult{.success = false, .error = "MembershipService not initialized", .members = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return MembershipListResult{.success = false, .error = "Query service not available", .members = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(100);  // Users typically won't be in more than 100 workspaces
+
+    // Build filter for user
+    auto* filter = query_req.mutable_filter();
+
+    if (!include_deleted) {
+        // Filter: user_id = X AND deleted_at = ""
+        auto* composite = filter->mutable_composite();
+        composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+        auto* user_filter = composite->add_filters()->mutable_field();
+        user_filter->set_field("user_id");
+        user_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        user_filter->mutable_value()->set_string_value(user_id);
+
+        auto* del_filter = composite->add_filters()->mutable_field();
+        del_filter->set_field("deleted_at");
+        del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        del_filter->mutable_value()->set_string_value("");
+    } else {
+        // Filter: user_id = X
+        auto* user_filter = filter->mutable_field();
+        user_filter->set_field("user_id");
+        user_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        user_filter->mutable_value()->set_string_value(user_id);
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return MembershipListResult{.success = false, .error = status.error_message(), .members = {}, .total_count = 0};
+    }
+
+    MembershipListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        result.members.push_back(DocumentToMember(doc));
+    }
+
+    return result;
+}
+
+auto MembershipService::UpdateMembership(const std::string& workspace_id, const std::string& user_id, const UpdateMemberRequest& request) -> MembershipResult {
+    if (!initialized_) {
+        return MembershipResult{.success = false, .error = "MembershipService not initialized", .member = std::nullopt};
+    }
+
+    // Find the membership
+    auto existing = GetMembershipByUser(workspace_id, user_id, true);
+    if (!existing.success || !existing.member) {
+        return MembershipResult{.success = false, .error = "Membership not found", .member = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return MembershipResult{.success = false, .error = "Document service not available", .member = std::nullopt};
+    }
+
+    auto member = *existing.member;
+    auto timestamp = GetCurrentTimestamp();
+
+    // Build update document
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(member.id);
+    update_req.set_merge(true);  // Merge with existing data
+
+    auto* data = update_req.mutable_data();
+
+    if (request.group_ids) {
+        SetArrayValue(data, "group_ids", *request.group_ids);
+        member.group_ids = *request.group_ids;
+    }
+
+    // If was deleted, reactivate
+    if (member.IsDeleted()) {
+        SetStringValue(data, "deleted_at", "");
+        member.deleted_at = "";
+    }
+
+    SetStringValue(data, "updated_at", timestamp);
+    member.updated_at = timestamp;
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return MembershipResult{.success = false, .error = status.error_message(), .member = std::nullopt};
+    }
+
+    return MembershipResult{.success = true, .error = "", .member = member};
+}
+
+auto MembershipService::RemoveMember(const std::string& workspace_id, const std::string& user_id) -> MembershipResult {
+    if (!initialized_) {
+        return MembershipResult{.success = false, .error = "MembershipService not initialized", .member = std::nullopt};
+    }
+
+    // Find the membership
+    auto existing = GetMembershipByUser(workspace_id, user_id, false);
+    if (!existing.success || !existing.member) {
+        return MembershipResult{.success = false, .error = "Membership not found", .member = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return MembershipResult{.success = false, .error = "Document service not available", .member = std::nullopt};
+    }
+
+    auto timestamp = GetCurrentTimestamp();
+
+    // Soft delete by setting deleted_at
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(existing.member->id);
+    update_req.set_merge(true);  // Merge with existing data
+
+    auto* data = update_req.mutable_data();
+    SetStringValue(data, "deleted_at", timestamp);
+    SetStringValue(data, "updated_at", timestamp);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return MembershipResult{.success = false, .error = status.error_message(), .member = std::nullopt};
+    }
+
+    auto member = *existing.member;
+    member.deleted_at = timestamp;
+    member.updated_at = timestamp;
+
+    return MembershipResult{.success = true, .error = "", .member = member};
+}
+
+auto MembershipService::IsMember(const std::string& workspace_id, const std::string& user_id) -> bool {
+    auto result = GetMembershipByUser(workspace_id, user_id, false);
+    return result.success && result.member.has_value();
+}
+
+}  // namespace smartbotic::webserver

+ 194 - 0
webserver/src/permissions.cpp

@@ -0,0 +1,194 @@
+#include "smartbotic/webserver/permissions.hpp"
+
+namespace smartbotic::permissions {
+
+auto BuildWorkspacePermission(std::string_view workspace_id,
+                               std::string_view action) -> std::string {
+    std::string result;
+    result.reserve(10 + workspace_id.size() + action.size());
+    result.append("workspace:");
+    result.append(workspace_id);
+    result.append(":");
+    result.append(action);
+    return result;
+}
+
+auto BuildCollectionPermission(std::string_view workspace_id,
+                                std::string_view collection,
+                                std::string_view action) -> std::string {
+    std::string result;
+    result.reserve(12 + workspace_id.size() + collection.size() + action.size());
+    result.append("collection:");
+    result.append(workspace_id);
+    result.append(":");
+    result.append(collection);
+    result.append(":");
+    result.append(action);
+    return result;
+}
+
+auto BuildFieldPermission(std::string_view workspace_id,
+                           std::string_view collection,
+                           std::string_view field,
+                           std::string_view action) -> std::string {
+    std::string result;
+    result.reserve(8 + workspace_id.size() + collection.size() + field.size() + action.size());
+    result.append("field:");
+    result.append(workspace_id);
+    result.append(":");
+    result.append(collection);
+    result.append(":");
+    result.append(field);
+    result.append(":");
+    result.append(action);
+    return result;
+}
+
+auto ParsePermission(std::string_view permission) -> std::vector<std::string> {
+    std::vector<std::string> parts;
+    std::string part;
+
+    for (char c : permission) {
+        if (c == ':') {
+            parts.push_back(std::move(part));
+            part.clear();
+        } else {
+            part += c;
+        }
+    }
+    if (!part.empty()) {
+        parts.push_back(std::move(part));
+    }
+
+    return parts;
+}
+
+auto MatchesPermission(std::string_view required,
+                        std::string_view granted) -> bool {
+    // Wildcard "*" matches everything
+    if (granted == kWildcard) {
+        return true;
+    }
+
+    // Exact match
+    if (required == granted) {
+        return true;
+    }
+
+    // Parse both permissions into parts
+    auto required_parts = ParsePermission(required);
+    auto granted_parts = ParsePermission(granted);
+
+    // If granted has fewer parts, it can't match
+    if (granted_parts.size() < required_parts.size()) {
+        // Unless the last part is a wildcard that covers the rest
+        // e.g., "system:*" should match "system:users:read"
+        if (granted_parts.empty()) {
+            return false;
+        }
+        if (granted_parts.back() != "*") {
+            return false;
+        }
+        // Check if the prefix matches
+        for (size_t i = 0; i < granted_parts.size() - 1; ++i) {
+            if (granted_parts[i] != "*" && granted_parts[i] != required_parts[i]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    // Compare part by part
+    for (size_t i = 0; i < required_parts.size(); ++i) {
+        if (i >= granted_parts.size()) {
+            return false;
+        }
+        // Wildcard in granted matches any value in required
+        if (granted_parts[i] == "*") {
+            continue;
+        }
+        if (granted_parts[i] != required_parts[i]) {
+            return false;
+        }
+    }
+
+    return true;
+}
+
+auto GetAllSystemPermissions() -> std::vector<std::string_view> {
+    return {
+        // Authentication
+        kLoginAllow,
+        kLoginDeny,
+
+        // User management
+        kUsersRead,
+        kUsersCreate,
+        kUsersUpdate,
+        kUsersDelete,
+
+        // Group management
+        kGroupsRead,
+        kGroupsCreate,
+        kGroupsUpdate,
+        kGroupsDelete,
+
+        // System group management
+        kSystemGroupsRead,
+        kSystemGroupsCreate,
+        kSystemGroupsUpdate,
+        kSystemGroupsDelete,
+
+        // Workspace management
+        kWorkspacesRead,
+        kWorkspacesCreate,
+        kWorkspacesUpdate,
+        kWorkspacesDelete,
+
+        // Collection management
+        kCollectionsRead,
+        kCollectionsCreate,
+        kCollectionsUpdate,
+        kCollectionsDelete,
+
+        // API key management
+        kApiKeysRead,
+        kApiKeysCreate,
+        kApiKeysUpdate,
+        kApiKeysDelete,
+
+        // Membership management
+        kMembershipsRead,
+        kMembershipsCreate,
+        kMembershipsUpdate,
+        kMembershipsDelete,
+
+        // View management
+        kViewsRead,
+        kViewsCreate,
+        kViewsUpdate,
+        kViewsDelete,
+    };
+}
+
+auto GetAllCollectionActions() -> std::vector<std::string_view> {
+    return {
+        "read_all",
+        "read_own",
+        "write_all",
+        "write_own",
+        "create",
+        "delete_all",
+        "delete_own",
+        "manage",
+    };
+}
+
+auto GetAllFieldActions() -> std::vector<std::string_view> {
+    return {
+        "read",
+        "write",
+    };
+}
+
+}  // namespace smartbotic::permissions

+ 413 - 0
webserver/src/user_service.cpp

@@ -0,0 +1,413 @@
+#include "smartbotic/webserver/user_service.hpp"
+
+#include "smartbotic/webserver/auth_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+auto SetStringValue(::smartbotic::database::MapValue* map, const std::string& key, const std::string& value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_string_value(value);
+}
+
+auto GetStringValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::string {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_string_value()) {
+        return it->second.string_value();
+    }
+    return "";
+}
+
+}  // namespace
+
+UserService::UserService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+UserService::~UserService() = default;
+
+// Move operations not supported due to reference member
+UserService::UserService(UserService&& other) noexcept : db_client_(other.db_client_), initialized_(other.initialized_) {}
+
+auto UserService::operator=(UserService&& /*other*/) noexcept -> UserService& {
+    // Cannot reassign reference member, so this is essentially a no-op
+    return *this;
+}
+
+auto UserService::Initialize() -> bool {
+    if (initialized_) {
+        return true;
+    }
+
+    auto* collection_service = db_client_.GetCollectionService();
+    if (collection_service == nullptr) {
+        spdlog::error("Collection service not available");
+        return false;
+    }
+
+    // Check if _users collection exists
+    ::smartbotic::database::GetCollectionMetadataRequest get_req;
+    get_req.set_name(kCollectionName);
+
+    grpc::ClientContext get_ctx;
+    ::smartbotic::database::CollectionMetadata metadata;
+    auto status = collection_service->GetCollectionMetadata(&get_ctx, get_req, &metadata);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kCollectionName);
+        initialized_ = true;
+        return true;
+    }
+
+    // Collection doesn't exist, create it
+    ::smartbotic::database::CreateCollectionRequest create_req;
+    create_req.set_name(kCollectionName);
+
+    // Add unique index on email
+    auto* email_index = create_req.add_indexes();
+    email_index->set_collection(kCollectionName);
+    email_index->set_index_name("email_unique");
+    email_index->add_fields("email");
+    email_index->set_unique(true);
+    email_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+    grpc::ClientContext create_ctx;
+    ::smartbotic::database::CollectionMetadata created_metadata;
+    status = collection_service->CreateCollection(&create_ctx, create_req, &created_metadata);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create {} collection: {}", kCollectionName, status.error_message());
+        return false;
+    }
+
+    spdlog::info("Created system collection {}", kCollectionName);
+    initialized_ = true;
+    return true;
+}
+
+auto UserService::CreateUser(const CreateUserRequest& request) -> UserResult {
+    if (!initialized_) {
+        return UserResult{.success = false, .error = "User service not initialized", .user = std::nullopt};
+    }
+
+    // Validate request
+    if (request.email.empty()) {
+        return UserResult{.success = false, .error = "Email is required", .user = std::nullopt};
+    }
+    if (request.password.empty()) {
+        return UserResult{.success = false, .error = "Password is required", .user = std::nullopt};
+    }
+    if (request.name.empty()) {
+        return UserResult{.success = false, .error = "Name is required", .user = std::nullopt};
+    }
+
+    // Check if email already exists
+    if (EmailExists(request.email)) {
+        return UserResult{.success = false, .error = "Email already exists", .user = std::nullopt};
+    }
+
+    // Hash the password
+    std::string password_hash;
+    try {
+        password_hash = AuthService::HashPassword(request.password);
+    } catch (const std::exception& e) {
+        spdlog::error("Failed to hash password: {}", e.what());
+        return UserResult{.success = false, .error = "Failed to hash password", .user = std::nullopt};
+    }
+
+    // Create the document
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return UserResult{.success = false, .error = "Document service not available", .user = std::nullopt};
+    }
+
+    ::smartbotic::database::CreateDocumentRequest create_req;
+    create_req.set_collection(kCollectionName);
+
+    auto* data = create_req.mutable_data();
+    SetStringValue(data, "email", request.email);
+    SetStringValue(data, "password_hash", password_hash);
+    SetStringValue(data, "name", request.name);
+    SetStringValue(data, "created_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+    SetStringValue(data, "deleted_at", "");
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document created_doc;
+    auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create user: {}", status.error_message());
+        return UserResult{.success = false, .error = status.error_message(), .user = std::nullopt};
+    }
+
+    spdlog::info("Created user {} with email {}", created_doc.id(), request.email);
+    return UserResult{.success = true, .error = "", .user = DocumentToUser(created_doc)};
+}
+
+auto UserService::GetUser(const std::string& id, bool include_deleted) -> UserResult {
+    if (!initialized_) {
+        return UserResult{.success = false, .error = "User service not initialized", .user = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return UserResult{.success = false, .error = "Document service not available", .user = std::nullopt};
+    }
+
+    ::smartbotic::database::GetDocumentRequest get_req;
+    get_req.set_collection(kCollectionName);
+    get_req.set_id(id);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document doc;
+    auto status = doc_service->GetDocument(&ctx, get_req, &doc);
+
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return UserResult{.success = false, .error = "User not found", .user = std::nullopt};
+        }
+        return UserResult{.success = false, .error = status.error_message(), .user = std::nullopt};
+    }
+
+    auto user = DocumentToUser(doc);
+    if (!include_deleted && user.IsDeleted()) {
+        return UserResult{.success = false, .error = "User not found", .user = std::nullopt};
+    }
+
+    return UserResult{.success = true, .error = "", .user = user};
+}
+
+auto UserService::GetUserByEmail(const std::string& email, bool include_deleted) -> UserResult {
+    if (!initialized_) {
+        return UserResult{.success = false, .error = "User service not initialized", .user = std::nullopt};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return UserResult{.success = false, .error = "Query service not available", .user = std::nullopt};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(1);
+
+    auto* filter = query_req.mutable_filter();
+    auto* field_filter = filter->mutable_field();
+    field_filter->set_field("email");
+    field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    field_filter->mutable_value()->set_string_value(email);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return UserResult{.success = false, .error = status.error_message(), .user = std::nullopt};
+    }
+
+    if (response.documents().empty()) {
+        return UserResult{.success = false, .error = "User not found", .user = std::nullopt};
+    }
+
+    auto user = DocumentToUser(response.documents(0));
+    if (!include_deleted && user.IsDeleted()) {
+        return UserResult{.success = false, .error = "User not found", .user = std::nullopt};
+    }
+
+    return UserResult{.success = true, .error = "", .user = user};
+}
+
+auto UserService::ListUsers(int limit, int offset, bool include_deleted) -> UserListResult {
+    if (!initialized_) {
+        return UserListResult{.success = false, .error = "User service not initialized", .users = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return UserListResult{.success = false, .error = "Query service not available", .users = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(limit);
+    query_req.set_offset(offset);
+
+    // Add filter to exclude deleted users if not including them
+    if (!include_deleted) {
+        auto* filter = query_req.mutable_filter();
+        auto* field_filter = filter->mutable_field();
+        field_filter->set_field("deleted_at");
+        field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        field_filter->mutable_value()->set_string_value("");
+    }
+
+    // Order by created_at descending
+    auto* order = query_req.add_order_by();
+    order->set_field("created_at");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_DESCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return UserListResult{.success = false, .error = status.error_message(), .users = {}, .total_count = 0};
+    }
+
+    UserListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        result.users.push_back(DocumentToUser(doc));
+    }
+
+    return result;
+}
+
+auto UserService::UpdateUser(const std::string& id, const UpdateUserRequest& request) -> UserResult {
+    if (!initialized_) {
+        return UserResult{.success = false, .error = "User service not initialized", .user = std::nullopt};
+    }
+
+    // First get the existing user
+    auto existing = GetUser(id, false);
+    if (!existing.success) {
+        return existing;
+    }
+
+    // Check if email is being updated and if it already exists
+    if (request.email && *request.email != existing.user->email) {
+        if (EmailExists(*request.email)) {
+            return UserResult{.success = false, .error = "Email already exists", .user = std::nullopt};
+        }
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return UserResult{.success = false, .error = "Document service not available", .user = std::nullopt};
+    }
+
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);  // Merge with existing data
+
+    auto* data = update_req.mutable_data();
+
+    if (request.email) {
+        SetStringValue(data, "email", *request.email);
+    }
+    if (request.name) {
+        SetStringValue(data, "name", *request.name);
+    }
+    if (request.password) {
+        try {
+            auto password_hash = AuthService::HashPassword(*request.password);
+            SetStringValue(data, "password_hash", password_hash);
+        } catch (const std::exception& e) {
+            spdlog::error("Failed to hash password: {}", e.what());
+            return UserResult{.success = false, .error = "Failed to hash password", .user = std::nullopt};
+        }
+    }
+
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return UserResult{.success = false, .error = status.error_message(), .user = std::nullopt};
+    }
+
+    spdlog::info("Updated user {}", id);
+    return UserResult{.success = true, .error = "", .user = DocumentToUser(updated_doc)};
+}
+
+auto UserService::DeleteUser(const std::string& id) -> UserResult {
+    if (!initialized_) {
+        return UserResult{.success = false, .error = "User service not initialized", .user = std::nullopt};
+    }
+
+    // First get the existing user
+    auto existing = GetUser(id, false);
+    if (!existing.success) {
+        return existing;
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return UserResult{.success = false, .error = "Document service not available", .user = std::nullopt};
+    }
+
+    // Soft delete by setting deleted_at
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);
+
+    auto* data = update_req.mutable_data();
+    SetStringValue(data, "deleted_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return UserResult{.success = false, .error = status.error_message(), .user = std::nullopt};
+    }
+
+    spdlog::info("Soft deleted user {}", id);
+    return UserResult{.success = true, .error = "", .user = DocumentToUser(updated_doc)};
+}
+
+auto UserService::VerifyCredentials(const std::string& email, const std::string& password) -> UserResult {
+    auto result = GetUserByEmail(email, false);
+    if (!result.success) {
+        return UserResult{.success = false, .error = "Invalid credentials", .user = std::nullopt};
+    }
+
+    if (!AuthService::VerifyPassword(password, result.user->password_hash)) {
+        return UserResult{.success = false, .error = "Invalid credentials", .user = std::nullopt};
+    }
+
+    return result;
+}
+
+auto UserService::EmailExists(const std::string& email) -> bool {
+    auto result = GetUserByEmail(email, true);
+    return result.success;
+}
+
+auto UserService::DocumentToUser(const ::smartbotic::database::Document& doc) -> User {
+    User user;
+    user.id = doc.id();
+    user.email = GetStringValue(doc.data(), "email");
+    user.password_hash = GetStringValue(doc.data(), "password_hash");
+    user.name = GetStringValue(doc.data(), "name");
+    user.created_at = GetStringValue(doc.data(), "created_at");
+    user.updated_at = GetStringValue(doc.data(), "updated_at");
+    user.deleted_at = GetStringValue(doc.data(), "deleted_at");
+    return user;
+}
+
+auto UserService::GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
+
+    std::ostringstream oss;
+    oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%S");
+    oss << '.' << std::setfill('0') << std::setw(3) << ms.count() << 'Z';
+    return oss.str();
+}
+
+}  // namespace smartbotic::webserver

+ 572 - 0
webserver/src/view_service.cpp

@@ -0,0 +1,572 @@
+#include "smartbotic/webserver/view_service.hpp"
+
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::webserver {
+
+auto FieldTypeToString(FieldType type) -> std::string {
+    switch (type) {
+        case FieldType::Text: return "text";
+        case FieldType::Number: return "number";
+        case FieldType::Date: return "date";
+        case FieldType::Boolean: return "boolean";
+        case FieldType::Select: return "select";
+        case FieldType::Reference: return "reference";
+        case FieldType::Computed: return "computed";
+    }
+    return "text";
+}
+
+auto StringToFieldType(const std::string& str) -> FieldType {
+    if (str == "number") return FieldType::Number;
+    if (str == "date") return FieldType::Date;
+    if (str == "boolean") return FieldType::Boolean;
+    if (str == "select") return FieldType::Select;
+    if (str == "reference") return FieldType::Reference;
+    if (str == "computed") return FieldType::Computed;
+    return FieldType::Text;
+}
+
+namespace {
+
+/// Get current timestamp as ISO 8601 string
+auto GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        now.time_since_epoch()) % 1000;
+
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
+    return oss.str();
+}
+
+}  // namespace
+
+ViewService::ViewService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+ViewService::~ViewService() = default;
+
+ViewService::ViewService(ViewService&&) noexcept = default;
+
+auto ViewService::operator=(ViewService&& /*other*/) noexcept -> ViewService& {
+    // Cannot reassign reference member
+    return *this;
+}
+
+auto ViewService::Initialize() -> bool {
+    // Check if _views collection exists, create if not
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetCollectionMetadataRequest req;
+    ::smartbotic::database::CollectionMetadata resp;
+
+    req.set_name(kViewsCollection);
+    auto status = db_client_.GetCollectionService()->GetCollectionMetadata(&ctx, req, &resp);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kViewsCollection);
+        return true;
+    }
+
+    if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+        // Create the collection
+        grpc::ClientContext create_ctx;
+        ::smartbotic::database::CreateCollectionRequest create_req;
+        ::smartbotic::database::CollectionMetadata create_resp;
+
+        create_req.set_name(kViewsCollection);
+        auto create_status = db_client_.GetCollectionService()->CreateCollection(
+            &create_ctx, create_req, &create_resp);
+
+        if (!create_status.ok()) {
+            spdlog::error("Failed to create system collection {}: {}",
+                          kViewsCollection, create_status.error_message());
+            return false;
+        }
+        spdlog::info("Created system collection {}", kViewsCollection);
+        return true;
+    }
+
+    spdlog::error("Failed to check system collection {}: {}",
+                  kViewsCollection, status.error_message());
+    return false;
+}
+
+auto ViewService::CreateView(const CreateViewRequest& request) -> ViewResult {
+    // Check if view with same name already exists in workspace
+    auto existing = GetViewByName(request.workspace_id, request.name);
+    if (existing.success && existing.view) {
+        return {.success = false, .error = "View with this name already exists", .view = std::nullopt};
+    }
+
+    // Create view document
+    ViewInfo view;
+    view.workspace_id = request.workspace_id;
+    view.name = request.name;
+    view.collection_name = request.collection_name;
+    view.schema = request.schema;
+    view.settings = request.settings;
+    view.created_at = GetCurrentTimestamp();
+    view.updated_at = view.created_at;
+
+    auto json_data = ViewToJson(view);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kViewsCollection);
+
+    // Convert JSON to MapValue
+    auto* data = req.mutable_data();
+    for (auto it = json_data.begin(); it != json_data.end(); ++it) {
+        ::smartbotic::database::Value value;
+        if (it.value().is_string()) {
+            value.set_string_value(it.value().get<std::string>());
+        } else if (it.value().is_boolean()) {
+            value.set_bool_value(it.value().get<bool>());
+        } else if (it.value().is_object() || it.value().is_array()) {
+            value.set_string_value(it.value().dump());  // Store complex types as JSON string
+        }
+        (*data->mutable_fields())[it.key()] = value;
+    }
+
+    auto status = db_client_.GetDocumentService()->CreateDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("Failed to create view {}: {}", request.name, status.error_message());
+        return {.success = false, .error = "Failed to create view: " + status.error_message(), .view = std::nullopt};
+    }
+
+    view.id = resp.id();
+    spdlog::info("Created view {} in workspace {}", view.name, view.workspace_id);
+    return {.success = true, .error = "", .view = view};
+}
+
+auto ViewService::GetView(const std::string& workspace_id, const std::string& id) -> ViewResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kViewsCollection);
+    req.set_id(id);
+
+    auto status = db_client_.GetDocumentService()->GetDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "View not found", .view = std::nullopt};
+        }
+        return {.success = false, .error = "Failed to get view: " + status.error_message(), .view = std::nullopt};
+    }
+
+    // Convert response to JSON
+    nlohmann::json json_data;
+    for (const auto& [key, value] : resp.data().fields()) {
+        if (value.has_string_value()) {
+            // Try to parse as JSON if it looks like JSON
+            const auto& str = value.string_value();
+            if ((str.starts_with('{') && str.ends_with('}')) ||
+                (str.starts_with('[') && str.ends_with(']'))) {
+                try {
+                    json_data[key] = nlohmann::json::parse(str);
+                } catch (...) {
+                    json_data[key] = str;
+                }
+            } else {
+                json_data[key] = str;
+            }
+        } else if (value.has_bool_value()) {
+            json_data[key] = value.bool_value();
+        } else if (value.has_int_value()) {
+            json_data[key] = value.int_value();
+        }
+    }
+    json_data["id"] = resp.id();
+
+    auto view = JsonToView(json_data);
+
+    // Verify workspace matches
+    if (view.workspace_id != workspace_id) {
+        return {.success = false, .error = "View not found", .view = std::nullopt};
+    }
+
+    return {.success = true, .error = "", .view = view};
+}
+
+auto ViewService::GetViewByName(const std::string& workspace_id,
+                                 const std::string& name) -> ViewResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(kViewsCollection);
+    req.set_limit(1);
+
+    // Build filter for workspace_id and name
+    auto* filter = req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    auto* name_filter = composite->add_filters()->mutable_field();
+    name_filter->set_field("name");
+    name_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    name_filter->mutable_value()->set_string_value(name);
+
+    auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = "Failed to query views: " + status.error_message(), .view = std::nullopt};
+    }
+
+    if (resp.documents_size() == 0) {
+        return {.success = false, .error = "View not found", .view = std::nullopt};
+    }
+
+    // Convert first document
+    const auto& doc = resp.documents(0);
+    nlohmann::json json_data;
+    for (const auto& [key, value] : doc.data().fields()) {
+        if (value.has_string_value()) {
+            const auto& str = value.string_value();
+            if ((str.starts_with('{') && str.ends_with('}')) ||
+                (str.starts_with('[') && str.ends_with(']'))) {
+                try {
+                    json_data[key] = nlohmann::json::parse(str);
+                } catch (...) {
+                    json_data[key] = str;
+                }
+            } else {
+                json_data[key] = str;
+            }
+        } else if (value.has_bool_value()) {
+            json_data[key] = value.bool_value();
+        }
+    }
+    json_data["id"] = doc.id();
+
+    auto view = JsonToView(json_data);
+    return {.success = true, .error = "", .view = view};
+}
+
+auto ViewService::ListViews(const std::string& workspace_id) -> ViewListResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(kViewsCollection);
+    req.set_limit(1000);
+
+    // Filter by workspace_id
+    auto* filter = req.mutable_filter()->mutable_field();
+    filter->set_field("workspace_id");
+    filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    filter->mutable_value()->set_string_value(workspace_id);
+
+    auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = "Failed to list views: " + status.error_message(), .views = {}};
+    }
+
+    std::vector<ViewInfo> views;
+    views.reserve(resp.documents_size());
+
+    for (const auto& doc : resp.documents()) {
+        nlohmann::json json_data;
+        for (const auto& [key, value] : doc.data().fields()) {
+            if (value.has_string_value()) {
+                const auto& str = value.string_value();
+                if ((str.starts_with('{') && str.ends_with('}')) ||
+                    (str.starts_with('[') && str.ends_with(']'))) {
+                    try {
+                        json_data[key] = nlohmann::json::parse(str);
+                    } catch (...) {
+                        json_data[key] = str;
+                    }
+                } else {
+                    json_data[key] = str;
+                }
+            } else if (value.has_bool_value()) {
+                json_data[key] = value.bool_value();
+            }
+        }
+        json_data["id"] = doc.id();
+        views.push_back(JsonToView(json_data));
+    }
+
+    return {.success = true, .error = "", .views = std::move(views)};
+}
+
+auto ViewService::ListViewsForCollection(const std::string& workspace_id,
+                                          const std::string& collection_name) -> ViewListResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(kViewsCollection);
+    req.set_limit(1000);
+
+    // Filter by workspace_id and collection_name
+    auto* filter = req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    auto* coll_filter = composite->add_filters()->mutable_field();
+    coll_filter->set_field("collection_name");
+    coll_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    coll_filter->mutable_value()->set_string_value(collection_name);
+
+    auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = "Failed to list views: " + status.error_message(), .views = {}};
+    }
+
+    std::vector<ViewInfo> views;
+    views.reserve(resp.documents_size());
+
+    for (const auto& doc : resp.documents()) {
+        nlohmann::json json_data;
+        for (const auto& [key, value] : doc.data().fields()) {
+            if (value.has_string_value()) {
+                const auto& str = value.string_value();
+                if ((str.starts_with('{') && str.ends_with('}')) ||
+                    (str.starts_with('[') && str.ends_with(']'))) {
+                    try {
+                        json_data[key] = nlohmann::json::parse(str);
+                    } catch (...) {
+                        json_data[key] = str;
+                    }
+                } else {
+                    json_data[key] = str;
+                }
+            } else if (value.has_bool_value()) {
+                json_data[key] = value.bool_value();
+            }
+        }
+        json_data["id"] = doc.id();
+        views.push_back(JsonToView(json_data));
+    }
+
+    return {.success = true, .error = "", .views = std::move(views)};
+}
+
+auto ViewService::UpdateView(const UpdateViewRequest& request) -> ViewResult {
+    // Get existing view
+    auto existing = GetView(request.workspace_id, request.id);
+    if (!existing.success || !existing.view) {
+        return {.success = false, .error = "View not found", .view = std::nullopt};
+    }
+
+    ViewInfo updated = *existing.view;
+
+    // Apply updates
+    if (request.name) {
+        // Check for name conflict
+        if (*request.name != updated.name) {
+            auto conflict = GetViewByName(request.workspace_id, *request.name);
+            if (conflict.success && conflict.view) {
+                return {.success = false, .error = "View with this name already exists", .view = std::nullopt};
+            }
+        }
+        updated.name = *request.name;
+    }
+    if (request.collection_name) {
+        updated.collection_name = *request.collection_name;
+    }
+    if (request.schema) {
+        updated.schema = *request.schema;
+    }
+    if (request.settings) {
+        updated.settings = *request.settings;
+    }
+    updated.updated_at = GetCurrentTimestamp();
+
+    auto json_data = ViewToJson(updated);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kViewsCollection);
+    req.set_id(request.id);
+    req.set_merge(false);  // Replace entire document
+
+    // Convert JSON to MapValue
+    auto* data = req.mutable_data();
+    for (auto it = json_data.begin(); it != json_data.end(); ++it) {
+        ::smartbotic::database::Value value;
+        if (it.value().is_string()) {
+            value.set_string_value(it.value().get<std::string>());
+        } else if (it.value().is_boolean()) {
+            value.set_bool_value(it.value().get<bool>());
+        } else if (it.value().is_object() || it.value().is_array()) {
+            value.set_string_value(it.value().dump());
+        }
+        (*data->mutable_fields())[it.key()] = value;
+    }
+
+    auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("Failed to update view {}: {}", request.id, status.error_message());
+        return {.success = false, .error = "Failed to update view: " + status.error_message(), .view = std::nullopt};
+    }
+
+    spdlog::info("Updated view {} in workspace {}", updated.name, updated.workspace_id);
+    return {.success = true, .error = "", .view = updated};
+}
+
+auto ViewService::DeleteView(const std::string& workspace_id, const std::string& id) -> ViewResult {
+    // Verify view exists and belongs to workspace
+    auto existing = GetView(workspace_id, id);
+    if (!existing.success || !existing.view) {
+        return {.success = false, .error = "View not found", .view = std::nullopt};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::DeleteDocumentRequest req;
+    ::smartbotic::database::DeleteDocumentResponse resp;
+
+    req.set_collection(kViewsCollection);
+    req.set_id(id);
+
+    auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("Failed to delete view {}: {}", id, status.error_message());
+        return {.success = false, .error = "Failed to delete view: " + status.error_message(), .view = std::nullopt};
+    }
+
+    spdlog::info("Deleted view {} from workspace {}", existing.view->name, workspace_id);
+    return {.success = true, .error = "", .view = std::nullopt};
+}
+
+auto ViewService::ViewToJson(const ViewInfo& view) -> nlohmann::json {
+    return {
+        {"workspace_id", view.workspace_id},
+        {"name", view.name},
+        {"collection_name", view.collection_name},
+        {"schema", SchemaToJson(view.schema)},
+        {"settings", SettingsToJson(view.settings)},
+        {"created_at", view.created_at},
+        {"updated_at", view.updated_at}
+    };
+}
+
+auto ViewService::JsonToView(const nlohmann::json& json) -> ViewInfo {
+    ViewInfo view;
+    view.id = json.value("id", "");
+    view.workspace_id = json.value("workspace_id", "");
+    view.name = json.value("name", "");
+    view.collection_name = json.value("collection_name", "");
+    view.created_at = json.value("created_at", "");
+    view.updated_at = json.value("updated_at", "");
+
+    if (json.contains("schema") && json["schema"].is_object()) {
+        view.schema = JsonToSchema(json["schema"]);
+    }
+    if (json.contains("settings") && json["settings"].is_object()) {
+        view.settings = JsonToSettings(json["settings"]);
+    }
+
+    return view;
+}
+
+auto ViewService::SchemaToJson(const ViewSchema& schema) -> nlohmann::json {
+    nlohmann::json fields_json = nlohmann::json::array();
+    for (const auto& field : schema.fields) {
+        nlohmann::json field_json = {
+            {"name", field.name},
+            {"type", FieldTypeToString(field.type)},
+            {"label", field.label},
+            {"description", field.description},
+            {"required", field.required},
+            {"display_order", field.display_order},
+            {"widget", field.widget},
+            {"group", field.group}
+        };
+        if (!field.default_value.is_null()) {
+            field_json["default_value"] = field.default_value;
+        }
+        if (!field.options.is_null()) {
+            field_json["options"] = field.options;
+        }
+        if (!field.reference_collection.empty()) {
+            field_json["reference_collection"] = field.reference_collection;
+        }
+        if (!field.computed_expression.empty()) {
+            field_json["computed_expression"] = field.computed_expression;
+        }
+        fields_json.push_back(field_json);
+    }
+
+    return {
+        {"fields", fields_json},
+        {"title", schema.title},
+        {"description", schema.description},
+        {"layout", schema.layout}
+    };
+}
+
+auto ViewService::JsonToSchema(const nlohmann::json& json) -> ViewSchema {
+    ViewSchema schema;
+    schema.title = json.value("title", "");
+    schema.description = json.value("description", "");
+    schema.layout = json.value("layout", nlohmann::json::object());
+
+    if (json.contains("fields") && json["fields"].is_array()) {
+        for (const auto& field_json : json["fields"]) {
+            SchemaField field;
+            field.name = field_json.value("name", "");
+            field.type = StringToFieldType(field_json.value("type", "text"));
+            field.label = field_json.value("label", "");
+            field.description = field_json.value("description", "");
+            field.required = field_json.value("required", false);
+            field.display_order = field_json.value("display_order", 0);
+            field.widget = field_json.value("widget", "");
+            field.group = field_json.value("group", "");
+            field.default_value = field_json.value("default_value", nlohmann::json());
+            field.options = field_json.value("options", nlohmann::json());
+            field.reference_collection = field_json.value("reference_collection", "");
+            field.computed_expression = field_json.value("computed_expression", "");
+            schema.fields.push_back(field);
+        }
+    }
+
+    return schema;
+}
+
+auto ViewService::SettingsToJson(const ViewSettings& settings) -> nlohmann::json {
+    return {
+        {"is_default", settings.is_default},
+        {"show_in_sidebar", settings.show_in_sidebar},
+        {"icon", settings.icon},
+        {"filters", settings.filters},
+        {"sort", settings.sort}
+    };
+}
+
+auto ViewService::JsonToSettings(const nlohmann::json& json) -> ViewSettings {
+    ViewSettings settings;
+    settings.is_default = json.value("is_default", false);
+    settings.show_in_sidebar = json.value("show_in_sidebar", true);
+    settings.icon = json.value("icon", "");
+    settings.filters = json.value("filters", nlohmann::json::object());
+    settings.sort = json.value("sort", nlohmann::json::object());
+    return settings;
+}
+
+}  // namespace smartbotic::webserver

+ 409 - 0
webserver/src/workspace_service.cpp

@@ -0,0 +1,409 @@
+#include "smartbotic/webserver/workspace_service.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+namespace smartbotic::webserver {
+
+namespace {
+
+auto SetStringValue(::smartbotic::database::MapValue* map, const std::string& key, const std::string& value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_string_value(value);
+}
+
+auto GetStringValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::string {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_string_value()) {
+        return it->second.string_value();
+    }
+    return "";
+}
+
+}  // namespace
+
+WorkspaceService::WorkspaceService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+WorkspaceService::~WorkspaceService() = default;
+
+// Move operations not supported due to reference member
+WorkspaceService::WorkspaceService(WorkspaceService&& other) noexcept
+    : db_client_(other.db_client_), initialized_(other.initialized_) {}
+
+auto WorkspaceService::operator=(WorkspaceService&& /*other*/) noexcept -> WorkspaceService& {
+    // Cannot reassign reference member, so this is essentially a no-op
+    return *this;
+}
+
+auto WorkspaceService::Initialize() -> bool {
+    if (initialized_) {
+        return true;
+    }
+
+    auto* collection_service = db_client_.GetCollectionService();
+    if (collection_service == nullptr) {
+        spdlog::error("Collection service not available");
+        return false;
+    }
+
+    // Check if _workspaces collection exists
+    ::smartbotic::database::GetCollectionMetadataRequest get_req;
+    get_req.set_name(kCollectionName);
+
+    grpc::ClientContext get_ctx;
+    ::smartbotic::database::CollectionMetadata metadata;
+    auto status = collection_service->GetCollectionMetadata(&get_ctx, get_req, &metadata);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kCollectionName);
+        initialized_ = true;
+        return true;
+    }
+
+    // Collection doesn't exist, create it
+    ::smartbotic::database::CreateCollectionRequest create_req;
+    create_req.set_name(kCollectionName);
+
+    // Add unique index on name
+    auto* name_index = create_req.add_indexes();
+    name_index->set_collection(kCollectionName);
+    name_index->set_index_name("name_unique");
+    name_index->add_fields("name");
+    name_index->set_unique(true);
+    name_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+    grpc::ClientContext create_ctx;
+    ::smartbotic::database::CollectionMetadata created_metadata;
+    status = collection_service->CreateCollection(&create_ctx, create_req, &created_metadata);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create {} collection: {}", kCollectionName, status.error_message());
+        return false;
+    }
+
+    spdlog::info("Created system collection {}", kCollectionName);
+    initialized_ = true;
+    return true;
+}
+
+auto WorkspaceService::CreateWorkspace(const CreateWorkspaceRequest& request) -> WorkspaceResult {
+    if (!initialized_) {
+        return WorkspaceResult{.success = false, .error = "Workspace service not initialized", .workspace = std::nullopt};
+    }
+
+    // Validate request
+    if (request.name.empty()) {
+        return WorkspaceResult{.success = false, .error = "Name is required", .workspace = std::nullopt};
+    }
+
+    // Check if name already exists
+    if (NameExists(request.name)) {
+        return WorkspaceResult{.success = false, .error = "Workspace name already exists", .workspace = std::nullopt};
+    }
+
+    // Create the document
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return WorkspaceResult{.success = false, .error = "Document service not available", .workspace = std::nullopt};
+    }
+
+    ::smartbotic::database::CreateDocumentRequest create_req;
+    create_req.set_collection(kCollectionName);
+
+    auto* data = create_req.mutable_data();
+    SetStringValue(data, "name", request.name);
+    SetStringValue(data, "settings", request.settings.dump());
+    SetStringValue(data, "created_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+    SetStringValue(data, "deleted_at", "");
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document created_doc;
+    auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create workspace: {}", status.error_message());
+        return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
+    }
+
+    spdlog::info("Created workspace {} with name {}", created_doc.id(), request.name);
+    return WorkspaceResult{.success = true, .error = "", .workspace = DocumentToWorkspace(created_doc)};
+}
+
+auto WorkspaceService::GetWorkspace(const std::string& id, bool include_deleted) -> WorkspaceResult {
+    if (!initialized_) {
+        return WorkspaceResult{.success = false, .error = "Workspace service not initialized", .workspace = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return WorkspaceResult{.success = false, .error = "Document service not available", .workspace = std::nullopt};
+    }
+
+    ::smartbotic::database::GetDocumentRequest get_req;
+    get_req.set_collection(kCollectionName);
+    get_req.set_id(id);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document doc;
+    auto status = doc_service->GetDocument(&ctx, get_req, &doc);
+
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return WorkspaceResult{.success = false, .error = "Workspace not found", .workspace = std::nullopt};
+        }
+        return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
+    }
+
+    auto workspace = DocumentToWorkspace(doc);
+    if (!include_deleted && workspace.IsDeleted()) {
+        return WorkspaceResult{.success = false, .error = "Workspace not found", .workspace = std::nullopt};
+    }
+
+    return WorkspaceResult{.success = true, .error = "", .workspace = workspace};
+}
+
+auto WorkspaceService::GetWorkspaceByName(const std::string& name, bool include_deleted) -> WorkspaceResult {
+    if (!initialized_) {
+        return WorkspaceResult{.success = false, .error = "Workspace service not initialized", .workspace = std::nullopt};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return WorkspaceResult{.success = false, .error = "Query service not available", .workspace = std::nullopt};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(1);
+
+    auto* filter = query_req.mutable_filter();
+    auto* field_filter = filter->mutable_field();
+    field_filter->set_field("name");
+    field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    field_filter->mutable_value()->set_string_value(name);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
+    }
+
+    if (response.documents().empty()) {
+        return WorkspaceResult{.success = false, .error = "Workspace not found", .workspace = std::nullopt};
+    }
+
+    auto workspace = DocumentToWorkspace(response.documents(0));
+    if (!include_deleted && workspace.IsDeleted()) {
+        return WorkspaceResult{.success = false, .error = "Workspace not found", .workspace = std::nullopt};
+    }
+
+    return WorkspaceResult{.success = true, .error = "", .workspace = workspace};
+}
+
+auto WorkspaceService::ListWorkspaces(int limit, int offset, bool include_deleted) -> WorkspaceListResult {
+    if (!initialized_) {
+        return WorkspaceListResult{.success = false, .error = "Workspace service not initialized", .workspaces = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return WorkspaceListResult{.success = false, .error = "Query service not available", .workspaces = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kCollectionName);
+    query_req.set_limit(limit);
+    query_req.set_offset(offset);
+
+    // Add filter to exclude deleted workspaces if not including them
+    if (!include_deleted) {
+        auto* filter = query_req.mutable_filter();
+        auto* field_filter = filter->mutable_field();
+        field_filter->set_field("deleted_at");
+        field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        field_filter->mutable_value()->set_string_value("");
+    }
+
+    // Order by created_at descending
+    auto* order = query_req.add_order_by();
+    order->set_field("created_at");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_DESCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return WorkspaceListResult{.success = false, .error = status.error_message(), .workspaces = {}, .total_count = 0};
+    }
+
+    WorkspaceListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        result.workspaces.push_back(DocumentToWorkspace(doc));
+    }
+
+    return result;
+}
+
+auto WorkspaceService::ListWorkspacesByIds(const std::vector<std::string>& workspace_ids, bool include_deleted) -> WorkspaceListResult {
+    if (!initialized_) {
+        return WorkspaceListResult{.success = false, .error = "Workspace service not initialized", .workspaces = {}, .total_count = 0};
+    }
+
+    if (workspace_ids.empty()) {
+        return WorkspaceListResult{.success = true, .error = "", .workspaces = {}, .total_count = 0};
+    }
+
+    // Fetch each workspace by ID
+    WorkspaceListResult result;
+    result.success = true;
+
+    for (const auto& id : workspace_ids) {
+        auto ws_result = GetWorkspace(id, include_deleted);
+        if (ws_result.success && ws_result.workspace) {
+            result.workspaces.push_back(*ws_result.workspace);
+        }
+    }
+
+    result.total_count = static_cast<int64_t>(result.workspaces.size());
+    return result;
+}
+
+auto WorkspaceService::UpdateWorkspace(const std::string& id, const UpdateWorkspaceRequest& request) -> WorkspaceResult {
+    if (!initialized_) {
+        return WorkspaceResult{.success = false, .error = "Workspace service not initialized", .workspace = std::nullopt};
+    }
+
+    // First get the existing workspace
+    auto existing = GetWorkspace(id, false);
+    if (!existing.success) {
+        return existing;
+    }
+
+    // Check if name is being updated and if it already exists
+    if (request.name && *request.name != existing.workspace->name) {
+        if (NameExists(*request.name)) {
+            return WorkspaceResult{.success = false, .error = "Workspace name already exists", .workspace = std::nullopt};
+        }
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return WorkspaceResult{.success = false, .error = "Document service not available", .workspace = std::nullopt};
+    }
+
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);  // Merge with existing data
+
+    auto* data = update_req.mutable_data();
+
+    if (request.name) {
+        SetStringValue(data, "name", *request.name);
+    }
+    if (request.settings) {
+        SetStringValue(data, "settings", request.settings->dump());
+    }
+
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
+    }
+
+    spdlog::info("Updated workspace {}", id);
+    return WorkspaceResult{.success = true, .error = "", .workspace = DocumentToWorkspace(updated_doc)};
+}
+
+auto WorkspaceService::DeleteWorkspace(const std::string& id) -> WorkspaceResult {
+    if (!initialized_) {
+        return WorkspaceResult{.success = false, .error = "Workspace service not initialized", .workspace = std::nullopt};
+    }
+
+    // First get the existing workspace
+    auto existing = GetWorkspace(id, false);
+    if (!existing.success) {
+        return existing;
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return WorkspaceResult{.success = false, .error = "Document service not available", .workspace = std::nullopt};
+    }
+
+    // Soft delete by setting deleted_at
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kCollectionName);
+    update_req.set_id(id);
+    update_req.set_merge(true);
+
+    auto* data = update_req.mutable_data();
+    SetStringValue(data, "deleted_at", GetCurrentTimestamp());
+    SetStringValue(data, "updated_at", GetCurrentTimestamp());
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    if (!status.ok()) {
+        return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
+    }
+
+    spdlog::info("Soft deleted workspace {}", id);
+    return WorkspaceResult{.success = true, .error = "", .workspace = DocumentToWorkspace(updated_doc)};
+}
+
+auto WorkspaceService::NameExists(const std::string& name) -> bool {
+    auto result = GetWorkspaceByName(name, true);
+    return result.success;
+}
+
+auto WorkspaceService::DocumentToWorkspace(const ::smartbotic::database::Document& doc) -> Workspace {
+    Workspace workspace;
+    workspace.id = doc.id();
+    workspace.name = GetStringValue(doc.data(), "name");
+    workspace.created_at = GetStringValue(doc.data(), "created_at");
+    workspace.updated_at = GetStringValue(doc.data(), "updated_at");
+    workspace.deleted_at = GetStringValue(doc.data(), "deleted_at");
+
+    // Parse settings JSON
+    std::string settings_str = GetStringValue(doc.data(), "settings");
+    if (!settings_str.empty()) {
+        try {
+            workspace.settings = nlohmann::json::parse(settings_str);
+        } catch (const nlohmann::json::parse_error&) {
+            workspace.settings = nlohmann::json::object();
+        }
+    } else {
+        workspace.settings = nlohmann::json::object();
+    }
+
+    return workspace;
+}
+
+auto WorkspaceService::GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
+
+    std::ostringstream oss;
+    oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%S");
+    oss << '.' << std::setfill('0') << std::setw(3) << ms.count() << 'Z';
+    return oss.str();
+}
+
+}  // namespace smartbotic::webserver

+ 295 - 0
webserver/src/ws_handler.cpp

@@ -0,0 +1,295 @@
+#include "smartbotic/webserver/ws_handler.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/webserver/http_server.hpp"
+
+namespace smartbotic::webserver {
+
+auto ParseMessageType(const std::string& type) -> WsMessageType {
+    if (type == "auth") return WsMessageType::Auth;
+    if (type == "subscribe") return WsMessageType::Subscribe;
+    if (type == "unsubscribe") return WsMessageType::Unsubscribe;
+    if (type == "document") return WsMessageType::Document;
+    if (type == "notification") return WsMessageType::Notification;
+    if (type == "error") return WsMessageType::Error;
+    if (type == "ping") return WsMessageType::Pong;
+    return WsMessageType::Unknown;
+}
+
+auto MessageTypeToString(WsMessageType type) -> std::string {
+    switch (type) {
+        case WsMessageType::Auth: return "auth";
+        case WsMessageType::Subscribe: return "subscribe";
+        case WsMessageType::Unsubscribe: return "unsubscribe";
+        case WsMessageType::Document: return "document";
+        case WsMessageType::Notification: return "notification";
+        case WsMessageType::Error: return "error";
+        case WsMessageType::Pong: return "pong";
+        default: return "unknown";
+    }
+}
+
+auto DocumentActionToString(DocumentAction action) -> std::string {
+    switch (action) {
+        case DocumentAction::Create: return "create";
+        case DocumentAction::Update: return "update";
+        case DocumentAction::Delete: return "delete";
+    }
+    return "unknown";
+}
+
+auto NotificationLevelToString(NotificationLevel level) -> std::string {
+    switch (level) {
+        case NotificationLevel::Info: return "info";
+        case NotificationLevel::Warning: return "warn";
+        case NotificationLevel::Error: return "error";
+    }
+    return "info";
+}
+
+WsHandler::WsHandler(AuthService& auth_service) : auth_service_(auth_service) {}
+
+WsHandler::~WsHandler() = default;
+
+WsHandler::WsHandler(WsHandler&&) noexcept = default;
+
+auto WsHandler::operator=(WsHandler&& /*other*/) noexcept -> WsHandler& {
+    // Cannot reassign reference member
+    return *this;
+}
+
+void WsHandler::HandleMessage(WsConnection* conn, const std::string& message,
+                               const SendCallback& send_callback) {
+    try {
+        auto json = nlohmann::json::parse(message);
+
+        if (!json.contains("type")) {
+            SendError(conn, "Missing 'type' field", send_callback);
+            return;
+        }
+
+        std::string type_str = json["type"].get<std::string>();
+        auto type = ParseMessageType(type_str);
+
+        // Auth is allowed before authentication
+        if (type == WsMessageType::Auth) {
+            HandleAuth(conn, json, send_callback);
+            return;
+        }
+
+        // Ping/pong is always allowed
+        if (type == WsMessageType::Pong) {
+            HandlePing(conn, send_callback);
+            return;
+        }
+
+        // All other message types require authentication
+        if (!conn->authenticated) {
+            SendError(conn, "Not authenticated. Send auth message first.", send_callback);
+            return;
+        }
+
+        switch (type) {
+            case WsMessageType::Subscribe:
+                HandleSubscribe(conn, json, send_callback);
+                break;
+            case WsMessageType::Unsubscribe:
+                HandleUnsubscribe(conn, json, send_callback);
+                break;
+            default:
+                SendError(conn, "Unknown or unsupported message type: " + type_str, send_callback);
+                break;
+        }
+
+    } catch (const nlohmann::json::parse_error& e) {
+        spdlog::warn("WebSocket received invalid JSON: {}", e.what());
+        SendError(conn, "Invalid JSON", send_callback);
+    } catch (const std::exception& e) {
+        spdlog::error("WebSocket message handler error: {}", e.what());
+        SendError(conn, "Internal error", send_callback);
+    }
+}
+
+auto WsHandler::AuthenticateFromToken(WsConnection* conn, const std::string& token) -> bool {
+    auto auth_result = auth_service_.ValidateAccessToken(token);
+    if (!auth_result.valid || !auth_result.user) {
+        spdlog::debug("WebSocket auth failed: {}", auth_result.error);
+        return false;
+    }
+
+    conn->authenticated = true;
+    conn->userId = auth_result.user->user_id;
+    // Store workspace IDs for access control
+    // Note: In a full implementation, we'd store these in the connection
+
+    spdlog::info("WebSocket authenticated user {} (session: {})",
+                 auth_result.user->email, conn->sessionId);
+    return true;
+}
+
+void WsHandler::HandleAuth(WsConnection* conn, const nlohmann::json& payload,
+                            const SendCallback& send_callback) {
+    if (!payload.contains("token")) {
+        SendError(conn, "Missing 'token' field", send_callback);
+        return;
+    }
+
+    std::string token = payload["token"].get<std::string>();
+    if (AuthenticateFromToken(conn, token)) {
+        nlohmann::json response = {
+            {"type", "auth_success"},
+            {"user_id", conn->userId},
+            {"session_id", conn->sessionId}
+        };
+        send_callback(conn, response.dump());
+    } else {
+        nlohmann::json response = {
+            {"type", "auth_error"},
+            {"error", "Invalid or expired token"}
+        };
+        send_callback(conn, response.dump());
+    }
+}
+
+void WsHandler::HandleSubscribe(WsConnection* conn, const nlohmann::json& payload,
+                                  const SendCallback& send_callback) {
+    if (!payload.contains("workspace_id") || !payload.contains("collection")) {
+        SendError(conn, "Missing 'workspace_id' or 'collection' field", send_callback);
+        return;
+    }
+
+    std::string workspace_id = payload["workspace_id"].get<std::string>();
+    std::string collection = payload["collection"].get<std::string>();
+
+    // Validate access (in full implementation, check workspace membership)
+    if (!ValidateAccess(conn, workspace_id, collection)) {
+        SendError(conn, "Access denied to collection", send_callback);
+        return;
+    }
+
+    std::string sub_key = BuildSubscriptionKey(workspace_id, collection);
+
+    // Add to subscriptions if not already subscribed
+    auto& subs = conn->subscriptions;
+    if (std::find(subs.begin(), subs.end(), sub_key) == subs.end()) {
+        subs.push_back(sub_key);
+        spdlog::debug("User {} subscribed to {} (session: {})",
+                      conn->userId, sub_key, conn->sessionId);
+    }
+
+    nlohmann::json response = {
+        {"type", "subscribe"},
+        {"success", true},
+        {"workspace_id", workspace_id},
+        {"collection", collection}
+    };
+    send_callback(conn, response.dump());
+}
+
+void WsHandler::HandleUnsubscribe(WsConnection* conn, const nlohmann::json& payload,
+                                    const SendCallback& send_callback) {
+    if (!payload.contains("workspace_id") || !payload.contains("collection")) {
+        SendError(conn, "Missing 'workspace_id' or 'collection' field", send_callback);
+        return;
+    }
+
+    std::string workspace_id = payload["workspace_id"].get<std::string>();
+    std::string collection = payload["collection"].get<std::string>();
+    std::string sub_key = BuildSubscriptionKey(workspace_id, collection);
+
+    // Remove from subscriptions
+    auto& subs = conn->subscriptions;
+    auto it = std::find(subs.begin(), subs.end(), sub_key);
+    if (it != subs.end()) {
+        subs.erase(it);
+        spdlog::debug("User {} unsubscribed from {} (session: {})",
+                      conn->userId, sub_key, conn->sessionId);
+    }
+
+    nlohmann::json response = {
+        {"type", "unsubscribe"},
+        {"success", true},
+        {"workspace_id", workspace_id},
+        {"collection", collection}
+    };
+    send_callback(conn, response.dump());
+}
+
+void WsHandler::HandlePing(WsConnection* conn, const SendCallback& send_callback) {
+    nlohmann::json response = {{"type", "pong"}};
+    send_callback(conn, response.dump());
+}
+
+auto WsHandler::ValidateAccess(const WsConnection* conn,
+                                 const std::string& workspace_id,
+                                 const std::string& /*collection*/) const -> bool {
+    // For now, allow authenticated users to subscribe to any collection
+    // In a full implementation, check workspace membership and collection policies
+    (void)workspace_id;
+    return conn->authenticated;
+}
+
+void WsHandler::BroadcastDocumentEvent(const std::string& workspace_id,
+                                         const std::string& collection,
+                                         DocumentAction action,
+                                         const std::string& document_id,
+                                         const nlohmann::json& data,
+                                         const std::function<void(const std::string&)>& broadcast_to_subscribed) {
+    nlohmann::json event = {
+        {"type", "document"},
+        {"action", DocumentActionToString(action)},
+        {"workspace_id", workspace_id},
+        {"collection", collection},
+        {"document_id", document_id}
+    };
+
+    // Include data for create/update, exclude for delete
+    if (action != DocumentAction::Delete && !data.is_null()) {
+        event["data"] = data;
+    }
+
+    std::string event_str = event.dump();
+    std::string sub_key = BuildSubscriptionKey(workspace_id, collection);
+
+    spdlog::debug("Broadcasting {} event for doc {} in {}",
+                  DocumentActionToString(action), document_id, sub_key);
+
+    // The broadcast_to_subscribed callback handles iterating over connections
+    // and filtering by subscription
+    broadcast_to_subscribed(event_str);
+}
+
+void WsHandler::SendNotification(WsConnection* conn, const std::string& message,
+                                   NotificationLevel level, const SendCallback& send_callback) {
+    nlohmann::json notification = {
+        {"type", "notification"},
+        {"message", message},
+        {"level", NotificationLevelToString(level)}
+    };
+    send_callback(conn, notification.dump());
+}
+
+void WsHandler::SendError(WsConnection* conn, const std::string& error,
+                           const SendCallback& send_callback) {
+    nlohmann::json err_msg = {
+        {"type", "error"},
+        {"error", error}
+    };
+    send_callback(conn, err_msg.dump());
+}
+
+auto WsHandler::IsSubscribed(const WsConnection* conn,
+                               const std::string& workspace_id,
+                               const std::string& collection) -> bool {
+    std::string sub_key = BuildSubscriptionKey(workspace_id, collection);
+    const auto& subs = conn->subscriptions;
+    return std::find(subs.begin(), subs.end(), sub_key) != subs.end();
+}
+
+auto WsHandler::BuildSubscriptionKey(const std::string& workspace_id,
+                                       const std::string& collection) -> std::string {
+    return workspace_id + ":" + collection;
+}
+
+}  // namespace smartbotic::webserver

+ 26 - 0
webui/.gitignore

@@ -0,0 +1,26 @@
+# Dependencies
+node_modules/
+
+# Build output
+dist/
+
+# Logs
+*.log
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea/
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Environment variables
+.env
+.env.local
+.env.*.local
+
+# TypeScript cache
+*.tsbuildinfo

+ 8 - 0
webui/.prettierrc

@@ -0,0 +1,8 @@
+{
+  "semi": false,
+  "singleQuote": true,
+  "tabWidth": 2,
+  "trailingComma": "es5",
+  "printWidth": 100,
+  "plugins": ["prettier-plugin-tailwindcss"]
+}

+ 31 - 0
webui/eslint.config.js

@@ -0,0 +1,31 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import eslintConfigPrettier from 'eslint-config-prettier'
+
+export default tseslint.config(
+  { ignores: ['dist', 'node_modules'] },
+  {
+    extends: [js.configs.recommended, ...tseslint.configs.recommended],
+    files: ['**/*.{ts,tsx}'],
+    languageOptions: {
+      ecmaVersion: 2022,
+      globals: globals.browser,
+    },
+    plugins: {
+      'react-hooks': reactHooks,
+      'react-refresh': reactRefresh,
+    },
+    rules: {
+      ...reactHooks.configs.recommended.rules,
+      'react-refresh/only-export-components': [
+        'warn',
+        { allowConstantExport: true },
+      ],
+      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+    },
+  },
+  eslintConfigPrettier,
+)

+ 13 - 0
webui/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>SmartBotic CRM</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 40 - 0
webui/package.json

@@ -0,0 +1,40 @@
+{
+  "name": "smartbotic-crm-webui",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "lint": "eslint .",
+    "lint:fix": "eslint . --fix",
+    "format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
+    "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
+    "preview": "vite preview",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "react-router-dom": "^7.1.0"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.18.0",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "@vitejs/plugin-react": "^4.3.4",
+    "autoprefixer": "^10.4.20",
+    "eslint": "^9.18.0",
+    "eslint-config-prettier": "^10.0.1",
+    "eslint-plugin-react-hooks": "^5.1.0",
+    "eslint-plugin-react-refresh": "^0.4.16",
+    "globals": "^15.14.0",
+    "postcss": "^8.5.1",
+    "prettier": "^3.4.2",
+    "prettier-plugin-tailwindcss": "^0.6.9",
+    "tailwindcss": "^3.4.17",
+    "typescript": "~5.7.3",
+    "typescript-eslint": "^8.20.0",
+    "vite": "^6.0.7"
+  }
+}

+ 2646 - 0
webui/pnpm-lock.yaml

@@ -0,0 +1,2646 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      react:
+        specifier: ^19.0.0
+        version: 19.2.4
+      react-dom:
+        specifier: ^19.0.0
+        version: 19.2.4(react@19.2.4)
+      react-router-dom:
+        specifier: ^7.1.0
+        version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+    devDependencies:
+      '@eslint/js':
+        specifier: ^9.18.0
+        version: 9.39.2
+      '@types/react':
+        specifier: ^19.0.0
+        version: 19.2.10
+      '@types/react-dom':
+        specifier: ^19.0.0
+        version: 19.2.3(@types/react@19.2.10)
+      '@vitejs/plugin-react':
+        specifier: ^4.3.4
+        version: 4.7.0(vite@6.4.1(jiti@1.21.7))
+      autoprefixer:
+        specifier: ^10.4.20
+        version: 10.4.23(postcss@8.5.6)
+      eslint:
+        specifier: ^9.18.0
+        version: 9.39.2(jiti@1.21.7)
+      eslint-config-prettier:
+        specifier: ^10.0.1
+        version: 10.1.8(eslint@9.39.2(jiti@1.21.7))
+      eslint-plugin-react-hooks:
+        specifier: ^5.1.0
+        version: 5.2.0(eslint@9.39.2(jiti@1.21.7))
+      eslint-plugin-react-refresh:
+        specifier: ^0.4.16
+        version: 0.4.26(eslint@9.39.2(jiti@1.21.7))
+      globals:
+        specifier: ^15.14.0
+        version: 15.15.0
+      postcss:
+        specifier: ^8.5.1
+        version: 8.5.6
+      prettier:
+        specifier: ^3.4.2
+        version: 3.8.1
+      prettier-plugin-tailwindcss:
+        specifier: ^0.6.9
+        version: 0.6.14(prettier@3.8.1)
+      tailwindcss:
+        specifier: ^3.4.17
+        version: 3.4.19
+      typescript:
+        specifier: ~5.7.3
+        version: 5.7.3
+      typescript-eslint:
+        specifier: ^8.20.0
+        version: 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      vite:
+        specifier: ^6.0.7
+        version: 6.4.1(jiti@1.21.7)
+
+packages:
+
+  '@alloc/quick-lru@5.2.0':
+    resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+    engines: {node: '>=10'}
+
+  '@babel/code-frame@7.28.6':
+    resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/compat-data@7.28.6':
+    resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/core@7.28.6':
+    resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/generator@7.28.6':
+    resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-compilation-targets@7.28.6':
+    resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-globals@7.28.0':
+    resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-imports@7.28.6':
+    resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-transforms@7.28.6':
+    resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0
+
+  '@babel/helper-plugin-utils@7.28.6':
+    resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-string-parser@7.27.1':
+    resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.28.5':
+    resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-option@7.27.1':
+    resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helpers@7.28.6':
+    resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/parser@7.28.6':
+    resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  '@babel/plugin-transform-react-jsx-self@7.27.1':
+    resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0-0
+
+  '@babel/plugin-transform-react-jsx-source@7.27.1':
+    resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0-0
+
+  '@babel/template@7.28.6':
+    resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/traverse@7.28.6':
+    resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/types@7.28.6':
+    resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
+    engines: {node: '>=6.9.0'}
+
+  '@esbuild/aix-ppc64@0.25.12':
+    resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.25.12':
+    resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.25.12':
+    resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.25.12':
+    resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.25.12':
+    resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.25.12':
+    resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.25.12':
+    resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.25.12':
+    resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.25.12':
+    resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.25.12':
+    resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.25.12':
+    resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.25.12':
+    resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.25.12':
+    resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.25.12':
+    resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.25.12':
+    resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.25.12':
+    resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.12':
+    resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.12':
+    resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.25.12':
+    resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.25.12':
+    resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.25.12':
+    resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.25.12':
+    resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@eslint-community/eslint-utils@4.9.1':
+    resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    peerDependencies:
+      eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+  '@eslint-community/regexpp@4.12.2':
+    resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+    engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+  '@eslint/config-array@0.21.1':
+    resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/config-helpers@0.4.2':
+    resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/core@0.17.0':
+    resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/eslintrc@3.3.3':
+    resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/js@9.39.2':
+    resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/object-schema@2.1.7':
+    resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/plugin-kit@0.4.1':
+    resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@humanfs/core@0.19.1':
+    resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+    engines: {node: '>=18.18.0'}
+
+  '@humanfs/node@0.16.7':
+    resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
+    engines: {node: '>=18.18.0'}
+
+  '@humanwhocodes/module-importer@1.0.1':
+    resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+    engines: {node: '>=12.22'}
+
+  '@humanwhocodes/retry@0.4.3':
+    resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+    engines: {node: '>=18.18'}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+  '@jridgewell/remapping@2.3.5':
+    resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/sourcemap-codec@1.5.5':
+    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+  '@nodelib/fs.scandir@2.1.5':
+    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.stat@2.0.5':
+    resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.walk@1.2.8':
+    resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+    engines: {node: '>= 8'}
+
+  '@rolldown/pluginutils@1.0.0-beta.27':
+    resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+  '@rollup/rollup-android-arm-eabi@4.57.0':
+    resolution: {integrity: sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.57.0':
+    resolution: {integrity: sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.57.0':
+    resolution: {integrity: sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.57.0':
+    resolution: {integrity: sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.57.0':
+    resolution: {integrity: sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.57.0':
+    resolution: {integrity: sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.57.0':
+    resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.57.0':
+    resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.57.0':
+    resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.57.0':
+    resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loong64-gnu@4.57.0':
+    resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-loong64-musl@4.57.0':
+    resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.57.0':
+    resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-musl@4.57.0':
+    resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.57.0':
+    resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.57.0':
+    resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.57.0':
+    resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.57.0':
+    resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.57.0':
+    resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-openbsd-x64@4.57.0':
+    resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@rollup/rollup-openharmony-arm64@4.57.0':
+    resolution: {integrity: sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rollup/rollup-win32-arm64-msvc@4.57.0':
+    resolution: {integrity: sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.57.0':
+    resolution: {integrity: sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-gnu@4.57.0':
+    resolution: {integrity: sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.57.0':
+    resolution: {integrity: sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==}
+    cpu: [x64]
+    os: [win32]
+
+  '@types/babel__core@7.20.5':
+    resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+  '@types/babel__generator@7.27.0':
+    resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+  '@types/babel__template@7.4.4':
+    resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+  '@types/babel__traverse@7.28.0':
+    resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+  '@types/estree@1.0.8':
+    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+  '@types/json-schema@7.0.15':
+    resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+  '@types/react-dom@19.2.3':
+    resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+    peerDependencies:
+      '@types/react': ^19.2.0
+
+  '@types/react@19.2.10':
+    resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==}
+
+  '@typescript-eslint/eslint-plugin@8.54.0':
+    resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      '@typescript-eslint/parser': ^8.54.0
+      eslint: ^8.57.0 || ^9.0.0
+      typescript: '>=4.8.4 <6.0.0'
+
+  '@typescript-eslint/parser@8.54.0':
+    resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^8.57.0 || ^9.0.0
+      typescript: '>=4.8.4 <6.0.0'
+
+  '@typescript-eslint/project-service@8.54.0':
+    resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: '>=4.8.4 <6.0.0'
+
+  '@typescript-eslint/scope-manager@8.54.0':
+    resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@typescript-eslint/tsconfig-utils@8.54.0':
+    resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: '>=4.8.4 <6.0.0'
+
+  '@typescript-eslint/type-utils@8.54.0':
+    resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^8.57.0 || ^9.0.0
+      typescript: '>=4.8.4 <6.0.0'
+
+  '@typescript-eslint/types@8.54.0':
+    resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@typescript-eslint/typescript-estree@8.54.0':
+    resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: '>=4.8.4 <6.0.0'
+
+  '@typescript-eslint/utils@8.54.0':
+    resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^8.57.0 || ^9.0.0
+      typescript: '>=4.8.4 <6.0.0'
+
+  '@typescript-eslint/visitor-keys@8.54.0':
+    resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@vitejs/plugin-react@4.7.0':
+    resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+  acorn-jsx@5.3.2:
+    resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+    peerDependencies:
+      acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+  acorn@8.15.0:
+    resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+    engines: {node: '>=0.4.0'}
+    hasBin: true
+
+  ajv@6.12.6:
+    resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  any-promise@1.3.0:
+    resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+  anymatch@3.1.3:
+    resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+    engines: {node: '>= 8'}
+
+  arg@5.0.2:
+    resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+  argparse@2.0.1:
+    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+  autoprefixer@10.4.23:
+    resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
+    engines: {node: ^10 || ^12 || >=14}
+    hasBin: true
+    peerDependencies:
+      postcss: ^8.1.0
+
+  balanced-match@1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+  baseline-browser-mapping@2.9.19:
+    resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
+    hasBin: true
+
+  binary-extensions@2.3.0:
+    resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+    engines: {node: '>=8'}
+
+  brace-expansion@1.1.12:
+    resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+  brace-expansion@2.0.2:
+    resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+  braces@3.0.3:
+    resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+    engines: {node: '>=8'}
+
+  browserslist@4.28.1:
+    resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+    engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+    hasBin: true
+
+  callsites@3.1.0:
+    resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+    engines: {node: '>=6'}
+
+  camelcase-css@2.0.1:
+    resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+    engines: {node: '>= 6'}
+
+  caniuse-lite@1.0.30001766:
+    resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
+
+  chalk@4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+
+  chokidar@3.6.0:
+    resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+    engines: {node: '>= 8.10.0'}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  commander@4.1.1:
+    resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+    engines: {node: '>= 6'}
+
+  concat-map@0.0.1:
+    resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+  convert-source-map@2.0.0:
+    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+  cookie@1.1.1:
+    resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+    engines: {node: '>=18'}
+
+  cross-spawn@7.0.6:
+    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+    engines: {node: '>= 8'}
+
+  cssesc@3.0.0:
+    resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+    engines: {node: '>=4'}
+    hasBin: true
+
+  csstype@3.2.3:
+    resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  deep-is@0.1.4:
+    resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+  didyoumean@1.2.2:
+    resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+  dlv@1.1.3:
+    resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
+  electron-to-chromium@1.5.282:
+    resolution: {integrity: sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==}
+
+  esbuild@0.25.12:
+    resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escalade@3.2.0:
+    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+    engines: {node: '>=6'}
+
+  escape-string-regexp@4.0.0:
+    resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+    engines: {node: '>=10'}
+
+  eslint-config-prettier@10.1.8:
+    resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
+    hasBin: true
+    peerDependencies:
+      eslint: '>=7.0.0'
+
+  eslint-plugin-react-hooks@5.2.0:
+    resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
+
+  eslint-plugin-react-refresh@0.4.26:
+    resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==}
+    peerDependencies:
+      eslint: '>=8.40'
+
+  eslint-scope@8.4.0:
+    resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  eslint-visitor-keys@3.4.3:
+    resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+  eslint-visitor-keys@4.2.1:
+    resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  eslint@9.39.2:
+    resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    hasBin: true
+    peerDependencies:
+      jiti: '*'
+    peerDependenciesMeta:
+      jiti:
+        optional: true
+
+  espree@10.4.0:
+    resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  esquery@1.7.0:
+    resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+    engines: {node: '>=0.10'}
+
+  esrecurse@4.3.0:
+    resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+    engines: {node: '>=4.0'}
+
+  estraverse@5.3.0:
+    resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+    engines: {node: '>=4.0'}
+
+  esutils@2.0.3:
+    resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+    engines: {node: '>=0.10.0'}
+
+  fast-deep-equal@3.1.3:
+    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+  fast-glob@3.3.3:
+    resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+    engines: {node: '>=8.6.0'}
+
+  fast-json-stable-stringify@2.1.0:
+    resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+  fast-levenshtein@2.0.6:
+    resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+  fastq@1.20.1:
+    resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+  fdir@6.5.0:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
+  file-entry-cache@8.0.0:
+    resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+    engines: {node: '>=16.0.0'}
+
+  fill-range@7.1.1:
+    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+    engines: {node: '>=8'}
+
+  find-up@5.0.0:
+    resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+    engines: {node: '>=10'}
+
+  flat-cache@4.0.1:
+    resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+    engines: {node: '>=16'}
+
+  flatted@3.3.3:
+    resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+
+  fraction.js@5.3.4:
+    resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  gensync@1.0.0-beta.2:
+    resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+    engines: {node: '>=6.9.0'}
+
+  glob-parent@5.1.2:
+    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+    engines: {node: '>= 6'}
+
+  glob-parent@6.0.2:
+    resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+    engines: {node: '>=10.13.0'}
+
+  globals@14.0.0:
+    resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+    engines: {node: '>=18'}
+
+  globals@15.15.0:
+    resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
+    engines: {node: '>=18'}
+
+  has-flag@4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+
+  hasown@2.0.2:
+    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+    engines: {node: '>= 0.4'}
+
+  ignore@5.3.2:
+    resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+    engines: {node: '>= 4'}
+
+  ignore@7.0.5:
+    resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+    engines: {node: '>= 4'}
+
+  import-fresh@3.3.1:
+    resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+    engines: {node: '>=6'}
+
+  imurmurhash@0.1.4:
+    resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+    engines: {node: '>=0.8.19'}
+
+  is-binary-path@2.1.0:
+    resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+    engines: {node: '>=8'}
+
+  is-core-module@2.16.1:
+    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
+    engines: {node: '>= 0.4'}
+
+  is-extglob@2.1.1:
+    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+    engines: {node: '>=0.10.0'}
+
+  is-glob@4.0.3:
+    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+    engines: {node: '>=0.10.0'}
+
+  is-number@7.0.0:
+    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+    engines: {node: '>=0.12.0'}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  jiti@1.21.7:
+    resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+    hasBin: true
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  js-yaml@4.1.1:
+    resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+    hasBin: true
+
+  jsesc@3.1.0:
+    resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  json-buffer@3.0.1:
+    resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+  json-schema-traverse@0.4.1:
+    resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+  json-stable-stringify-without-jsonify@1.0.1:
+    resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+  json5@2.2.3:
+    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  keyv@4.5.4:
+    resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+  levn@0.4.1:
+    resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+    engines: {node: '>= 0.8.0'}
+
+  lilconfig@3.1.3:
+    resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
+    engines: {node: '>=14'}
+
+  lines-and-columns@1.2.4:
+    resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+  locate-path@6.0.0:
+    resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+    engines: {node: '>=10'}
+
+  lodash.merge@4.6.2:
+    resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+  lru-cache@5.1.1:
+    resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+  merge2@1.4.1:
+    resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+    engines: {node: '>= 8'}
+
+  micromatch@4.0.8:
+    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+    engines: {node: '>=8.6'}
+
+  minimatch@3.1.2:
+    resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+  minimatch@9.0.5:
+    resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  mz@2.7.0:
+    resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+  nanoid@3.3.11:
+    resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  natural-compare@1.4.0:
+    resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+  node-releases@2.0.27:
+    resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+
+  normalize-path@3.0.0:
+    resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+    engines: {node: '>=0.10.0'}
+
+  object-assign@4.1.1:
+    resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+    engines: {node: '>=0.10.0'}
+
+  object-hash@3.0.0:
+    resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+    engines: {node: '>= 6'}
+
+  optionator@0.9.4:
+    resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+    engines: {node: '>= 0.8.0'}
+
+  p-limit@3.1.0:
+    resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+    engines: {node: '>=10'}
+
+  p-locate@5.0.0:
+    resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+    engines: {node: '>=10'}
+
+  parent-module@1.0.1:
+    resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+    engines: {node: '>=6'}
+
+  path-exists@4.0.0:
+    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+    engines: {node: '>=8'}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  path-parse@1.0.7:
+    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@2.3.1:
+    resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+    engines: {node: '>=8.6'}
+
+  picomatch@4.0.3:
+    resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+    engines: {node: '>=12'}
+
+  pify@2.3.0:
+    resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+    engines: {node: '>=0.10.0'}
+
+  pirates@4.0.7:
+    resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+    engines: {node: '>= 6'}
+
+  postcss-import@15.1.0:
+    resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      postcss: ^8.0.0
+
+  postcss-js@4.1.0:
+    resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+    engines: {node: ^12 || ^14 || >= 16}
+    peerDependencies:
+      postcss: ^8.4.21
+
+  postcss-load-config@6.0.1:
+    resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+    engines: {node: '>= 18'}
+    peerDependencies:
+      jiti: '>=1.21.0'
+      postcss: '>=8.0.9'
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      jiti:
+        optional: true
+      postcss:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+  postcss-nested@6.2.0:
+    resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+    engines: {node: '>=12.0'}
+    peerDependencies:
+      postcss: ^8.2.14
+
+  postcss-selector-parser@6.1.2:
+    resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
+    engines: {node: '>=4'}
+
+  postcss-value-parser@4.2.0:
+    resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+  postcss@8.5.6:
+    resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+    engines: {node: ^10 || ^12 || >=14}
+
+  prelude-ls@1.2.1:
+    resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+    engines: {node: '>= 0.8.0'}
+
+  prettier-plugin-tailwindcss@0.6.14:
+    resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==}
+    engines: {node: '>=14.21.3'}
+    peerDependencies:
+      '@ianvs/prettier-plugin-sort-imports': '*'
+      '@prettier/plugin-hermes': '*'
+      '@prettier/plugin-oxc': '*'
+      '@prettier/plugin-pug': '*'
+      '@shopify/prettier-plugin-liquid': '*'
+      '@trivago/prettier-plugin-sort-imports': '*'
+      '@zackad/prettier-plugin-twig': '*'
+      prettier: ^3.0
+      prettier-plugin-astro: '*'
+      prettier-plugin-css-order: '*'
+      prettier-plugin-import-sort: '*'
+      prettier-plugin-jsdoc: '*'
+      prettier-plugin-marko: '*'
+      prettier-plugin-multiline-arrays: '*'
+      prettier-plugin-organize-attributes: '*'
+      prettier-plugin-organize-imports: '*'
+      prettier-plugin-sort-imports: '*'
+      prettier-plugin-style-order: '*'
+      prettier-plugin-svelte: '*'
+    peerDependenciesMeta:
+      '@ianvs/prettier-plugin-sort-imports':
+        optional: true
+      '@prettier/plugin-hermes':
+        optional: true
+      '@prettier/plugin-oxc':
+        optional: true
+      '@prettier/plugin-pug':
+        optional: true
+      '@shopify/prettier-plugin-liquid':
+        optional: true
+      '@trivago/prettier-plugin-sort-imports':
+        optional: true
+      '@zackad/prettier-plugin-twig':
+        optional: true
+      prettier-plugin-astro:
+        optional: true
+      prettier-plugin-css-order:
+        optional: true
+      prettier-plugin-import-sort:
+        optional: true
+      prettier-plugin-jsdoc:
+        optional: true
+      prettier-plugin-marko:
+        optional: true
+      prettier-plugin-multiline-arrays:
+        optional: true
+      prettier-plugin-organize-attributes:
+        optional: true
+      prettier-plugin-organize-imports:
+        optional: true
+      prettier-plugin-sort-imports:
+        optional: true
+      prettier-plugin-style-order:
+        optional: true
+      prettier-plugin-svelte:
+        optional: true
+
+  prettier@3.8.1:
+    resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
+    engines: {node: '>=14'}
+    hasBin: true
+
+  punycode@2.3.1:
+    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+    engines: {node: '>=6'}
+
+  queue-microtask@1.2.3:
+    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+  react-dom@19.2.4:
+    resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+    peerDependencies:
+      react: ^19.2.4
+
+  react-refresh@0.17.0:
+    resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+    engines: {node: '>=0.10.0'}
+
+  react-router-dom@7.13.0:
+    resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: '>=18'
+      react-dom: '>=18'
+
+  react-router@7.13.0:
+    resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: '>=18'
+      react-dom: '>=18'
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+
+  react@19.2.4:
+    resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+    engines: {node: '>=0.10.0'}
+
+  read-cache@1.0.0:
+    resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+
+  readdirp@3.6.0:
+    resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+    engines: {node: '>=8.10.0'}
+
+  resolve-from@4.0.0:
+    resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+    engines: {node: '>=4'}
+
+  resolve@1.22.11:
+    resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
+    engines: {node: '>= 0.4'}
+    hasBin: true
+
+  reusify@1.1.0:
+    resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+  rollup@4.57.0:
+    resolution: {integrity: sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  run-parallel@1.2.0:
+    resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+  scheduler@0.27.0:
+    resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+  semver@6.3.1:
+    resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+    hasBin: true
+
+  semver@7.7.3:
+    resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  set-cookie-parser@2.7.2:
+    resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  source-map-js@1.2.1:
+    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+    engines: {node: '>=0.10.0'}
+
+  strip-json-comments@3.1.1:
+    resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+    engines: {node: '>=8'}
+
+  sucrase@3.35.1:
+    resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+    engines: {node: '>=16 || 14 >=14.17'}
+    hasBin: true
+
+  supports-color@7.2.0:
+    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+    engines: {node: '>=8'}
+
+  supports-preserve-symlinks-flag@1.0.0:
+    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+    engines: {node: '>= 0.4'}
+
+  tailwindcss@3.4.19:
+    resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+    engines: {node: '>=14.0.0'}
+    hasBin: true
+
+  thenify-all@1.6.0:
+    resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+    engines: {node: '>=0.8'}
+
+  thenify@3.3.1:
+    resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+  tinyglobby@0.2.15:
+    resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+    engines: {node: '>=12.0.0'}
+
+  to-regex-range@5.0.1:
+    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+    engines: {node: '>=8.0'}
+
+  ts-api-utils@2.4.0:
+    resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
+    engines: {node: '>=18.12'}
+    peerDependencies:
+      typescript: '>=4.8.4'
+
+  ts-interface-checker@0.1.13:
+    resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+  type-check@0.4.0:
+    resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+    engines: {node: '>= 0.8.0'}
+
+  typescript-eslint@8.54.0:
+    resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^8.57.0 || ^9.0.0
+      typescript: '>=4.8.4 <6.0.0'
+
+  typescript@5.7.3:
+    resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  update-browserslist-db@1.2.3:
+    resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+    hasBin: true
+    peerDependencies:
+      browserslist: '>= 4.21.0'
+
+  uri-js@4.4.1:
+    resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+  vite@6.4.1:
+    resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+      jiti: '>=1.21.0'
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.16.0
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      jiti:
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+  which@2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  word-wrap@1.2.5:
+    resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+    engines: {node: '>=0.10.0'}
+
+  yallist@3.1.1:
+    resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+  yocto-queue@0.1.0:
+    resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+    engines: {node: '>=10'}
+
+snapshots:
+
+  '@alloc/quick-lru@5.2.0': {}
+
+  '@babel/code-frame@7.28.6':
+    dependencies:
+      '@babel/helper-validator-identifier': 7.28.5
+      js-tokens: 4.0.0
+      picocolors: 1.1.1
+
+  '@babel/compat-data@7.28.6': {}
+
+  '@babel/core@7.28.6':
+    dependencies:
+      '@babel/code-frame': 7.28.6
+      '@babel/generator': 7.28.6
+      '@babel/helper-compilation-targets': 7.28.6
+      '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6)
+      '@babel/helpers': 7.28.6
+      '@babel/parser': 7.28.6
+      '@babel/template': 7.28.6
+      '@babel/traverse': 7.28.6
+      '@babel/types': 7.28.6
+      '@jridgewell/remapping': 2.3.5
+      convert-source-map: 2.0.0
+      debug: 4.4.3
+      gensync: 1.0.0-beta.2
+      json5: 2.2.3
+      semver: 6.3.1
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/generator@7.28.6':
+    dependencies:
+      '@babel/parser': 7.28.6
+      '@babel/types': 7.28.6
+      '@jridgewell/gen-mapping': 0.3.13
+      '@jridgewell/trace-mapping': 0.3.31
+      jsesc: 3.1.0
+
+  '@babel/helper-compilation-targets@7.28.6':
+    dependencies:
+      '@babel/compat-data': 7.28.6
+      '@babel/helper-validator-option': 7.27.1
+      browserslist: 4.28.1
+      lru-cache: 5.1.1
+      semver: 6.3.1
+
+  '@babel/helper-globals@7.28.0': {}
+
+  '@babel/helper-module-imports@7.28.6':
+    dependencies:
+      '@babel/traverse': 7.28.6
+      '@babel/types': 7.28.6
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)':
+    dependencies:
+      '@babel/core': 7.28.6
+      '@babel/helper-module-imports': 7.28.6
+      '@babel/helper-validator-identifier': 7.28.5
+      '@babel/traverse': 7.28.6
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-plugin-utils@7.28.6': {}
+
+  '@babel/helper-string-parser@7.27.1': {}
+
+  '@babel/helper-validator-identifier@7.28.5': {}
+
+  '@babel/helper-validator-option@7.27.1': {}
+
+  '@babel/helpers@7.28.6':
+    dependencies:
+      '@babel/template': 7.28.6
+      '@babel/types': 7.28.6
+
+  '@babel/parser@7.28.6':
+    dependencies:
+      '@babel/types': 7.28.6
+
+  '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)':
+    dependencies:
+      '@babel/core': 7.28.6
+      '@babel/helper-plugin-utils': 7.28.6
+
+  '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)':
+    dependencies:
+      '@babel/core': 7.28.6
+      '@babel/helper-plugin-utils': 7.28.6
+
+  '@babel/template@7.28.6':
+    dependencies:
+      '@babel/code-frame': 7.28.6
+      '@babel/parser': 7.28.6
+      '@babel/types': 7.28.6
+
+  '@babel/traverse@7.28.6':
+    dependencies:
+      '@babel/code-frame': 7.28.6
+      '@babel/generator': 7.28.6
+      '@babel/helper-globals': 7.28.0
+      '@babel/parser': 7.28.6
+      '@babel/template': 7.28.6
+      '@babel/types': 7.28.6
+      debug: 4.4.3
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/types@7.28.6':
+    dependencies:
+      '@babel/helper-string-parser': 7.27.1
+      '@babel/helper-validator-identifier': 7.28.5
+
+  '@esbuild/aix-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm@0.25.12':
+    optional: true
+
+  '@esbuild/android-x64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.12':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.12':
+    optional: true
+
+  '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))':
+    dependencies:
+      eslint: 9.39.2(jiti@1.21.7)
+      eslint-visitor-keys: 3.4.3
+
+  '@eslint-community/regexpp@4.12.2': {}
+
+  '@eslint/config-array@0.21.1':
+    dependencies:
+      '@eslint/object-schema': 2.1.7
+      debug: 4.4.3
+      minimatch: 3.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@eslint/config-helpers@0.4.2':
+    dependencies:
+      '@eslint/core': 0.17.0
+
+  '@eslint/core@0.17.0':
+    dependencies:
+      '@types/json-schema': 7.0.15
+
+  '@eslint/eslintrc@3.3.3':
+    dependencies:
+      ajv: 6.12.6
+      debug: 4.4.3
+      espree: 10.4.0
+      globals: 14.0.0
+      ignore: 5.3.2
+      import-fresh: 3.3.1
+      js-yaml: 4.1.1
+      minimatch: 3.1.2
+      strip-json-comments: 3.1.1
+    transitivePeerDependencies:
+      - supports-color
+
+  '@eslint/js@9.39.2': {}
+
+  '@eslint/object-schema@2.1.7': {}
+
+  '@eslint/plugin-kit@0.4.1':
+    dependencies:
+      '@eslint/core': 0.17.0
+      levn: 0.4.1
+
+  '@humanfs/core@0.19.1': {}
+
+  '@humanfs/node@0.16.7':
+    dependencies:
+      '@humanfs/core': 0.19.1
+      '@humanwhocodes/retry': 0.4.3
+
+  '@humanwhocodes/module-importer@1.0.1': {}
+
+  '@humanwhocodes/retry@0.4.3': {}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.5
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/remapping@2.3.5':
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.13
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/sourcemap-codec@1.5.5': {}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.5.5
+
+  '@nodelib/fs.scandir@2.1.5':
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      run-parallel: 1.2.0
+
+  '@nodelib/fs.stat@2.0.5': {}
+
+  '@nodelib/fs.walk@1.2.8':
+    dependencies:
+      '@nodelib/fs.scandir': 2.1.5
+      fastq: 1.20.1
+
+  '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+  '@rollup/rollup-android-arm-eabi@4.57.0':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.57.0':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.57.0':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.57.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.57.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-gnu@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-musl@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-musl@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.57.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.57.0':
+    optional: true
+
+  '@rollup/rollup-openbsd-x64@4.57.0':
+    optional: true
+
+  '@rollup/rollup-openharmony-arm64@4.57.0':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.57.0':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.57.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-gnu@4.57.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.57.0':
+    optional: true
+
+  '@types/babel__core@7.20.5':
+    dependencies:
+      '@babel/parser': 7.28.6
+      '@babel/types': 7.28.6
+      '@types/babel__generator': 7.27.0
+      '@types/babel__template': 7.4.4
+      '@types/babel__traverse': 7.28.0
+
+  '@types/babel__generator@7.27.0':
+    dependencies:
+      '@babel/types': 7.28.6
+
+  '@types/babel__template@7.4.4':
+    dependencies:
+      '@babel/parser': 7.28.6
+      '@babel/types': 7.28.6
+
+  '@types/babel__traverse@7.28.0':
+    dependencies:
+      '@babel/types': 7.28.6
+
+  '@types/estree@1.0.8': {}
+
+  '@types/json-schema@7.0.15': {}
+
+  '@types/react-dom@19.2.3(@types/react@19.2.10)':
+    dependencies:
+      '@types/react': 19.2.10
+
+  '@types/react@19.2.10':
+    dependencies:
+      csstype: 3.2.3
+
+  '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)':
+    dependencies:
+      '@eslint-community/regexpp': 4.12.2
+      '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      '@typescript-eslint/scope-manager': 8.54.0
+      '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      '@typescript-eslint/visitor-keys': 8.54.0
+      eslint: 9.39.2(jiti@1.21.7)
+      ignore: 7.0.5
+      natural-compare: 1.4.0
+      ts-api-utils: 2.4.0(typescript@5.7.3)
+      typescript: 5.7.3
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)':
+    dependencies:
+      '@typescript-eslint/scope-manager': 8.54.0
+      '@typescript-eslint/types': 8.54.0
+      '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.7.3)
+      '@typescript-eslint/visitor-keys': 8.54.0
+      debug: 4.4.3
+      eslint: 9.39.2(jiti@1.21.7)
+      typescript: 5.7.3
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/project-service@8.54.0(typescript@5.7.3)':
+    dependencies:
+      '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.7.3)
+      '@typescript-eslint/types': 8.54.0
+      debug: 4.4.3
+      typescript: 5.7.3
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/scope-manager@8.54.0':
+    dependencies:
+      '@typescript-eslint/types': 8.54.0
+      '@typescript-eslint/visitor-keys': 8.54.0
+
+  '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.7.3)':
+    dependencies:
+      typescript: 5.7.3
+
+  '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)':
+    dependencies:
+      '@typescript-eslint/types': 8.54.0
+      '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.7.3)
+      '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      debug: 4.4.3
+      eslint: 9.39.2(jiti@1.21.7)
+      ts-api-utils: 2.4.0(typescript@5.7.3)
+      typescript: 5.7.3
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/types@8.54.0': {}
+
+  '@typescript-eslint/typescript-estree@8.54.0(typescript@5.7.3)':
+    dependencies:
+      '@typescript-eslint/project-service': 8.54.0(typescript@5.7.3)
+      '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.7.3)
+      '@typescript-eslint/types': 8.54.0
+      '@typescript-eslint/visitor-keys': 8.54.0
+      debug: 4.4.3
+      minimatch: 9.0.5
+      semver: 7.7.3
+      tinyglobby: 0.2.15
+      ts-api-utils: 2.4.0(typescript@5.7.3)
+      typescript: 5.7.3
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)':
+    dependencies:
+      '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
+      '@typescript-eslint/scope-manager': 8.54.0
+      '@typescript-eslint/types': 8.54.0
+      '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.7.3)
+      eslint: 9.39.2(jiti@1.21.7)
+      typescript: 5.7.3
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/visitor-keys@8.54.0':
+    dependencies:
+      '@typescript-eslint/types': 8.54.0
+      eslint-visitor-keys: 4.2.1
+
+  '@vitejs/plugin-react@4.7.0(vite@6.4.1(jiti@1.21.7))':
+    dependencies:
+      '@babel/core': 7.28.6
+      '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
+      '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6)
+      '@rolldown/pluginutils': 1.0.0-beta.27
+      '@types/babel__core': 7.20.5
+      react-refresh: 0.17.0
+      vite: 6.4.1(jiti@1.21.7)
+    transitivePeerDependencies:
+      - supports-color
+
+  acorn-jsx@5.3.2(acorn@8.15.0):
+    dependencies:
+      acorn: 8.15.0
+
+  acorn@8.15.0: {}
+
+  ajv@6.12.6:
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-json-stable-stringify: 2.1.0
+      json-schema-traverse: 0.4.1
+      uri-js: 4.4.1
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  any-promise@1.3.0: {}
+
+  anymatch@3.1.3:
+    dependencies:
+      normalize-path: 3.0.0
+      picomatch: 2.3.1
+
+  arg@5.0.2: {}
+
+  argparse@2.0.1: {}
+
+  autoprefixer@10.4.23(postcss@8.5.6):
+    dependencies:
+      browserslist: 4.28.1
+      caniuse-lite: 1.0.30001766
+      fraction.js: 5.3.4
+      picocolors: 1.1.1
+      postcss: 8.5.6
+      postcss-value-parser: 4.2.0
+
+  balanced-match@1.0.2: {}
+
+  baseline-browser-mapping@2.9.19: {}
+
+  binary-extensions@2.3.0: {}
+
+  brace-expansion@1.1.12:
+    dependencies:
+      balanced-match: 1.0.2
+      concat-map: 0.0.1
+
+  brace-expansion@2.0.2:
+    dependencies:
+      balanced-match: 1.0.2
+
+  braces@3.0.3:
+    dependencies:
+      fill-range: 7.1.1
+
+  browserslist@4.28.1:
+    dependencies:
+      baseline-browser-mapping: 2.9.19
+      caniuse-lite: 1.0.30001766
+      electron-to-chromium: 1.5.282
+      node-releases: 2.0.27
+      update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
+  callsites@3.1.0: {}
+
+  camelcase-css@2.0.1: {}
+
+  caniuse-lite@1.0.30001766: {}
+
+  chalk@4.1.2:
+    dependencies:
+      ansi-styles: 4.3.0
+      supports-color: 7.2.0
+
+  chokidar@3.6.0:
+    dependencies:
+      anymatch: 3.1.3
+      braces: 3.0.3
+      glob-parent: 5.1.2
+      is-binary-path: 2.1.0
+      is-glob: 4.0.3
+      normalize-path: 3.0.0
+      readdirp: 3.6.0
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  commander@4.1.1: {}
+
+  concat-map@0.0.1: {}
+
+  convert-source-map@2.0.0: {}
+
+  cookie@1.1.1: {}
+
+  cross-spawn@7.0.6:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+
+  cssesc@3.0.0: {}
+
+  csstype@3.2.3: {}
+
+  debug@4.4.3:
+    dependencies:
+      ms: 2.1.3
+
+  deep-is@0.1.4: {}
+
+  didyoumean@1.2.2: {}
+
+  dlv@1.1.3: {}
+
+  electron-to-chromium@1.5.282: {}
+
+  esbuild@0.25.12:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.12
+      '@esbuild/android-arm': 0.25.12
+      '@esbuild/android-arm64': 0.25.12
+      '@esbuild/android-x64': 0.25.12
+      '@esbuild/darwin-arm64': 0.25.12
+      '@esbuild/darwin-x64': 0.25.12
+      '@esbuild/freebsd-arm64': 0.25.12
+      '@esbuild/freebsd-x64': 0.25.12
+      '@esbuild/linux-arm': 0.25.12
+      '@esbuild/linux-arm64': 0.25.12
+      '@esbuild/linux-ia32': 0.25.12
+      '@esbuild/linux-loong64': 0.25.12
+      '@esbuild/linux-mips64el': 0.25.12
+      '@esbuild/linux-ppc64': 0.25.12
+      '@esbuild/linux-riscv64': 0.25.12
+      '@esbuild/linux-s390x': 0.25.12
+      '@esbuild/linux-x64': 0.25.12
+      '@esbuild/netbsd-arm64': 0.25.12
+      '@esbuild/netbsd-x64': 0.25.12
+      '@esbuild/openbsd-arm64': 0.25.12
+      '@esbuild/openbsd-x64': 0.25.12
+      '@esbuild/openharmony-arm64': 0.25.12
+      '@esbuild/sunos-x64': 0.25.12
+      '@esbuild/win32-arm64': 0.25.12
+      '@esbuild/win32-ia32': 0.25.12
+      '@esbuild/win32-x64': 0.25.12
+
+  escalade@3.2.0: {}
+
+  escape-string-regexp@4.0.0: {}
+
+  eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)):
+    dependencies:
+      eslint: 9.39.2(jiti@1.21.7)
+
+  eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@1.21.7)):
+    dependencies:
+      eslint: 9.39.2(jiti@1.21.7)
+
+  eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)):
+    dependencies:
+      eslint: 9.39.2(jiti@1.21.7)
+
+  eslint-scope@8.4.0:
+    dependencies:
+      esrecurse: 4.3.0
+      estraverse: 5.3.0
+
+  eslint-visitor-keys@3.4.3: {}
+
+  eslint-visitor-keys@4.2.1: {}
+
+  eslint@9.39.2(jiti@1.21.7):
+    dependencies:
+      '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
+      '@eslint-community/regexpp': 4.12.2
+      '@eslint/config-array': 0.21.1
+      '@eslint/config-helpers': 0.4.2
+      '@eslint/core': 0.17.0
+      '@eslint/eslintrc': 3.3.3
+      '@eslint/js': 9.39.2
+      '@eslint/plugin-kit': 0.4.1
+      '@humanfs/node': 0.16.7
+      '@humanwhocodes/module-importer': 1.0.1
+      '@humanwhocodes/retry': 0.4.3
+      '@types/estree': 1.0.8
+      ajv: 6.12.6
+      chalk: 4.1.2
+      cross-spawn: 7.0.6
+      debug: 4.4.3
+      escape-string-regexp: 4.0.0
+      eslint-scope: 8.4.0
+      eslint-visitor-keys: 4.2.1
+      espree: 10.4.0
+      esquery: 1.7.0
+      esutils: 2.0.3
+      fast-deep-equal: 3.1.3
+      file-entry-cache: 8.0.0
+      find-up: 5.0.0
+      glob-parent: 6.0.2
+      ignore: 5.3.2
+      imurmurhash: 0.1.4
+      is-glob: 4.0.3
+      json-stable-stringify-without-jsonify: 1.0.1
+      lodash.merge: 4.6.2
+      minimatch: 3.1.2
+      natural-compare: 1.4.0
+      optionator: 0.9.4
+    optionalDependencies:
+      jiti: 1.21.7
+    transitivePeerDependencies:
+      - supports-color
+
+  espree@10.4.0:
+    dependencies:
+      acorn: 8.15.0
+      acorn-jsx: 5.3.2(acorn@8.15.0)
+      eslint-visitor-keys: 4.2.1
+
+  esquery@1.7.0:
+    dependencies:
+      estraverse: 5.3.0
+
+  esrecurse@4.3.0:
+    dependencies:
+      estraverse: 5.3.0
+
+  estraverse@5.3.0: {}
+
+  esutils@2.0.3: {}
+
+  fast-deep-equal@3.1.3: {}
+
+  fast-glob@3.3.3:
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      '@nodelib/fs.walk': 1.2.8
+      glob-parent: 5.1.2
+      merge2: 1.4.1
+      micromatch: 4.0.8
+
+  fast-json-stable-stringify@2.1.0: {}
+
+  fast-levenshtein@2.0.6: {}
+
+  fastq@1.20.1:
+    dependencies:
+      reusify: 1.1.0
+
+  fdir@6.5.0(picomatch@4.0.3):
+    optionalDependencies:
+      picomatch: 4.0.3
+
+  file-entry-cache@8.0.0:
+    dependencies:
+      flat-cache: 4.0.1
+
+  fill-range@7.1.1:
+    dependencies:
+      to-regex-range: 5.0.1
+
+  find-up@5.0.0:
+    dependencies:
+      locate-path: 6.0.0
+      path-exists: 4.0.0
+
+  flat-cache@4.0.1:
+    dependencies:
+      flatted: 3.3.3
+      keyv: 4.5.4
+
+  flatted@3.3.3: {}
+
+  fraction.js@5.3.4: {}
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  gensync@1.0.0-beta.2: {}
+
+  glob-parent@5.1.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob-parent@6.0.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  globals@14.0.0: {}
+
+  globals@15.15.0: {}
+
+  has-flag@4.0.0: {}
+
+  hasown@2.0.2:
+    dependencies:
+      function-bind: 1.1.2
+
+  ignore@5.3.2: {}
+
+  ignore@7.0.5: {}
+
+  import-fresh@3.3.1:
+    dependencies:
+      parent-module: 1.0.1
+      resolve-from: 4.0.0
+
+  imurmurhash@0.1.4: {}
+
+  is-binary-path@2.1.0:
+    dependencies:
+      binary-extensions: 2.3.0
+
+  is-core-module@2.16.1:
+    dependencies:
+      hasown: 2.0.2
+
+  is-extglob@2.1.1: {}
+
+  is-glob@4.0.3:
+    dependencies:
+      is-extglob: 2.1.1
+
+  is-number@7.0.0: {}
+
+  isexe@2.0.0: {}
+
+  jiti@1.21.7: {}
+
+  js-tokens@4.0.0: {}
+
+  js-yaml@4.1.1:
+    dependencies:
+      argparse: 2.0.1
+
+  jsesc@3.1.0: {}
+
+  json-buffer@3.0.1: {}
+
+  json-schema-traverse@0.4.1: {}
+
+  json-stable-stringify-without-jsonify@1.0.1: {}
+
+  json5@2.2.3: {}
+
+  keyv@4.5.4:
+    dependencies:
+      json-buffer: 3.0.1
+
+  levn@0.4.1:
+    dependencies:
+      prelude-ls: 1.2.1
+      type-check: 0.4.0
+
+  lilconfig@3.1.3: {}
+
+  lines-and-columns@1.2.4: {}
+
+  locate-path@6.0.0:
+    dependencies:
+      p-locate: 5.0.0
+
+  lodash.merge@4.6.2: {}
+
+  lru-cache@5.1.1:
+    dependencies:
+      yallist: 3.1.1
+
+  merge2@1.4.1: {}
+
+  micromatch@4.0.8:
+    dependencies:
+      braces: 3.0.3
+      picomatch: 2.3.1
+
+  minimatch@3.1.2:
+    dependencies:
+      brace-expansion: 1.1.12
+
+  minimatch@9.0.5:
+    dependencies:
+      brace-expansion: 2.0.2
+
+  ms@2.1.3: {}
+
+  mz@2.7.0:
+    dependencies:
+      any-promise: 1.3.0
+      object-assign: 4.1.1
+      thenify-all: 1.6.0
+
+  nanoid@3.3.11: {}
+
+  natural-compare@1.4.0: {}
+
+  node-releases@2.0.27: {}
+
+  normalize-path@3.0.0: {}
+
+  object-assign@4.1.1: {}
+
+  object-hash@3.0.0: {}
+
+  optionator@0.9.4:
+    dependencies:
+      deep-is: 0.1.4
+      fast-levenshtein: 2.0.6
+      levn: 0.4.1
+      prelude-ls: 1.2.1
+      type-check: 0.4.0
+      word-wrap: 1.2.5
+
+  p-limit@3.1.0:
+    dependencies:
+      yocto-queue: 0.1.0
+
+  p-locate@5.0.0:
+    dependencies:
+      p-limit: 3.1.0
+
+  parent-module@1.0.1:
+    dependencies:
+      callsites: 3.1.0
+
+  path-exists@4.0.0: {}
+
+  path-key@3.1.1: {}
+
+  path-parse@1.0.7: {}
+
+  picocolors@1.1.1: {}
+
+  picomatch@2.3.1: {}
+
+  picomatch@4.0.3: {}
+
+  pify@2.3.0: {}
+
+  pirates@4.0.7: {}
+
+  postcss-import@15.1.0(postcss@8.5.6):
+    dependencies:
+      postcss: 8.5.6
+      postcss-value-parser: 4.2.0
+      read-cache: 1.0.0
+      resolve: 1.22.11
+
+  postcss-js@4.1.0(postcss@8.5.6):
+    dependencies:
+      camelcase-css: 2.0.1
+      postcss: 8.5.6
+
+  postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6):
+    dependencies:
+      lilconfig: 3.1.3
+    optionalDependencies:
+      jiti: 1.21.7
+      postcss: 8.5.6
+
+  postcss-nested@6.2.0(postcss@8.5.6):
+    dependencies:
+      postcss: 8.5.6
+      postcss-selector-parser: 6.1.2
+
+  postcss-selector-parser@6.1.2:
+    dependencies:
+      cssesc: 3.0.0
+      util-deprecate: 1.0.2
+
+  postcss-value-parser@4.2.0: {}
+
+  postcss@8.5.6:
+    dependencies:
+      nanoid: 3.3.11
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
+  prelude-ls@1.2.1: {}
+
+  prettier-plugin-tailwindcss@0.6.14(prettier@3.8.1):
+    dependencies:
+      prettier: 3.8.1
+
+  prettier@3.8.1: {}
+
+  punycode@2.3.1: {}
+
+  queue-microtask@1.2.3: {}
+
+  react-dom@19.2.4(react@19.2.4):
+    dependencies:
+      react: 19.2.4
+      scheduler: 0.27.0
+
+  react-refresh@0.17.0: {}
+
+  react-router-dom@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+    dependencies:
+      react: 19.2.4
+      react-dom: 19.2.4(react@19.2.4)
+      react-router: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+
+  react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+    dependencies:
+      cookie: 1.1.1
+      react: 19.2.4
+      set-cookie-parser: 2.7.2
+    optionalDependencies:
+      react-dom: 19.2.4(react@19.2.4)
+
+  react@19.2.4: {}
+
+  read-cache@1.0.0:
+    dependencies:
+      pify: 2.3.0
+
+  readdirp@3.6.0:
+    dependencies:
+      picomatch: 2.3.1
+
+  resolve-from@4.0.0: {}
+
+  resolve@1.22.11:
+    dependencies:
+      is-core-module: 2.16.1
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+
+  reusify@1.1.0: {}
+
+  rollup@4.57.0:
+    dependencies:
+      '@types/estree': 1.0.8
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.57.0
+      '@rollup/rollup-android-arm64': 4.57.0
+      '@rollup/rollup-darwin-arm64': 4.57.0
+      '@rollup/rollup-darwin-x64': 4.57.0
+      '@rollup/rollup-freebsd-arm64': 4.57.0
+      '@rollup/rollup-freebsd-x64': 4.57.0
+      '@rollup/rollup-linux-arm-gnueabihf': 4.57.0
+      '@rollup/rollup-linux-arm-musleabihf': 4.57.0
+      '@rollup/rollup-linux-arm64-gnu': 4.57.0
+      '@rollup/rollup-linux-arm64-musl': 4.57.0
+      '@rollup/rollup-linux-loong64-gnu': 4.57.0
+      '@rollup/rollup-linux-loong64-musl': 4.57.0
+      '@rollup/rollup-linux-ppc64-gnu': 4.57.0
+      '@rollup/rollup-linux-ppc64-musl': 4.57.0
+      '@rollup/rollup-linux-riscv64-gnu': 4.57.0
+      '@rollup/rollup-linux-riscv64-musl': 4.57.0
+      '@rollup/rollup-linux-s390x-gnu': 4.57.0
+      '@rollup/rollup-linux-x64-gnu': 4.57.0
+      '@rollup/rollup-linux-x64-musl': 4.57.0
+      '@rollup/rollup-openbsd-x64': 4.57.0
+      '@rollup/rollup-openharmony-arm64': 4.57.0
+      '@rollup/rollup-win32-arm64-msvc': 4.57.0
+      '@rollup/rollup-win32-ia32-msvc': 4.57.0
+      '@rollup/rollup-win32-x64-gnu': 4.57.0
+      '@rollup/rollup-win32-x64-msvc': 4.57.0
+      fsevents: 2.3.3
+
+  run-parallel@1.2.0:
+    dependencies:
+      queue-microtask: 1.2.3
+
+  scheduler@0.27.0: {}
+
+  semver@6.3.1: {}
+
+  semver@7.7.3: {}
+
+  set-cookie-parser@2.7.2: {}
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@3.0.0: {}
+
+  source-map-js@1.2.1: {}
+
+  strip-json-comments@3.1.1: {}
+
+  sucrase@3.35.1:
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.13
+      commander: 4.1.1
+      lines-and-columns: 1.2.4
+      mz: 2.7.0
+      pirates: 4.0.7
+      tinyglobby: 0.2.15
+      ts-interface-checker: 0.1.13
+
+  supports-color@7.2.0:
+    dependencies:
+      has-flag: 4.0.0
+
+  supports-preserve-symlinks-flag@1.0.0: {}
+
+  tailwindcss@3.4.19:
+    dependencies:
+      '@alloc/quick-lru': 5.2.0
+      arg: 5.0.2
+      chokidar: 3.6.0
+      didyoumean: 1.2.2
+      dlv: 1.1.3
+      fast-glob: 3.3.3
+      glob-parent: 6.0.2
+      is-glob: 4.0.3
+      jiti: 1.21.7
+      lilconfig: 3.1.3
+      micromatch: 4.0.8
+      normalize-path: 3.0.0
+      object-hash: 3.0.0
+      picocolors: 1.1.1
+      postcss: 8.5.6
+      postcss-import: 15.1.0(postcss@8.5.6)
+      postcss-js: 4.1.0(postcss@8.5.6)
+      postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)
+      postcss-nested: 6.2.0(postcss@8.5.6)
+      postcss-selector-parser: 6.1.2
+      resolve: 1.22.11
+      sucrase: 3.35.1
+    transitivePeerDependencies:
+      - tsx
+      - yaml
+
+  thenify-all@1.6.0:
+    dependencies:
+      thenify: 3.3.1
+
+  thenify@3.3.1:
+    dependencies:
+      any-promise: 1.3.0
+
+  tinyglobby@0.2.15:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.3)
+      picomatch: 4.0.3
+
+  to-regex-range@5.0.1:
+    dependencies:
+      is-number: 7.0.0
+
+  ts-api-utils@2.4.0(typescript@5.7.3):
+    dependencies:
+      typescript: 5.7.3
+
+  ts-interface-checker@0.1.13: {}
+
+  type-check@0.4.0:
+    dependencies:
+      prelude-ls: 1.2.1
+
+  typescript-eslint@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3):
+    dependencies:
+      '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.7.3)
+      '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.7.3)
+      eslint: 9.39.2(jiti@1.21.7)
+      typescript: 5.7.3
+    transitivePeerDependencies:
+      - supports-color
+
+  typescript@5.7.3: {}
+
+  update-browserslist-db@1.2.3(browserslist@4.28.1):
+    dependencies:
+      browserslist: 4.28.1
+      escalade: 3.2.0
+      picocolors: 1.1.1
+
+  uri-js@4.4.1:
+    dependencies:
+      punycode: 2.3.1
+
+  util-deprecate@1.0.2: {}
+
+  vite@6.4.1(jiti@1.21.7):
+    dependencies:
+      esbuild: 0.25.12
+      fdir: 6.5.0(picomatch@4.0.3)
+      picomatch: 4.0.3
+      postcss: 8.5.6
+      rollup: 4.57.0
+      tinyglobby: 0.2.15
+    optionalDependencies:
+      fsevents: 2.3.3
+      jiti: 1.21.7
+
+  which@2.0.2:
+    dependencies:
+      isexe: 2.0.0
+
+  word-wrap@1.2.5: {}
+
+  yallist@3.1.1: {}
+
+  yocto-queue@0.1.0: {}

+ 6 - 0
webui/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}

+ 10 - 0
webui/public/favicon.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+  <defs>
+    <linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
+    </linearGradient>
+  </defs>
+  <rect width="100" height="100" rx="20" fill="url(#grad)"/>
+  <text x="50" y="68" font-family="Arial, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">S</text>
+</svg>

+ 74 - 0
webui/src/App.tsx

@@ -0,0 +1,74 @@
+import { Routes, Route, Navigate } from 'react-router-dom'
+import { useAuth } from '@/contexts/AuthContext'
+import Login from '@/pages/Login'
+import Dashboard from '@/pages/Dashboard'
+import Collections from '@/pages/Collections'
+import Documents from '@/pages/Documents'
+import Views from '@/pages/Views'
+import Users from '@/pages/Users'
+import Groups from '@/pages/Groups'
+import ApiKeys from '@/pages/ApiKeys'
+import Workspaces from '@/pages/Workspaces'
+import ProtectedRoute from '@/components/ProtectedRoute'
+import { DashboardLayout } from '@/components/layout'
+
+function App() {
+  const { isAuthenticated } = useAuth()
+
+  return (
+    <Routes>
+      {/* Public routes */}
+      <Route
+        path="/"
+        element={
+          isAuthenticated ? (
+            <Navigate to="/dashboard" replace />
+          ) : (
+            <div className="flex min-h-screen items-center justify-center bg-gray-50">
+              <div className="text-center">
+                <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-xl bg-primary-600">
+                  <span className="text-3xl font-bold text-white">S</span>
+                </div>
+                <h1 className="text-4xl font-bold text-gray-900">SmartBotic CRM</h1>
+                <p className="mt-4 text-lg text-gray-600">Manage your business smarter</p>
+                <div className="mt-8 flex justify-center gap-4">
+                  <a
+                    href="/login"
+                    className="rounded-lg bg-primary-600 px-6 py-3 text-white transition hover:bg-primary-700"
+                  >
+                    Sign in
+                  </a>
+                </div>
+              </div>
+            </div>
+          )
+        }
+      />
+      <Route path="/login" element={<Login />} />
+
+      {/* Protected routes with dashboard layout */}
+      <Route
+        element={
+          <ProtectedRoute>
+            <DashboardLayout />
+          </ProtectedRoute>
+        }
+      >
+        <Route path="/dashboard" element={<Dashboard />} />
+        <Route path="/collections" element={<Collections />} />
+        <Route path="/collections/:collectionName" element={<Documents />} />
+        <Route path="/views" element={<Views />} />
+        <Route path="/views/:collectionName" element={<Views />} />
+        <Route path="/users" element={<Users />} />
+        <Route path="/groups" element={<Groups />} />
+        <Route path="/api-keys" element={<ApiKeys />} />
+        <Route path="/workspaces" element={<Workspaces />} />
+      </Route>
+
+      {/* Catch-all redirect */}
+      <Route path="*" element={<Navigate to="/" replace />} />
+    </Routes>
+  )
+}
+
+export default App

+ 32 - 0
webui/src/api/auth.ts

@@ -0,0 +1,32 @@
+// Authentication API functions
+
+import apiClient from './client'
+import type { AuthTokens } from '@/types'
+
+export interface LoginRequest {
+  email: string
+  password: string
+}
+
+export interface RefreshRequest {
+  refresh_token: string
+}
+
+export async function login(credentials: LoginRequest): Promise<AuthTokens> {
+  const tokens = await apiClient.post<AuthTokens>('/auth/login', credentials)
+  apiClient.setAccessToken(tokens.access_token)
+  return tokens
+}
+
+export async function refreshToken(refreshToken: string): Promise<AuthTokens> {
+  const tokens = await apiClient.post<AuthTokens>('/auth/refresh', {
+    refresh_token: refreshToken,
+  })
+  apiClient.setAccessToken(tokens.access_token)
+  return tokens
+}
+
+export async function logout(refreshToken: string): Promise<void> {
+  await apiClient.post('/auth/logout', { refresh_token: refreshToken })
+  apiClient.setAccessToken(null)
+}

+ 67 - 0
webui/src/api/client.ts

@@ -0,0 +1,67 @@
+// API client for SmartBotic CRM backend
+
+const API_BASE = '/api'
+
+interface RequestOptions {
+  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+  body?: unknown
+  headers?: Record<string, string>
+}
+
+class ApiClient {
+  private accessToken: string | null = null
+
+  setAccessToken(token: string | null) {
+    this.accessToken = token
+  }
+
+  getAccessToken(): string | null {
+    return this.accessToken
+  }
+
+  async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
+    const { method = 'GET', body, headers = {} } = options
+
+    const requestHeaders: Record<string, string> = {
+      'Content-Type': 'application/json',
+      ...headers,
+    }
+
+    if (this.accessToken) {
+      requestHeaders['Authorization'] = `Bearer ${this.accessToken}`
+    }
+
+    const response = await fetch(`${API_BASE}${endpoint}`, {
+      method,
+      headers: requestHeaders,
+      body: body ? JSON.stringify(body) : undefined,
+    })
+
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
+      throw new Error(errorData.error || `HTTP ${response.status}`)
+    }
+
+    return response.json()
+  }
+
+  // Convenience methods
+  get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
+    return this.request<T>(endpoint, { method: 'GET', headers })
+  }
+
+  post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
+    return this.request<T>(endpoint, { method: 'POST', body, headers })
+  }
+
+  patch<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
+    return this.request<T>(endpoint, { method: 'PATCH', body, headers })
+  }
+
+  delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
+    return this.request<T>(endpoint, { method: 'DELETE', headers })
+  }
+}
+
+export const apiClient = new ApiClient()
+export default apiClient

+ 65 - 0
webui/src/components/Button.tsx

@@ -0,0 +1,65 @@
+// Reusable Button component
+
+import { ButtonHTMLAttributes, forwardRef } from 'react'
+
+interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
+  size?: 'sm' | 'md' | 'lg'
+  isLoading?: boolean
+}
+
+const Button = forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className = '', variant = 'primary', size = 'md', isLoading, children, ...props }, ref) => {
+    const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition'
+
+    const variantClasses = {
+      primary: 'bg-primary-600 text-white hover:bg-primary-700 disabled:bg-primary-300',
+      secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 disabled:bg-gray-50',
+      danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-red-300',
+      ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 disabled:text-gray-300',
+    }
+
+    const sizeClasses = {
+      sm: 'px-3 py-1.5 text-sm',
+      md: 'px-4 py-2 text-base',
+      lg: 'px-6 py-3 text-lg',
+    }
+
+    return (
+      <button
+        ref={ref}
+        className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
+        disabled={isLoading || props.disabled}
+        {...props}
+      >
+        {isLoading ? (
+          <svg
+            className="-ml-1 mr-2 h-4 w-4 animate-spin"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        ) : null}
+        {children}
+      </button>
+    )
+  }
+)
+
+Button.displayName = 'Button'
+
+export default Button

+ 311 - 0
webui/src/components/FieldPermissionEditor.tsx

@@ -0,0 +1,311 @@
+import { useState, useCallback } from 'react'
+import type { FieldPermission, Group } from '../types'
+
+interface FieldPermissionEditorProps {
+  fieldPermissions: FieldPermission[]
+  onChange: (fieldPermissions: FieldPermission[]) => void
+  availableGroups: Group[]
+  fields?: string[]
+  disabled?: boolean
+}
+
+interface EditingState {
+  isEditing: boolean
+  index: number | null
+  fieldName: string
+  readGroups: string[]
+  writeGroups: string[]
+}
+
+export function FieldPermissionEditor({
+  fieldPermissions,
+  onChange,
+  availableGroups,
+  fields = [],
+  disabled = false
+}: FieldPermissionEditorProps) {
+  const [editing, setEditing] = useState<EditingState>({
+    isEditing: false,
+    index: null,
+    fieldName: '',
+    readGroups: [],
+    writeGroups: []
+  })
+
+  const startAdding = useCallback(() => {
+    setEditing({
+      isEditing: true,
+      index: null,
+      fieldName: fields[0] || '',
+      readGroups: [],
+      writeGroups: []
+    })
+  }, [fields])
+
+  const startEditing = useCallback((index: number) => {
+    const fp = fieldPermissions[index]
+    setEditing({
+      isEditing: true,
+      index,
+      fieldName: fp.field_name,
+      readGroups: [...fp.read_groups],
+      writeGroups: [...fp.write_groups]
+    })
+  }, [fieldPermissions])
+
+  const cancelEditing = useCallback(() => {
+    setEditing({
+      isEditing: false,
+      index: null,
+      fieldName: '',
+      readGroups: [],
+      writeGroups: []
+    })
+  }, [])
+
+  const saveEditing = useCallback(() => {
+    const newPermission: FieldPermission = {
+      field_name: editing.fieldName,
+      read_groups: editing.readGroups,
+      write_groups: editing.writeGroups
+    }
+
+    if (editing.index !== null) {
+      // Update existing
+      const newPermissions = [...fieldPermissions]
+      newPermissions[editing.index] = newPermission
+      onChange(newPermissions)
+    } else {
+      // Add new
+      onChange([...fieldPermissions, newPermission])
+    }
+
+    cancelEditing()
+  }, [editing, fieldPermissions, onChange, cancelEditing])
+
+  const removePermission = useCallback((index: number) => {
+    onChange(fieldPermissions.filter((_, i) => i !== index))
+  }, [fieldPermissions, onChange])
+
+  const toggleGroup = useCallback((type: 'read' | 'write', groupId: string) => {
+    if (type === 'read') {
+      setEditing(prev => ({
+        ...prev,
+        readGroups: prev.readGroups.includes(groupId)
+          ? prev.readGroups.filter(g => g !== groupId)
+          : [...prev.readGroups, groupId]
+      }))
+    } else {
+      setEditing(prev => ({
+        ...prev,
+        writeGroups: prev.writeGroups.includes(groupId)
+          ? prev.writeGroups.filter(g => g !== groupId)
+          : [...prev.writeGroups, groupId]
+      }))
+    }
+  }, [])
+
+  const getGroupName = useCallback((groupId: string): string => {
+    const group = availableGroups.find(g => g.id === groupId)
+    return group?.name || groupId
+  }, [availableGroups])
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <label className="block text-sm font-medium text-gray-700">
+          Field Permissions
+        </label>
+        {!disabled && !editing.isEditing && (
+          <button
+            type="button"
+            onClick={startAdding}
+            className="inline-flex items-center px-2 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50"
+          >
+            + Add Field Rule
+          </button>
+        )}
+      </div>
+
+      {/* Existing Field Permissions */}
+      {fieldPermissions.length === 0 && !editing.isEditing ? (
+        <p className="text-sm text-gray-500 italic">
+          No field permissions configured. All fields are accessible by default.
+        </p>
+      ) : (
+        <div className="space-y-2">
+          {fieldPermissions.map((fp, index) => (
+            <div
+              key={index}
+              className="flex items-start justify-between p-3 bg-gray-50 rounded-md border border-gray-200"
+            >
+              <div className="flex-1">
+                <div className="flex items-center">
+                  <code className="text-sm font-medium text-gray-900">{fp.field_name}</code>
+                  {fp.field_name === '*' && (
+                    <span className="ml-2 text-xs text-gray-500">(All fields)</span>
+                  )}
+                </div>
+                <div className="mt-1 flex flex-wrap gap-2 text-xs">
+                  <span className="text-gray-500">Read:</span>
+                  {fp.read_groups.length === 0 ? (
+                    <span className="text-gray-400">Everyone</span>
+                  ) : (
+                    fp.read_groups.map(g => (
+                      <span key={g} className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">
+                        {getGroupName(g)}
+                      </span>
+                    ))
+                  )}
+                  <span className="text-gray-500 ml-2">Write:</span>
+                  {fp.write_groups.length === 0 ? (
+                    <span className="text-gray-400">Everyone</span>
+                  ) : (
+                    fp.write_groups.map(g => (
+                      <span key={g} className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded">
+                        {getGroupName(g)}
+                      </span>
+                    ))
+                  )}
+                </div>
+              </div>
+              {!disabled && (
+                <div className="flex space-x-1">
+                  <button
+                    type="button"
+                    onClick={() => startEditing(index)}
+                    className="p-1 text-gray-400 hover:text-gray-600"
+                    title="Edit"
+                  >
+                    Edit
+                  </button>
+                  <button
+                    type="button"
+                    onClick={() => removePermission(index)}
+                    className="p-1 text-red-400 hover:text-red-600"
+                    title="Remove"
+                  >
+                    Remove
+                  </button>
+                </div>
+              )}
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* Editor */}
+      {editing.isEditing && (
+        <div className="border border-indigo-200 rounded-md p-4 bg-indigo-50">
+          <h4 className="text-sm font-medium text-gray-900 mb-3">
+            {editing.index !== null ? 'Edit Field Permission' : 'Add Field Permission'}
+          </h4>
+
+          <div className="space-y-4">
+            {/* Field Name */}
+            <div>
+              <label className="block text-xs font-medium text-gray-600 mb-1">Field</label>
+              {fields.length > 0 ? (
+                <select
+                  value={editing.fieldName}
+                  onChange={(e) => setEditing({ ...editing, fieldName: e.target.value })}
+                  className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+                >
+                  <option value="*">* (All fields)</option>
+                  {fields.map(field => (
+                    <option key={field} value={field}>{field}</option>
+                  ))}
+                </select>
+              ) : (
+                <input
+                  type="text"
+                  value={editing.fieldName}
+                  onChange={(e) => setEditing({ ...editing, fieldName: e.target.value })}
+                  placeholder="field_name or *"
+                  className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+                />
+              )}
+            </div>
+
+            {/* Read Groups */}
+            <div>
+              <label className="block text-xs font-medium text-gray-600 mb-1">
+                Read Access (empty = everyone)
+              </label>
+              <div className="flex flex-wrap gap-2">
+                {availableGroups.map(group => (
+                  <label
+                    key={group.id}
+                    className={`inline-flex items-center px-2 py-1 rounded text-xs cursor-pointer ${
+                      editing.readGroups.includes(group.id)
+                        ? 'bg-blue-500 text-white'
+                        : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
+                    }`}
+                  >
+                    <input
+                      type="checkbox"
+                      checked={editing.readGroups.includes(group.id)}
+                      onChange={() => toggleGroup('read', group.id)}
+                      className="sr-only"
+                    />
+                    {group.name}
+                    {group.is_system && <span className="ml-1 opacity-60">(sys)</span>}
+                  </label>
+                ))}
+              </div>
+            </div>
+
+            {/* Write Groups */}
+            <div>
+              <label className="block text-xs font-medium text-gray-600 mb-1">
+                Write Access (empty = everyone)
+              </label>
+              <div className="flex flex-wrap gap-2">
+                {availableGroups.map(group => (
+                  <label
+                    key={group.id}
+                    className={`inline-flex items-center px-2 py-1 rounded text-xs cursor-pointer ${
+                      editing.writeGroups.includes(group.id)
+                        ? 'bg-green-500 text-white'
+                        : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
+                    }`}
+                  >
+                    <input
+                      type="checkbox"
+                      checked={editing.writeGroups.includes(group.id)}
+                      onChange={() => toggleGroup('write', group.id)}
+                      className="sr-only"
+                    />
+                    {group.name}
+                    {group.is_system && <span className="ml-1 opacity-60">(sys)</span>}
+                  </label>
+                ))}
+              </div>
+            </div>
+          </div>
+
+          {/* Actions */}
+          <div className="mt-4 flex justify-end space-x-2">
+            <button
+              type="button"
+              onClick={cancelEditing}
+              className="px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900"
+            >
+              Cancel
+            </button>
+            <button
+              type="button"
+              onClick={saveEditing}
+              disabled={!editing.fieldName}
+              className="px-3 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {editing.index !== null ? 'Update' : 'Add'}
+            </button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default FieldPermissionEditor

+ 35 - 0
webui/src/components/Input.tsx

@@ -0,0 +1,35 @@
+// Reusable Input component
+
+import { InputHTMLAttributes, forwardRef } from 'react'
+
+interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
+  label?: string
+  error?: string
+}
+
+const Input = forwardRef<HTMLInputElement, InputProps>(
+  ({ className = '', label, error, id, ...props }, ref) => {
+    const inputId = id || props.name
+
+    return (
+      <div className="w-full">
+        {label && (
+          <label htmlFor={inputId} className="mb-1.5 block text-sm font-medium text-gray-700">
+            {label}
+          </label>
+        )}
+        <input
+          ref={ref}
+          id={inputId}
+          className={`w-full rounded-lg border px-3 py-2 text-gray-900 placeholder-gray-400 transition focus:outline-none focus:ring-2 ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : 'border-gray-300 focus:border-primary-500 focus:ring-primary-200'} disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 ${className} `}
+          {...props}
+        />
+        {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
+      </div>
+    )
+  }
+)
+
+Input.displayName = 'Input'
+
+export default Input

+ 206 - 0
webui/src/components/NotificationBell.tsx

@@ -0,0 +1,206 @@
+// Notification bell component (US-036)
+
+import { useState, useRef, useEffect } from 'react'
+import { useWebSocket } from '@/contexts/WebSocketContext'
+
+function NotificationBell() {
+  const { notifications, unreadCount, markAsRead, markAllAsRead, clearNotifications, isConnected } =
+    useWebSocket()
+  const [isOpen, setIsOpen] = useState(false)
+  const dropdownRef = useRef<HTMLDivElement>(null)
+
+  // Close dropdown when clicking outside
+  useEffect(() => {
+    function handleClickOutside(event: MouseEvent) {
+      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+        setIsOpen(false)
+      }
+    }
+
+    document.addEventListener('mousedown', handleClickOutside)
+    return () => document.removeEventListener('mousedown', handleClickOutside)
+  }, [])
+
+  const getLevelColor = (level: string) => {
+    switch (level) {
+      case 'error':
+        return 'text-red-500'
+      case 'warn':
+        return 'text-yellow-500'
+      default:
+        return 'text-blue-500'
+    }
+  }
+
+  const getLevelIcon = (level: string) => {
+    switch (level) {
+      case 'error':
+        return (
+          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+            />
+          </svg>
+        )
+      case 'warn':
+        return (
+          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
+            />
+          </svg>
+        )
+      default:
+        return (
+          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+            />
+          </svg>
+        )
+    }
+  }
+
+  const formatTime = (timestamp: string) => {
+    const date = new Date(timestamp)
+    const now = new Date()
+    const diffMs = now.getTime() - date.getTime()
+    const diffMins = Math.floor(diffMs / 60000)
+    const diffHours = Math.floor(diffMins / 60)
+
+    if (diffMins < 1) return 'Just now'
+    if (diffMins < 60) return `${diffMins}m ago`
+    if (diffHours < 24) return `${diffHours}h ago`
+    return date.toLocaleDateString()
+  }
+
+  return (
+    <div className="relative" ref={dropdownRef}>
+      {/* Bell button */}
+      <button
+        onClick={() => setIsOpen(!isOpen)}
+        className="relative rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
+        title={isConnected ? 'Notifications' : 'Reconnecting...'}
+      >
+        <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth={2}
+            d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
+          />
+        </svg>
+
+        {/* Unread badge */}
+        {unreadCount > 0 && (
+          <span className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
+            {unreadCount > 9 ? '9+' : unreadCount}
+          </span>
+        )}
+
+        {/* Connection indicator */}
+        {!isConnected && (
+          <span className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-yellow-400" />
+        )}
+      </button>
+
+      {/* Dropdown */}
+      {isOpen && (
+        <div className="absolute right-0 z-50 mt-2 w-80 origin-top-right rounded-lg bg-white shadow-lg ring-1 ring-black/5">
+          {/* Header */}
+          <div className="flex items-center justify-between border-b px-4 py-3">
+            <h3 className="font-medium text-gray-900">Notifications</h3>
+            <div className="flex gap-2">
+              {unreadCount > 0 && (
+                <button
+                  onClick={markAllAsRead}
+                  className="text-xs text-primary-600 hover:text-primary-700"
+                >
+                  Mark all read
+                </button>
+              )}
+              {notifications.length > 0 && (
+                <button
+                  onClick={clearNotifications}
+                  className="text-xs text-gray-500 hover:text-gray-700"
+                >
+                  Clear
+                </button>
+              )}
+            </div>
+          </div>
+
+          {/* Notifications list */}
+          <div className="max-h-96 overflow-y-auto">
+            {notifications.length === 0 ? (
+              <div className="px-4 py-8 text-center text-gray-500">
+                <svg
+                  className="mx-auto h-8 w-8 text-gray-300"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
+                  />
+                </svg>
+                <p className="mt-2 text-sm">No notifications</p>
+              </div>
+            ) : (
+              notifications.map((notification) => (
+                <div
+                  key={notification.id}
+                  onClick={() => markAsRead(notification.id)}
+                  className={`cursor-pointer border-b border-gray-100 px-4 py-3 transition hover:bg-gray-50 last:border-0 ${
+                    !notification.read ? 'bg-blue-50/50' : ''
+                  }`}
+                >
+                  <div className="flex items-start gap-3">
+                    <div className={`mt-0.5 ${getLevelColor(notification.level)}`}>
+                      {getLevelIcon(notification.level)}
+                    </div>
+                    <div className="flex-1 min-w-0">
+                      <p className="text-sm text-gray-900">{notification.message}</p>
+                      <p className="mt-1 text-xs text-gray-500">
+                        {formatTime(notification.timestamp)}
+                      </p>
+                    </div>
+                    {!notification.read && (
+                      <div className="mt-1.5 h-2 w-2 rounded-full bg-blue-500" />
+                    )}
+                  </div>
+                </div>
+              ))
+            )}
+          </div>
+
+          {/* Connection status */}
+          <div className="border-t px-4 py-2">
+            <div className="flex items-center gap-2 text-xs">
+              <span
+                className={`h-2 w-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-yellow-500'}`}
+              />
+              <span className="text-gray-500">
+                {isConnected ? 'Connected' : 'Reconnecting...'}
+              </span>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default NotificationBell

+ 322 - 0
webui/src/components/PermissionEditor.tsx

@@ -0,0 +1,322 @@
+import { useState, useCallback } from 'react'
+import {
+  SystemActions,
+  SystemResources,
+  WorkspaceActions,
+  CollectionActions,
+  FieldActions,
+  type SystemAction,
+  type SystemResource,
+  type WorkspaceAction,
+  type CollectionAction,
+  type FieldAction,
+  type PermissionScope,
+  parsePermission,
+  buildSystemPermission,
+  buildWorkspacePermission,
+  buildCollectionPermission,
+  buildFieldPermission
+} from '../types'
+
+interface PermissionEditorProps {
+  permissions: string[]
+  onChange: (permissions: string[]) => void
+  workspaceId?: string
+  collections?: string[]
+  disabled?: boolean
+}
+
+interface PermissionBuilderState {
+  scope: PermissionScope
+  resource: string
+  action: string
+  qualifier: string
+}
+
+export function PermissionEditor({
+  permissions,
+  onChange,
+  workspaceId = '*',
+  collections = [],
+  disabled = false
+}: PermissionEditorProps) {
+  const [showBuilder, setShowBuilder] = useState(false)
+  const [builder, setBuilder] = useState<PermissionBuilderState>({
+    scope: 'system',
+    resource: 'users',
+    action: 'read',
+    qualifier: ''
+  })
+
+  const addPermission = useCallback((permission: string) => {
+    if (!permissions.includes(permission)) {
+      onChange([...permissions, permission])
+    }
+  }, [permissions, onChange])
+
+  const removePermission = useCallback((permission: string) => {
+    onChange(permissions.filter(p => p !== permission))
+  }, [permissions, onChange])
+
+  const buildPermissionString = useCallback((): string => {
+    switch (builder.scope) {
+      case 'system':
+        return buildSystemPermission(builder.resource as SystemResource, builder.action as SystemAction)
+      case 'workspace':
+        return buildWorkspacePermission(builder.resource || workspaceId, builder.action as WorkspaceAction)
+      case 'collection':
+        return buildCollectionPermission(workspaceId, builder.resource || '*', builder.action as CollectionAction)
+      case 'field':
+        return buildFieldPermission(workspaceId, builder.resource || '*', builder.qualifier || '*', builder.action as FieldAction)
+      default:
+        return ''
+    }
+  }, [builder, workspaceId])
+
+  const handleAddFromBuilder = useCallback(() => {
+    const permission = buildPermissionString()
+    if (permission) {
+      addPermission(permission)
+      setShowBuilder(false)
+    }
+  }, [buildPermissionString, addPermission])
+
+  const getActionsForScope = (scope: PermissionScope): readonly string[] => {
+    switch (scope) {
+      case 'system':
+        return SystemActions
+      case 'workspace':
+        return WorkspaceActions
+      case 'collection':
+        return CollectionActions
+      case 'field':
+        return FieldActions
+      default:
+        return []
+    }
+  }
+
+  const renderPermissionBadge = (permission: string) => {
+    const parsed = parsePermission(permission)
+    const scopeColors: Record<PermissionScope, string> = {
+      system: 'bg-purple-100 text-purple-800 border-purple-200',
+      workspace: 'bg-blue-100 text-blue-800 border-blue-200',
+      collection: 'bg-green-100 text-green-800 border-green-200',
+      field: 'bg-yellow-100 text-yellow-800 border-yellow-200'
+    }
+
+    const colorClass = parsed ? scopeColors[parsed.scope] : 'bg-gray-100 text-gray-800 border-gray-200'
+
+    return (
+      <span
+        key={permission}
+        className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border ${colorClass} mr-2 mb-2`}
+      >
+        <code className="mr-1">{permission}</code>
+        {!disabled && (
+          <button
+            type="button"
+            onClick={() => removePermission(permission)}
+            className="ml-1 text-current opacity-60 hover:opacity-100"
+          >
+            &times;
+          </button>
+        )}
+      </span>
+    )
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Current Permissions */}
+      <div>
+        <label className="block text-sm font-medium text-gray-700 mb-2">
+          Permissions
+        </label>
+        <div className="min-h-[40px] p-2 border border-gray-200 rounded-md bg-gray-50">
+          {permissions.length === 0 ? (
+            <span className="text-gray-400 text-sm">No permissions assigned</span>
+          ) : (
+            permissions.map(renderPermissionBadge)
+          )}
+        </div>
+      </div>
+
+      {/* Add Permission Button */}
+      {!disabled && !showBuilder && (
+        <button
+          type="button"
+          onClick={() => setShowBuilder(true)}
+          className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
+        >
+          + Add Permission
+        </button>
+      )}
+
+      {/* Permission Builder */}
+      {showBuilder && !disabled && (
+        <div className="border border-gray-200 rounded-md p-4 bg-white shadow-sm">
+          <h4 className="text-sm font-medium text-gray-900 mb-3">Add Permission</h4>
+
+          <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
+            {/* Scope Select */}
+            <div>
+              <label className="block text-xs font-medium text-gray-500 mb-1">Scope</label>
+              <select
+                value={builder.scope}
+                onChange={(e) => setBuilder({
+                  ...builder,
+                  scope: e.target.value as PermissionScope,
+                  resource: e.target.value === 'system' ? 'users' : '*',
+                  action: getActionsForScope(e.target.value as PermissionScope)[0] as string,
+                  qualifier: ''
+                })}
+                className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+              >
+                <option value="system">System</option>
+                <option value="workspace">Workspace</option>
+                <option value="collection">Collection</option>
+                <option value="field">Field</option>
+              </select>
+            </div>
+
+            {/* Resource Select */}
+            <div>
+              <label className="block text-xs font-medium text-gray-500 mb-1">
+                {builder.scope === 'system' ? 'Resource' : builder.scope === 'workspace' ? 'Workspace' : builder.scope === 'collection' ? 'Collection' : 'Collection'}
+              </label>
+              {builder.scope === 'system' ? (
+                <select
+                  value={builder.resource}
+                  onChange={(e) => setBuilder({ ...builder, resource: e.target.value })}
+                  className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+                >
+                  {SystemResources.map(resource => (
+                    <option key={resource} value={resource}>{resource}</option>
+                  ))}
+                </select>
+              ) : builder.scope === 'workspace' ? (
+                <input
+                  type="text"
+                  value={builder.resource}
+                  onChange={(e) => setBuilder({ ...builder, resource: e.target.value })}
+                  placeholder={workspaceId || '*'}
+                  className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+                />
+              ) : (
+                <select
+                  value={builder.resource}
+                  onChange={(e) => setBuilder({ ...builder, resource: e.target.value })}
+                  className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+                >
+                  <option value="*">* (All)</option>
+                  {collections.map(col => (
+                    <option key={col} value={col}>{col}</option>
+                  ))}
+                </select>
+              )}
+            </div>
+
+            {/* Action Select */}
+            <div>
+              <label className="block text-xs font-medium text-gray-500 mb-1">Action</label>
+              <select
+                value={builder.action}
+                onChange={(e) => setBuilder({ ...builder, action: e.target.value })}
+                className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+              >
+                {getActionsForScope(builder.scope).map(action => (
+                  <option key={action} value={action}>{action}</option>
+                ))}
+              </select>
+            </div>
+
+            {/* Field (for field scope only) */}
+            {builder.scope === 'field' && (
+              <div>
+                <label className="block text-xs font-medium text-gray-500 mb-1">Field</label>
+                <input
+                  type="text"
+                  value={builder.qualifier}
+                  onChange={(e) => setBuilder({ ...builder, qualifier: e.target.value })}
+                  placeholder="* (all fields)"
+                  className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
+                />
+              </div>
+            )}
+          </div>
+
+          {/* Preview */}
+          <div className="mt-3">
+            <label className="block text-xs font-medium text-gray-500 mb-1">Preview</label>
+            <code className="text-sm text-gray-800 bg-gray-100 px-2 py-1 rounded">
+              {buildPermissionString()}
+            </code>
+          </div>
+
+          {/* Actions */}
+          <div className="mt-4 flex justify-end space-x-2">
+            <button
+              type="button"
+              onClick={() => setShowBuilder(false)}
+              className="px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900"
+            >
+              Cancel
+            </button>
+            <button
+              type="button"
+              onClick={handleAddFromBuilder}
+              className="px-3 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
+            >
+              Add
+            </button>
+          </div>
+
+          {/* Quick Add Buttons */}
+          <div className="mt-4 pt-4 border-t border-gray-200">
+            <label className="block text-xs font-medium text-gray-500 mb-2">Quick Add</label>
+            <div className="flex flex-wrap gap-2">
+              <button
+                type="button"
+                onClick={() => addPermission('*')}
+                className="px-2 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700"
+              >
+                Superadmin (*)
+              </button>
+              <button
+                type="button"
+                onClick={() => addPermission('system:login:allow')}
+                className="px-2 py-1 text-xs font-medium text-purple-700 bg-purple-100 rounded hover:bg-purple-200"
+              >
+                Login Allow
+              </button>
+              <button
+                type="button"
+                onClick={() => addPermission('system:*:read')}
+                className="px-2 py-1 text-xs font-medium text-purple-700 bg-purple-100 rounded hover:bg-purple-200"
+              >
+                System Read All
+              </button>
+              <button
+                type="button"
+                onClick={() => addPermission(`collection:${workspaceId}:*:read_all`)}
+                className="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded hover:bg-green-200"
+              >
+                Read All Collections
+              </button>
+              <button
+                type="button"
+                onClick={() => addPermission(`collection:${workspaceId}:*:read_own`)}
+                className="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded hover:bg-green-200"
+              >
+                Read Own Documents
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default PermissionEditor

+ 52 - 0
webui/src/components/ProtectedRoute.tsx

@@ -0,0 +1,52 @@
+// Protected route wrapper that redirects unauthenticated users to login
+
+import { Navigate, useLocation } from 'react-router-dom'
+import { useAuth } from '@/contexts/AuthContext'
+
+interface ProtectedRouteProps {
+  children: React.ReactNode
+}
+
+function ProtectedRoute({ children }: ProtectedRouteProps) {
+  const { isAuthenticated, isLoading } = useAuth()
+  const location = useLocation()
+
+  if (isLoading) {
+    return (
+      <div className="flex min-h-screen items-center justify-center">
+        <div className="flex flex-col items-center gap-4">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+          <p className="text-gray-600">Loading...</p>
+        </div>
+      </div>
+    )
+  }
+
+  if (!isAuthenticated) {
+    // Save the attempted URL for redirecting after login
+    return <Navigate to="/login" state={{ from: location }} replace />
+  }
+
+  return <>{children}</>
+}
+
+export default ProtectedRoute

+ 196 - 0
webui/src/components/Toast.tsx

@@ -0,0 +1,196 @@
+// Toast notification component (US-036)
+
+import { useState, useEffect, useCallback } from 'react'
+
+export interface ToastData {
+  id: string
+  message: string
+  type: 'success' | 'error' | 'warning' | 'info'
+  duration?: number
+}
+
+interface ToastProps {
+  toast: ToastData
+  onDismiss: (id: string) => void
+}
+
+function Toast({ toast, onDismiss }: ToastProps) {
+  const [isVisible, setIsVisible] = useState(false)
+
+  useEffect(() => {
+    // Trigger enter animation
+    const enterTimeout = setTimeout(() => setIsVisible(true), 10)
+
+    // Auto dismiss
+    const duration = toast.duration ?? 5000
+    const dismissTimeout = setTimeout(() => {
+      setIsVisible(false)
+      setTimeout(() => onDismiss(toast.id), 300) // Wait for exit animation
+    }, duration)
+
+    return () => {
+      clearTimeout(enterTimeout)
+      clearTimeout(dismissTimeout)
+    }
+  }, [toast.id, toast.duration, onDismiss])
+
+  const handleDismiss = () => {
+    setIsVisible(false)
+    setTimeout(() => onDismiss(toast.id), 300)
+  }
+
+  const icons = {
+    success: (
+      <svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+      </svg>
+    ),
+    error: (
+      <svg className="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M6 18L18 6M6 6l12 12"
+        />
+      </svg>
+    ),
+    warning: (
+      <svg
+        className="h-5 w-5 text-yellow-500"
+        fill="none"
+        viewBox="0 0 24 24"
+        stroke="currentColor"
+      >
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
+        />
+      </svg>
+    ),
+    info: (
+      <svg className="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+        />
+      </svg>
+    ),
+  }
+
+  const bgColors = {
+    success: 'bg-green-50 border-green-200',
+    error: 'bg-red-50 border-red-200',
+    warning: 'bg-yellow-50 border-yellow-200',
+    info: 'bg-blue-50 border-blue-200',
+  }
+
+  return (
+    <div
+      className={`pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border shadow-lg transition-all duration-300 ${bgColors[toast.type]} ${
+        isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
+      }`}
+    >
+      <div className="p-4">
+        <div className="flex items-start">
+          <div className="flex-shrink-0">{icons[toast.type]}</div>
+          <div className="ml-3 flex-1">
+            <p className="text-sm font-medium text-gray-900">{toast.message}</p>
+          </div>
+          <div className="ml-4 flex flex-shrink-0">
+            <button
+              onClick={handleDismiss}
+              className="inline-flex rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
+            >
+              <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M6 18L18 6M6 6l12 12"
+                />
+              </svg>
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Toast container that renders all toasts
+interface ToastContainerProps {
+  toasts: ToastData[]
+  onDismiss: (id: string) => void
+}
+
+export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
+  return (
+    <div className="pointer-events-none fixed inset-0 z-[100] flex flex-col items-end justify-start gap-2 p-4">
+      {toasts.map((toast) => (
+        <Toast key={toast.id} toast={toast} onDismiss={onDismiss} />
+      ))}
+    </div>
+  )
+}
+
+// Toast context and hook for global toast management
+import { createContext, useContext } from 'react'
+
+interface ToastContextType {
+  showToast: (message: string, type?: ToastData['type'], duration?: number) => void
+  success: (message: string) => void
+  error: (message: string) => void
+  warning: (message: string) => void
+  info: (message: string) => void
+}
+
+const ToastContext = createContext<ToastContextType | undefined>(undefined)
+
+export function ToastProvider({ children }: { children: React.ReactNode }) {
+  const [toasts, setToasts] = useState<ToastData[]>([])
+
+  const showToast = useCallback(
+    (message: string, type: ToastData['type'] = 'info', duration?: number) => {
+      const toast: ToastData = {
+        id: crypto.randomUUID(),
+        message,
+        type,
+        duration,
+      }
+      setToasts((prev) => [...prev, toast])
+    },
+    []
+  )
+
+  const dismissToast = useCallback((id: string) => {
+    setToasts((prev) => prev.filter((t) => t.id !== id))
+  }, [])
+
+  const value: ToastContextType = {
+    showToast,
+    success: (message: string) => showToast(message, 'success'),
+    error: (message: string) => showToast(message, 'error'),
+    warning: (message: string) => showToast(message, 'warning'),
+    info: (message: string) => showToast(message, 'info'),
+  }
+
+  return (
+    <ToastContext.Provider value={value}>
+      {children}
+      <ToastContainer toasts={toasts} onDismiss={dismissToast} />
+    </ToastContext.Provider>
+  )
+}
+
+export function useToast() {
+  const context = useContext(ToastContext)
+  if (context === undefined) {
+    throw new Error('useToast must be used within a ToastProvider')
+  }
+  return context
+}

+ 30 - 0
webui/src/components/layout/DashboardLayout.tsx

@@ -0,0 +1,30 @@
+// Dashboard layout with sidebar and top bar
+
+import { useState } from 'react'
+import { Outlet } from 'react-router-dom'
+import Sidebar from './Sidebar'
+import TopBar from './TopBar'
+
+function DashboardLayout() {
+  const [isSidebarOpen, setIsSidebarOpen] = useState(false)
+
+  return (
+    <div className="flex h-screen bg-gray-50">
+      {/* Sidebar */}
+      <Sidebar isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
+
+      {/* Main content area */}
+      <div className="flex flex-1 flex-col overflow-hidden">
+        {/* Top bar */}
+        <TopBar onMenuClick={() => setIsSidebarOpen(true)} />
+
+        {/* Page content */}
+        <main className="flex-1 overflow-y-auto p-6">
+          <Outlet />
+        </main>
+      </div>
+    </div>
+  )
+}
+
+export default DashboardLayout

+ 229 - 0
webui/src/components/layout/Sidebar.tsx

@@ -0,0 +1,229 @@
+// Sidebar navigation component
+
+import { NavLink } from 'react-router-dom'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import type { Workspace } from '@/types'
+
+interface SidebarProps {
+  isOpen: boolean
+  onClose: () => void
+}
+
+interface NavItem {
+  name: string
+  path: string
+  icon: React.ReactNode
+}
+
+const navigation: NavItem[] = [
+  {
+    name: 'Dashboard',
+    path: '/dashboard',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+        />
+      </svg>
+    ),
+  },
+  {
+    name: 'Collections',
+    path: '/collections',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
+        />
+      </svg>
+    ),
+  },
+  {
+    name: 'Views',
+    path: '/views',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
+        />
+      </svg>
+    ),
+  },
+  {
+    name: 'Users',
+    path: '/users',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
+        />
+      </svg>
+    ),
+  },
+  {
+    name: 'Groups',
+    path: '/groups',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
+        />
+      </svg>
+    ),
+  },
+  {
+    name: 'API Keys',
+    path: '/api-keys',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
+        />
+      </svg>
+    ),
+  },
+  {
+    name: 'Workspaces',
+    path: '/workspaces',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
+        />
+      </svg>
+    ),
+  },
+]
+
+function WorkspaceSelector() {
+  const { workspaces, currentWorkspace, setCurrentWorkspace, isLoading } = useWorkspace()
+
+  const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+    const workspace = workspaces.find((w) => w.id === e.target.value)
+    setCurrentWorkspace(workspace || null)
+  }
+
+  if (isLoading) {
+    return (
+      <div className="px-3 py-2">
+        <div className="h-9 animate-pulse rounded-lg bg-gray-200" />
+      </div>
+    )
+  }
+
+  return (
+    <div className="px-3 py-2">
+      <label htmlFor="workspace-select" className="mb-1 block text-xs font-medium text-gray-500">
+        Workspace
+      </label>
+      <select
+        id="workspace-select"
+        value={currentWorkspace?.id || ''}
+        onChange={handleChange}
+        className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+      >
+        {workspaces.length === 0 ? (
+          <option value="">No workspaces</option>
+        ) : (
+          workspaces.map((workspace: Workspace) => (
+            <option key={workspace.id} value={workspace.id}>
+              {workspace.name}
+            </option>
+          ))
+        )}
+      </select>
+    </div>
+  )
+}
+
+function Sidebar({ isOpen, onClose }: SidebarProps) {
+  return (
+    <>
+      {/* Mobile backdrop */}
+      {isOpen && <div className="fixed inset-0 z-40 bg-gray-600/75 lg:hidden" onClick={onClose} />}
+
+      {/* Sidebar */}
+      <aside
+        className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col bg-white shadow-lg transition-transform duration-300 ease-in-out lg:static lg:translate-x-0 ${isOpen ? 'translate-x-0' : '-translate-x-full'} `}
+      >
+        {/* Logo */}
+        <div className="flex h-16 items-center justify-between border-b px-4">
+          <div className="flex items-center gap-2">
+            <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
+              <span className="text-lg font-bold text-white">S</span>
+            </div>
+            <span className="text-lg font-semibold text-gray-900">SmartBotic</span>
+          </div>
+          <button
+            onClick={onClose}
+            className="rounded-lg p-1 text-gray-500 hover:bg-gray-100 lg:hidden"
+          >
+            <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
+
+        {/* Workspace selector */}
+        <WorkspaceSelector />
+
+        {/* Navigation */}
+        <nav className="flex-1 overflow-y-auto px-3 py-4">
+          <ul className="space-y-1">
+            {navigation.map((item) => (
+              <li key={item.path}>
+                <NavLink
+                  to={item.path}
+                  onClick={onClose}
+                  className={({ isActive }) =>
+                    `flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition ${
+                      isActive
+                        ? 'bg-primary-50 text-primary-700'
+                        : 'text-gray-700 hover:bg-gray-100'
+                    }`
+                  }
+                >
+                  {item.icon}
+                  {item.name}
+                </NavLink>
+              </li>
+            ))}
+          </ul>
+        </nav>
+
+        {/* Footer */}
+        <div className="border-t p-4">
+          <p className="text-xs text-gray-500">SmartBotic CRM v0.1.0</p>
+        </div>
+      </aside>
+    </>
+  )
+}
+
+export default Sidebar

+ 106 - 0
webui/src/components/layout/TopBar.tsx

@@ -0,0 +1,106 @@
+// Top bar component with user menu
+
+import { useState, useRef, useEffect } from 'react'
+import { useAuth } from '@/contexts/AuthContext'
+import NotificationBell from '@/components/NotificationBell'
+
+interface TopBarProps {
+  onMenuClick: () => void
+}
+
+function TopBar({ onMenuClick }: TopBarProps) {
+  const { user, logout, isLoading } = useAuth()
+  const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
+  const menuRef = useRef<HTMLDivElement>(null)
+
+  // Close menu when clicking outside
+  useEffect(() => {
+    function handleClickOutside(event: MouseEvent) {
+      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+        setIsUserMenuOpen(false)
+      }
+    }
+
+    document.addEventListener('mousedown', handleClickOutside)
+    return () => document.removeEventListener('mousedown', handleClickOutside)
+  }, [])
+
+  const handleLogout = async () => {
+    setIsUserMenuOpen(false)
+    await logout()
+  }
+
+  return (
+    <header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-white px-4 shadow-sm">
+      {/* Left side - Menu button (mobile) */}
+      <div className="flex items-center gap-4">
+        <button
+          onClick={onMenuClick}
+          className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 lg:hidden"
+          aria-label="Open menu"
+        >
+          <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M4 6h16M4 12h16M4 18h16"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {/* Right side - Notifications and User menu */}
+      <div className="flex items-center gap-2">
+        <NotificationBell />
+
+        <div className="relative" ref={menuRef}>
+        <button
+          onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
+          className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
+        >
+          <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-primary-700">
+            {user?.name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U'}
+          </div>
+          <span className="hidden sm:block">{user?.name || user?.email || 'User'}</span>
+          <svg
+            className={`h-4 w-4 transition-transform ${isUserMenuOpen ? 'rotate-180' : ''}`}
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+          </svg>
+        </button>
+
+        {/* Dropdown menu */}
+        {isUserMenuOpen && (
+          <div className="absolute right-0 mt-2 w-56 origin-top-right rounded-lg bg-white py-1 shadow-lg ring-1 ring-black/5">
+            <div className="border-b px-4 py-3">
+              <p className="text-sm font-medium text-gray-900">{user?.name || 'User'}</p>
+              <p className="truncate text-sm text-gray-500">{user?.email}</p>
+            </div>
+            <button
+              onClick={handleLogout}
+              disabled={isLoading}
+              className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
+            >
+              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
+                />
+              </svg>
+              {isLoading ? 'Signing out...' : 'Sign out'}
+            </button>
+          </div>
+        )}
+        </div>
+      </div>
+    </header>
+  )
+}
+
+export default TopBar

+ 5 - 0
webui/src/components/layout/index.ts

@@ -0,0 +1,5 @@
+// Layout components barrel export
+
+export { default as DashboardLayout } from './DashboardLayout'
+export { default as Sidebar } from './Sidebar'
+export { default as TopBar } from './TopBar'

+ 214 - 0
webui/src/contexts/AuthContext.tsx

@@ -0,0 +1,214 @@
+// Authentication context for sharing auth state across the app
+
+import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
+import * as authApi from '@/api/auth'
+import apiClient from '@/api/client'
+import type { AuthTokens, User } from '@/types'
+
+const ACCESS_TOKEN_KEY = 'smartbotic_access_token'
+const REFRESH_TOKEN_KEY = 'smartbotic_refresh_token'
+const TOKEN_EXPIRY_KEY = 'smartbotic_token_expiry'
+
+interface AuthContextValue {
+  isAuthenticated: boolean
+  isLoading: boolean
+  user: User | null
+  error: string | null
+  login: (email: string, password: string) => Promise<void>
+  logout: () => Promise<void>
+  clearError: () => void
+}
+
+const AuthContext = createContext<AuthContextValue | null>(null)
+
+interface AuthProviderProps {
+  children: ReactNode
+}
+
+export function AuthProvider({ children }: AuthProviderProps) {
+  const [isAuthenticated, setIsAuthenticated] = useState(() => {
+    return !!localStorage.getItem(ACCESS_TOKEN_KEY)
+  })
+  const [isLoading, setIsLoading] = useState(true)
+  const [user, setUser] = useState<User | null>(null)
+  const [error, setError] = useState<string | null>(null)
+
+  const clearError = useCallback(() => {
+    setError(null)
+  }, [])
+
+  const saveTokens = useCallback((tokens: AuthTokens) => {
+    localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token)
+    localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
+    // Calculate expiry time (subtract 60 seconds for buffer)
+    const expiryTime = Date.now() + (tokens.expires_in - 60) * 1000
+    localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toString())
+    apiClient.setAccessToken(tokens.access_token)
+  }, [])
+
+  const clearTokens = useCallback(() => {
+    localStorage.removeItem(ACCESS_TOKEN_KEY)
+    localStorage.removeItem(REFRESH_TOKEN_KEY)
+    localStorage.removeItem(TOKEN_EXPIRY_KEY)
+    apiClient.setAccessToken(null)
+  }, [])
+
+  const refreshTokens = useCallback(async (): Promise<boolean> => {
+    const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
+    if (!refreshToken) {
+      return false
+    }
+
+    try {
+      const tokens = await authApi.refreshToken(refreshToken)
+      saveTokens(tokens)
+      return true
+    } catch {
+      clearTokens()
+      setIsAuthenticated(false)
+      setUser(null)
+      return false
+    }
+  }, [saveTokens, clearTokens])
+
+  const login = useCallback(
+    async (email: string, password: string) => {
+      setIsLoading(true)
+      setError(null)
+      try {
+        const tokens = await authApi.login({ email, password })
+        saveTokens(tokens)
+        setIsAuthenticated(true)
+        // Fetch user info after successful login
+        try {
+          const userInfo = await apiClient.get<User>('/auth/me')
+          setUser(userInfo)
+        } catch {
+          // User info fetch failed but login succeeded
+        }
+      } catch (err) {
+        const message = err instanceof Error ? err.message : 'Login failed'
+        setError(message)
+        throw err
+      } finally {
+        setIsLoading(false)
+      }
+    },
+    [saveTokens]
+  )
+
+  const logout = useCallback(async () => {
+    setIsLoading(true)
+    try {
+      const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
+      if (refreshToken) {
+        await authApi.logout(refreshToken).catch(() => {
+          // Ignore logout API errors
+        })
+      }
+    } finally {
+      clearTokens()
+      setIsAuthenticated(false)
+      setUser(null)
+      setIsLoading(false)
+    }
+  }, [clearTokens])
+
+  // Initialize auth state on mount
+  useEffect(() => {
+    const initAuth = async () => {
+      const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY)
+      if (!accessToken) {
+        setIsLoading(false)
+        return
+      }
+
+      apiClient.setAccessToken(accessToken)
+
+      // Check if token is expired or about to expire
+      const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY)
+      const expiry = expiryStr ? parseInt(expiryStr, 10) : 0
+
+      if (Date.now() >= expiry) {
+        // Token expired, try to refresh
+        const refreshed = await refreshTokens()
+        if (!refreshed) {
+          setIsLoading(false)
+          return
+        }
+      }
+
+      // Fetch user info
+      try {
+        const userInfo = await apiClient.get<User>('/auth/me')
+        setUser(userInfo)
+        setIsAuthenticated(true)
+      } catch {
+        // Token invalid, try to refresh
+        const refreshed = await refreshTokens()
+        if (refreshed) {
+          try {
+            const userInfo = await apiClient.get<User>('/auth/me')
+            setUser(userInfo)
+            setIsAuthenticated(true)
+          } catch {
+            clearTokens()
+            setIsAuthenticated(false)
+          }
+        }
+      } finally {
+        setIsLoading(false)
+      }
+    }
+
+    initAuth()
+  }, [refreshTokens, clearTokens])
+
+  // Auto-refresh token before expiry
+  useEffect(() => {
+    if (!isAuthenticated) {
+      return
+    }
+
+    const checkAndRefresh = () => {
+      const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY)
+      const expiry = expiryStr ? parseInt(expiryStr, 10) : 0
+
+      // Refresh if token expires in less than 2 minutes
+      if (Date.now() >= expiry - 120000) {
+        refreshTokens()
+      }
+    }
+
+    // Check every minute
+    const interval = setInterval(checkAndRefresh, 60000)
+
+    return () => clearInterval(interval)
+  }, [isAuthenticated, refreshTokens])
+
+  return (
+    <AuthContext.Provider
+      value={{
+        isAuthenticated,
+        isLoading,
+        user,
+        error,
+        login,
+        logout,
+        clearError,
+      }}
+    >
+      {children}
+    </AuthContext.Provider>
+  )
+}
+
+export function useAuth() {
+  const context = useContext(AuthContext)
+  if (!context) {
+    throw new Error('useAuth must be used within an AuthProvider')
+  }
+  return context
+}
+
+export default AuthContext

+ 291 - 0
webui/src/contexts/WebSocketContext.tsx

@@ -0,0 +1,291 @@
+// WebSocket context for real-time updates (US-036)
+
+import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
+import { useAuth } from './AuthContext'
+
+const ACCESS_TOKEN_KEY = 'smartbotic_access_token'
+
+interface WebSocketMessage {
+  type: string
+  [key: string]: unknown
+}
+
+interface Notification {
+  id: string
+  message: string
+  level: 'info' | 'warn' | 'error'
+  timestamp: string
+  read: boolean
+}
+
+interface DocumentEvent {
+  workspace_id: string
+  collection: string
+  action: 'create' | 'update' | 'delete'
+  document_id: string
+  data?: unknown
+}
+
+interface WebSocketContextType {
+  isConnected: boolean
+  notifications: Notification[]
+  unreadCount: number
+  subscribe: (workspaceId: string, collection: string) => void
+  unsubscribe: (workspaceId: string, collection: string) => void
+  markAsRead: (notificationId: string) => void
+  markAllAsRead: () => void
+  clearNotifications: () => void
+  onDocumentEvent: (callback: (event: DocumentEvent) => void) => () => void
+}
+
+const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
+
+const WS_URL = `ws://${window.location.hostname}:18081/ws`
+const RECONNECT_DELAY = 3000
+const MAX_NOTIFICATIONS = 50
+
+export function WebSocketProvider({ children }: { children: React.ReactNode }) {
+  const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
+  const wsRef = useRef<WebSocket | null>(null)
+  const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
+  const documentEventCallbacksRef = useRef<Set<(event: DocumentEvent) => void>>(new Set())
+
+  const [isConnected, setIsConnected] = useState(false)
+  const [notifications, setNotifications] = useState<Notification[]>([])
+  const [subscriptions, setSubscriptions] = useState<Set<string>>(new Set())
+
+  const unreadCount = notifications.filter((n) => !n.read).length
+
+  // Get access token from localStorage
+  const getAccessToken = useCallback(() => {
+    return localStorage.getItem(ACCESS_TOKEN_KEY)
+  }, [])
+
+  // Connect to WebSocket
+  const connect = useCallback(() => {
+    // Don't connect while auth is still loading
+    if (isAuthLoading) {
+      return
+    }
+    const accessToken = getAccessToken()
+    if (!isAuthenticated || !accessToken || wsRef.current?.readyState === WebSocket.OPEN) {
+      return
+    }
+
+    try {
+      const ws = new WebSocket(WS_URL)
+      wsRef.current = ws
+
+      ws.onopen = () => {
+        console.log('WebSocket connected')
+        // Send authentication
+        ws.send(JSON.stringify({ type: 'auth', token: accessToken }))
+      }
+
+      ws.onmessage = (event) => {
+        try {
+          const message: WebSocketMessage = JSON.parse(event.data)
+          handleMessage(message)
+        } catch (err) {
+          console.error('Failed to parse WebSocket message:', err)
+        }
+      }
+
+      ws.onclose = () => {
+        console.log('WebSocket disconnected')
+        setIsConnected(false)
+        wsRef.current = null
+
+        // Attempt reconnection
+        if (isAuthenticated) {
+          reconnectTimeoutRef.current = setTimeout(connect, RECONNECT_DELAY)
+        }
+      }
+
+      ws.onerror = (error) => {
+        console.error('WebSocket error:', error)
+      }
+    } catch (err) {
+      console.error('Failed to create WebSocket connection:', err)
+    }
+  }, [isAuthenticated, isAuthLoading, getAccessToken])
+
+  // Handle incoming messages
+  const handleMessage = useCallback((message: WebSocketMessage) => {
+    switch (message.type) {
+      case 'auth_success':
+        setIsConnected(true)
+        // Re-subscribe to previous subscriptions
+        subscriptions.forEach((key) => {
+          const [workspaceId, collection] = key.split(':')
+          wsRef.current?.send(
+            JSON.stringify({ type: 'subscribe', workspace_id: workspaceId, collection })
+          )
+        })
+        break
+
+      case 'auth_error':
+        console.error('WebSocket auth error:', message.error)
+        wsRef.current?.close()
+        break
+
+      case 'notification':
+        addNotification({
+          id: crypto.randomUUID(),
+          message: String(message.message || 'New notification'),
+          level: (message.level as 'info' | 'warn' | 'error') || 'info',
+          timestamp: new Date().toISOString(),
+          read: false,
+        })
+        break
+
+      case 'document': {
+        // Notify document event listeners
+        const docEvent: DocumentEvent = {
+          workspace_id: String(message.workspace_id || ''),
+          collection: String(message.collection || ''),
+          action: message.action as 'create' | 'update' | 'delete',
+          document_id: String(message.document_id || ''),
+          data: message.data,
+        }
+        documentEventCallbacksRef.current.forEach((cb) => cb(docEvent))
+        break
+      }
+
+      case 'subscribed':
+        console.log(`Subscribed to ${message.workspace_id}:${message.collection}`)
+        break
+
+      case 'unsubscribed':
+        console.log(`Unsubscribed from ${message.workspace_id}:${message.collection}`)
+        break
+
+      case 'error':
+        console.error('WebSocket error message:', message.error)
+        break
+
+      default:
+        console.log('Unknown WebSocket message type:', message.type)
+    }
+  }, [subscriptions])
+
+  // Add notification
+  const addNotification = useCallback((notification: Notification) => {
+    setNotifications((prev) => {
+      const updated = [notification, ...prev]
+      // Keep only the last MAX_NOTIFICATIONS
+      return updated.slice(0, MAX_NOTIFICATIONS)
+    })
+  }, [])
+
+  // Subscribe to collection changes
+  const subscribe = useCallback((workspaceId: string, collection: string) => {
+    const key = `${workspaceId}:${collection}`
+    setSubscriptions((prev) => new Set(prev).add(key))
+
+    if (wsRef.current?.readyState === WebSocket.OPEN) {
+      wsRef.current.send(JSON.stringify({ type: 'subscribe', workspace_id: workspaceId, collection }))
+    }
+  }, [])
+
+  // Unsubscribe from collection changes
+  const unsubscribe = useCallback((workspaceId: string, collection: string) => {
+    const key = `${workspaceId}:${collection}`
+    setSubscriptions((prev) => {
+      const updated = new Set(prev)
+      updated.delete(key)
+      return updated
+    })
+
+    if (wsRef.current?.readyState === WebSocket.OPEN) {
+      wsRef.current.send(
+        JSON.stringify({ type: 'unsubscribe', workspace_id: workspaceId, collection })
+      )
+    }
+  }, [])
+
+  // Mark notification as read
+  const markAsRead = useCallback((notificationId: string) => {
+    setNotifications((prev) =>
+      prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n))
+    )
+  }, [])
+
+  // Mark all notifications as read
+  const markAllAsRead = useCallback(() => {
+    setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
+  }, [])
+
+  // Clear all notifications
+  const clearNotifications = useCallback(() => {
+    setNotifications([])
+  }, [])
+
+  // Register document event callback
+  const onDocumentEvent = useCallback((callback: (event: DocumentEvent) => void) => {
+    documentEventCallbacksRef.current.add(callback)
+    return () => {
+      documentEventCallbacksRef.current.delete(callback)
+    }
+  }, [])
+
+  // Connect on auth change
+  useEffect(() => {
+    // Wait for auth to finish loading
+    if (isAuthLoading) {
+      return
+    }
+
+    if (isAuthenticated) {
+      connect()
+    } else {
+      // Disconnect on logout
+      if (wsRef.current) {
+        wsRef.current.close()
+        wsRef.current = null
+      }
+      setIsConnected(false)
+      setSubscriptions(new Set())
+    }
+
+    return () => {
+      if (reconnectTimeoutRef.current) {
+        clearTimeout(reconnectTimeoutRef.current)
+      }
+    }
+  }, [isAuthenticated, isAuthLoading, connect])
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (wsRef.current) {
+        wsRef.current.close()
+      }
+      if (reconnectTimeoutRef.current) {
+        clearTimeout(reconnectTimeoutRef.current)
+      }
+    }
+  }, [])
+
+  const value: WebSocketContextType = {
+    isConnected,
+    notifications,
+    unreadCount,
+    subscribe,
+    unsubscribe,
+    markAsRead,
+    markAllAsRead,
+    clearNotifications,
+    onDocumentEvent,
+  }
+
+  return <WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>
+}
+
+export function useWebSocket() {
+  const context = useContext(WebSocketContext)
+  if (context === undefined) {
+    throw new Error('useWebSocket must be used within a WebSocketProvider')
+  }
+  return context
+}

+ 103 - 0
webui/src/contexts/WorkspaceContext.tsx

@@ -0,0 +1,103 @@
+// Workspace context for managing current workspace state
+
+import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
+import apiClient from '@/api/client'
+import { useAuth } from './AuthContext'
+import type { Workspace } from '@/types'
+
+interface WorkspaceContextValue {
+  workspaces: Workspace[]
+  currentWorkspace: Workspace | null
+  isLoading: boolean
+  error: string | null
+  setCurrentWorkspace: (workspace: Workspace | null) => void
+  fetchWorkspaces: () => Promise<void>
+}
+
+const WorkspaceContext = createContext<WorkspaceContextValue | null>(null)
+
+const CURRENT_WORKSPACE_KEY = 'smartbotic_current_workspace_id'
+
+interface WorkspaceProviderProps {
+  children: ReactNode
+}
+
+export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
+  const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
+  const [workspaces, setWorkspaces] = useState<Workspace[]>([])
+  const [currentWorkspace, setCurrentWorkspaceState] = useState<Workspace | null>(null)
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const setCurrentWorkspace = useCallback((workspace: Workspace | null) => {
+    setCurrentWorkspaceState(workspace)
+    if (workspace) {
+      localStorage.setItem(CURRENT_WORKSPACE_KEY, workspace.id)
+    } else {
+      localStorage.removeItem(CURRENT_WORKSPACE_KEY)
+    }
+  }, [])
+
+  const fetchWorkspaces = useCallback(async () => {
+    setIsLoading(true)
+    setError(null)
+    try {
+      const response = await apiClient.get<{ workspaces: Workspace[] }>('/workspaces')
+      const fetchedWorkspaces = response.workspaces || []
+      setWorkspaces(fetchedWorkspaces)
+
+      // Restore or select current workspace
+      const savedId = localStorage.getItem(CURRENT_WORKSPACE_KEY)
+      const savedWorkspace = fetchedWorkspaces.find((w) => w.id === savedId)
+
+      if (savedWorkspace) {
+        setCurrentWorkspaceState(savedWorkspace)
+      } else if (fetchedWorkspaces.length > 0 && !currentWorkspace) {
+        // Auto-select first workspace if none selected
+        setCurrentWorkspace(fetchedWorkspaces[0])
+      }
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Failed to fetch workspaces'
+      setError(message)
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace, setCurrentWorkspace])
+
+  // Fetch workspaces when authenticated
+  useEffect(() => {
+    if (isAuthenticated && !isAuthLoading) {
+      fetchWorkspaces()
+    } else if (!isAuthenticated && !isAuthLoading) {
+      // Clear workspaces on logout
+      setWorkspaces([])
+      setCurrentWorkspaceState(null)
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isAuthenticated, isAuthLoading])
+
+  return (
+    <WorkspaceContext.Provider
+      value={{
+        workspaces,
+        currentWorkspace,
+        isLoading,
+        error,
+        setCurrentWorkspace,
+        fetchWorkspaces,
+      }}
+    >
+      {children}
+    </WorkspaceContext.Provider>
+  )
+}
+
+export function useWorkspace() {
+  const context = useContext(WorkspaceContext)
+  if (!context) {
+    throw new Error('useWorkspace must be used within a WorkspaceProvider')
+  }
+  return context
+}
+
+export default WorkspaceContext

+ 17 - 0
webui/src/index.css

@@ -0,0 +1,17 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Custom styles */
+body {
+  font-family:
+    'Inter',
+    system-ui,
+    -apple-system,
+    BlinkMacSystemFont,
+    'Segoe UI',
+    Roboto,
+    'Helvetica Neue',
+    Arial,
+    sans-serif;
+}

+ 25 - 0
webui/src/main.tsx

@@ -0,0 +1,25 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { AuthProvider } from '@/contexts/AuthContext'
+import { WorkspaceProvider } from '@/contexts/WorkspaceContext'
+import { WebSocketProvider } from '@/contexts/WebSocketContext'
+import { ToastProvider } from '@/components/Toast'
+import App from './App'
+import './index.css'
+
+createRoot(document.getElementById('root')!).render(
+  <StrictMode>
+    <BrowserRouter>
+      <AuthProvider>
+        <WorkspaceProvider>
+          <WebSocketProvider>
+            <ToastProvider>
+              <App />
+            </ToastProvider>
+          </WebSocketProvider>
+        </WorkspaceProvider>
+      </AuthProvider>
+    </BrowserRouter>
+  </StrictMode>
+)

+ 479 - 0
webui/src/pages/ApiKeys.tsx

@@ -0,0 +1,479 @@
+// API Keys management page (US-035)
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useAuth } from '@/contexts/AuthContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+
+interface ApiKey {
+  id: string
+  user_id: string
+  name: string
+  key_prefix: string
+  permissions: string[]
+  expires_at?: string
+  last_used_at?: string
+  created_at: string
+}
+
+interface ApiKeyFormData {
+  name: string
+  permissions: string[]
+  expires_in_days?: number
+}
+
+const AVAILABLE_PERMISSIONS = [
+  { id: 'read', label: 'Read', description: 'Read documents and collections' },
+  { id: 'write', label: 'Write', description: 'Create and update documents' },
+  { id: 'delete', label: 'Delete', description: 'Delete documents' },
+]
+
+function CreateApiKeyModal({
+  userId,
+  onClose,
+  onSave,
+}: {
+  userId: string
+  onClose: () => void
+  onSave: (newKey: string) => void
+}) {
+  const [formData, setFormData] = useState<ApiKeyFormData>({
+    name: '',
+    permissions: ['read'],
+    expires_in_days: 365,
+  })
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handlePermissionToggle = (permissionId: string) => {
+    setFormData((prev) => ({
+      ...prev,
+      permissions: prev.permissions.includes(permissionId)
+        ? prev.permissions.filter((p) => p !== permissionId)
+        : [...prev.permissions, permissionId],
+    }))
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const response = await apiClient.post<{ api_key: ApiKey; key: string }>(
+        `/users/${userId}/api-keys`,
+        formData
+      )
+      onSave(response.key)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to create API key')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Create API Key</h2>
+
+        <form onSubmit={handleSubmit} className="mt-4 space-y-4">
+          {error && (
+            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+          )}
+
+          <Input
+            label="Key Name"
+            name="name"
+            value={formData.name}
+            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+            placeholder="My API Key"
+            required
+            autoFocus
+          />
+
+          <div>
+            <label className="mb-2 block text-sm font-medium text-gray-700">Permissions</label>
+            <div className="space-y-2 rounded-lg border p-3">
+              {AVAILABLE_PERMISSIONS.map((perm) => (
+                <label
+                  key={perm.id}
+                  className="flex cursor-pointer items-start gap-3 rounded-lg p-2 hover:bg-gray-50"
+                >
+                  <input
+                    type="checkbox"
+                    checked={formData.permissions.includes(perm.id)}
+                    onChange={() => handlePermissionToggle(perm.id)}
+                    className="mt-1 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
+                  />
+                  <div>
+                    <div className="font-medium text-gray-900">{perm.label}</div>
+                    <div className="text-sm text-gray-500">{perm.description}</div>
+                  </div>
+                </label>
+              ))}
+            </div>
+          </div>
+
+          <div>
+            <label className="mb-1.5 block text-sm font-medium text-gray-700">Expiration</label>
+            <select
+              value={formData.expires_in_days || 0}
+              onChange={(e) =>
+                setFormData({
+                  ...formData,
+                  expires_in_days: e.target.value === '0' ? undefined : parseInt(e.target.value),
+                })
+              }
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
+            >
+              <option value="30">30 days</option>
+              <option value="90">90 days</option>
+              <option value="365">1 year</option>
+              <option value="0">Never expires</option>
+            </select>
+          </div>
+
+          <div className="flex justify-end gap-3 pt-4">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading}>
+              Create API Key
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+function NewKeyDisplayModal({ apiKey, onClose }: { apiKey: string; onClose: () => void }) {
+  const [copied, setCopied] = useState(false)
+
+  const handleCopy = async () => {
+    await navigator.clipboard.writeText(apiKey)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
+        <div className="flex items-center gap-3">
+          <div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
+            <svg
+              className="h-6 w-6 text-green-600"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M5 13l4 4L19 7"
+              />
+            </svg>
+          </div>
+          <h2 className="text-xl font-semibold text-gray-900">API Key Created</h2>
+        </div>
+
+        <div className="mt-4 rounded-lg bg-yellow-50 p-4">
+          <p className="text-sm text-yellow-800">
+            <strong>Important:</strong> Copy your API key now. You won't be able to see it again!
+          </p>
+        </div>
+
+        <div className="mt-4">
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">Your API Key</label>
+          <div className="flex gap-2">
+            <code className="flex-1 break-all rounded-lg border bg-gray-50 px-3 py-2 font-mono text-sm">
+              {apiKey}
+            </code>
+            <Button type="button" variant="secondary" onClick={handleCopy}>
+              {copied ? 'Copied!' : 'Copy'}
+            </Button>
+          </div>
+        </div>
+
+        <div className="mt-6 flex justify-end">
+          <Button onClick={onClose}>Done</Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function RevokeConfirmModal({
+  apiKey,
+  userId,
+  onClose,
+  onConfirm,
+}: {
+  apiKey: ApiKey
+  userId: string
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleRevoke = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.delete(`/users/${userId}/api-keys/${apiKey.id}`)
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to revoke API key')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Revoke API Key</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to revoke <strong>{apiKey.name}</strong>? Applications using this
+          key will no longer be able to authenticate.
+        </p>
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" variant="danger" onClick={handleRevoke} isLoading={isLoading}>
+            Revoke Key
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function ApiKeys() {
+  const { user } = useAuth()
+  const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [newApiKey, setNewApiKey] = useState<string | null>(null)
+  const [revokingKey, setRevokingKey] = useState<ApiKey | null>(null)
+
+  const fetchApiKeys = useCallback(async () => {
+    if (!user) {
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+    try {
+      const response = await apiClient.get<{ api_keys: ApiKey[] }>(`/users/${user.id}/api-keys`)
+      setApiKeys(response.api_keys || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch API keys')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [user])
+
+  useEffect(() => {
+    fetchApiKeys()
+  }, [fetchApiKeys])
+
+  const handleKeyCreated = (key: string) => {
+    setShowCreateModal(false)
+    setNewApiKey(key)
+    fetchApiKeys()
+  }
+
+  const handleRevoked = () => {
+    setRevokingKey(null)
+    fetchApiKeys()
+  }
+
+  if (!user) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
+          <p className="mt-1 text-gray-600">Manage your API keys</p>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">Please log in to manage your API keys.</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
+          <p className="mt-1 text-gray-600">Manage your API keys for programmatic access</p>
+        </div>
+        <Button onClick={() => setShowCreateModal(true)}>
+          <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          Create API Key
+        </Button>
+      </div>
+
+      {/* Error state */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading state */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* API Key list */}
+      {!isLoading && !error && (
+        <div className="overflow-hidden rounded-lg bg-white shadow">
+          {apiKeys.length === 0 ? (
+            <div className="px-6 py-12 text-center">
+              <svg
+                className="mx-auto h-12 w-12 text-gray-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={1.5}
+                  d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
+                />
+              </svg>
+              <p className="mt-4 text-gray-500">No API keys yet</p>
+              <p className="mt-1 text-sm text-gray-400">
+                Create an API key to access the API programmatically.
+              </p>
+            </div>
+          ) : (
+            <table className="min-w-full divide-y divide-gray-200">
+              <thead className="bg-gray-50">
+                <tr>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Name
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Key
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Permissions
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Last Used
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Expires
+                  </th>
+                  <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Actions
+                  </th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-gray-200 bg-white">
+                {apiKeys.map((apiKey) => (
+                  <tr key={apiKey.id} className="hover:bg-gray-50">
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <div className="font-medium text-gray-900">{apiKey.name}</div>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <code className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600">
+                        {apiKey.key_prefix}...
+                      </code>
+                    </td>
+                    <td className="px-6 py-4">
+                      <div className="flex flex-wrap gap-1">
+                        {apiKey.permissions.map((perm) => (
+                          <span
+                            key={perm}
+                            className="inline-flex rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800"
+                          >
+                            {perm}
+                          </span>
+                        ))}
+                      </div>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {apiKey.last_used_at
+                        ? new Date(apiKey.last_used_at).toLocaleDateString()
+                        : 'Never'}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {apiKey.expires_at
+                        ? new Date(apiKey.expires_at).toLocaleDateString()
+                        : 'Never'}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-right">
+                      <button
+                        onClick={() => setRevokingKey(apiKey)}
+                        className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
+                      >
+                        Revoke
+                      </button>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {showCreateModal && (
+        <CreateApiKeyModal
+          userId={user.id}
+          onClose={() => setShowCreateModal(false)}
+          onSave={handleKeyCreated}
+        />
+      )}
+      {newApiKey && <NewKeyDisplayModal apiKey={newApiKey} onClose={() => setNewApiKey(null)} />}
+      {revokingKey && (
+        <RevokeConfirmModal
+          apiKey={revokingKey}
+          userId={user.id}
+          onClose={() => setRevokingKey(null)}
+          onConfirm={handleRevoked}
+        />
+      )}
+    </div>
+  )
+}
+
+export default ApiKeys

+ 611 - 0
webui/src/pages/Collections.tsx

@@ -0,0 +1,611 @@
+// Collections management page (US-032) - Updated for RBAC field permissions
+
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { useAuth } from '@/contexts/AuthContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import FieldPermissionEditor from '@/components/FieldPermissionEditor'
+import type { Collection, Group, FieldPermission } from '@/types'
+
+interface CollectionFormData {
+  name: string
+  schema: string
+  field_permissions: FieldPermission[]
+}
+
+function CollectionModal({
+  collection,
+  workspaceId,
+  availableGroups,
+  onClose,
+  onSave,
+}: {
+  collection: Collection | null
+  workspaceId: string
+  availableGroups: Group[]
+  onClose: () => void
+  onSave: () => void
+}) {
+  const [formData, setFormData] = useState<CollectionFormData>({
+    name: collection?.name || '',
+    schema: collection?.settings?.schema
+      ? typeof collection.settings.schema === 'string'
+        ? collection.settings.schema
+        : JSON.stringify(collection.settings.schema, null, 2)
+      : '{}',
+    field_permissions: collection?.settings?.field_permissions || [],
+  })
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [schemaError, setSchemaError] = useState<string | null>(null)
+  const [activeTab, setActiveTab] = useState<'schema' | 'permissions'>('schema')
+
+  // Extract field names from schema for the field permission editor
+  const schemaFields = (() => {
+    try {
+      const parsed = JSON.parse(formData.schema)
+      if (parsed.properties && typeof parsed.properties === 'object') {
+        return Object.keys(parsed.properties)
+      }
+      return []
+    } catch {
+      return []
+    }
+  })()
+
+  const validateSchema = (schemaStr: string): boolean => {
+    try {
+      JSON.parse(schemaStr)
+      setSchemaError(null)
+      return true
+    } catch {
+      setSchemaError('Invalid JSON schema')
+      return false
+    }
+  }
+
+  const handleSchemaChange = (value: string) => {
+    setFormData({ ...formData, schema: value })
+    validateSchema(value)
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+
+    if (!validateSchema(formData.schema)) {
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const payload = {
+        name: formData.name,
+        settings: {
+          schema: JSON.parse(formData.schema),
+          field_permissions: formData.field_permissions,
+        },
+      }
+
+      if (collection) {
+        await apiClient.request(`/workspaces/${workspaceId}/collections/${collection.name}`, {
+          method: 'PUT',
+          body: payload,
+        })
+      } else {
+        await apiClient.post(`/workspaces/${workspaceId}/collections`, payload)
+      }
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save collection')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">
+          {collection ? 'Edit Collection' : 'Create Collection'}
+        </h2>
+
+        <form onSubmit={handleSubmit} className="mt-4 space-y-4">
+          {error && (
+            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+          )}
+
+          <Input
+            label="Collection Name"
+            name="name"
+            value={formData.name}
+            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+            placeholder="my_collection"
+            required
+            disabled={!!collection}
+            autoFocus={!collection}
+          />
+
+          {/* Tabs */}
+          <div className="border-b border-gray-200">
+            <nav className="-mb-px flex space-x-8">
+              <button
+                type="button"
+                onClick={() => setActiveTab('schema')}
+                className={`py-2 px-1 border-b-2 font-medium text-sm ${
+                  activeTab === 'schema'
+                    ? 'border-indigo-500 text-indigo-600'
+                    : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
+                }`}
+              >
+                Schema
+              </button>
+              <button
+                type="button"
+                onClick={() => setActiveTab('permissions')}
+                className={`py-2 px-1 border-b-2 font-medium text-sm ${
+                  activeTab === 'permissions'
+                    ? 'border-indigo-500 text-indigo-600'
+                    : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
+                }`}
+              >
+                Field Permissions
+              </button>
+            </nav>
+          </div>
+
+          {/* Tab Content */}
+          {activeTab === 'schema' && (
+            <div>
+              <label className="mb-1.5 block text-sm font-medium text-gray-700">
+                Schema (JSON)
+              </label>
+              <textarea
+                value={formData.schema}
+                onChange={(e) => handleSchemaChange(e.target.value)}
+                placeholder='{ "type": "object", "properties": { ... } }'
+                rows={12}
+                className={`w-full rounded-lg border px-3 py-2 font-mono text-sm transition focus:outline-none focus:ring-2 ${
+                  schemaError
+                    ? 'border-red-500 focus:border-red-500 focus:ring-red-200'
+                    : 'border-gray-300 focus:border-primary-500 focus:ring-primary-200'
+                }`}
+              />
+              {schemaError && <p className="mt-1 text-sm text-red-600">{schemaError}</p>}
+              <p className="mt-1 text-xs text-gray-500">
+                Define the JSON Schema for document validation. Fields defined in the schema can be
+                used for field-level permissions.
+              </p>
+            </div>
+          )}
+
+          {activeTab === 'permissions' && (
+            <div className="space-y-4">
+              <p className="text-sm text-gray-600">
+                Configure which groups can read or write specific fields. Leave empty to allow all
+                groups access.
+              </p>
+              <FieldPermissionEditor
+                fieldPermissions={formData.field_permissions}
+                onChange={(field_permissions) =>
+                  setFormData({ ...formData, field_permissions })
+                }
+                availableGroups={availableGroups}
+                fields={schemaFields}
+              />
+            </div>
+          )}
+
+          <div className="flex justify-end gap-3 pt-4 border-t">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading} disabled={!!schemaError}>
+              {collection ? 'Save Changes' : 'Create Collection'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+function DeleteConfirmModal({
+  collection,
+  workspaceId,
+  onClose,
+  onConfirm,
+}: {
+  collection: Collection
+  workspaceId: string
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [confirmName, setConfirmName] = useState('')
+
+  const handleDelete = async () => {
+    if (confirmName !== collection.name) {
+      setError('Collection name does not match')
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.delete(`/workspaces/${workspaceId}/collections/${collection.name}`)
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete collection')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Delete Collection</h2>
+        <p className="mt-2 text-gray-600">
+          This will permanently delete the collection <strong>{collection.name}</strong> and all its
+          documents. This action cannot be undone.
+        </p>
+
+        <div className="mt-4">
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            Type <strong>{collection.name}</strong> to confirm
+          </label>
+          <Input
+            value={confirmName}
+            onChange={(e) => setConfirmName(e.target.value)}
+            placeholder={collection.name}
+          />
+        </div>
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button
+            type="button"
+            variant="danger"
+            onClick={handleDelete}
+            isLoading={isLoading}
+            disabled={confirmName !== collection.name}
+          >
+            Delete Collection
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function Collections() {
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+  const { user } = useAuth()
+  const [collections, setCollections] = useState<Collection[]>([])
+  const [groups, setGroups] = useState<Group[]>([])
+  const [filteredCollections, setFilteredCollections] = useState<Collection[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState('')
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingCollection, setEditingCollection] = useState<Collection | null>(null)
+  const [deletingCollection, setDeletingCollection] = useState<Collection | null>(null)
+  const [showSystemCollections, setShowSystemCollections] = useState(false)
+
+  const isSuperadmin = user?.is_superadmin === true
+
+  const fetchCollections = useCallback(async () => {
+    if (!currentWorkspace) {
+      setCollections([])
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+    try {
+      let url = `/workspaces/${currentWorkspace.id}/collections`
+      if (showSystemCollections && isSuperadmin) {
+        url += '?include_system=true'
+      }
+      const response = await apiClient.get<{ collections: Collection[] }>(url)
+      setCollections(response.collections || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch collections')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace, showSystemCollections, isSuperadmin])
+
+  const fetchGroups = useCallback(async () => {
+    if (!currentWorkspace) {
+      setGroups([])
+      return
+    }
+
+    try {
+      const response = await apiClient.get<{ groups: Group[] }>(
+        `/workspaces/${currentWorkspace.id}/groups`
+      )
+      setGroups(response.groups || [])
+    } catch {
+      // Silently fail - groups are optional for field permissions
+    }
+  }, [currentWorkspace])
+
+  useEffect(() => {
+    fetchCollections()
+    fetchGroups()
+  }, [fetchCollections, fetchGroups])
+
+  useEffect(() => {
+    const query = searchQuery.toLowerCase()
+    setFilteredCollections(collections.filter((c) => c.name.toLowerCase().includes(query)))
+  }, [collections, searchQuery])
+
+  const handleSave = () => {
+    setShowCreateModal(false)
+    setEditingCollection(null)
+    fetchCollections()
+  }
+
+  const handleDelete = () => {
+    setDeletingCollection(null)
+    fetchCollections()
+  }
+
+  const getFieldPermissionCount = (collection: Collection): number => {
+    return collection.settings?.field_permissions?.length || 0
+  }
+
+  if (!currentWorkspace) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Collections</h1>
+          <p className="mt-1 text-gray-600">Manage your data collections</p>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">Please select a workspace to manage collections.</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Collections</h1>
+          <p className="mt-1 text-gray-600">Manage collections in {currentWorkspace.name}</p>
+        </div>
+        <Button onClick={() => setShowCreateModal(true)}>
+          <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          Create Collection
+        </Button>
+      </div>
+
+      {/* Search and filters */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div className="max-w-md flex-1">
+          <Input
+            type="search"
+            placeholder="Search collections..."
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+          />
+        </div>
+
+        {/* System collections toggle for superadmins */}
+        {isSuperadmin && (
+          <label className="flex items-center gap-2 text-sm">
+            <input
+              type="checkbox"
+              checked={showSystemCollections}
+              onChange={(e) => setShowSystemCollections(e.target.checked)}
+              className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
+            />
+            <span className="text-gray-700">Show system collections</span>
+          </label>
+        )}
+      </div>
+
+      {/* Error state */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading state */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* Collection list */}
+      {!isLoading && !error && (
+        <div className="overflow-hidden rounded-lg bg-white shadow">
+          {filteredCollections.length === 0 ? (
+            <div className="px-6 py-12 text-center">
+              <p className="text-gray-500">
+                {searchQuery ? 'No collections match your search' : 'No collections found'}
+              </p>
+            </div>
+          ) : (
+            <table className="min-w-full divide-y divide-gray-200">
+              <thead className="bg-gray-50">
+                <tr>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Name
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Type
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Documents
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Field Rules
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Created
+                  </th>
+                  <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Actions
+                  </th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-gray-200 bg-white">
+                {filteredCollections.map((collection) => (
+                  <tr key={collection.name} className="hover:bg-gray-50">
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <div className="flex items-center gap-2">
+                        <svg
+                          className="h-5 w-5 text-gray-400"
+                          fill="none"
+                          viewBox="0 0 24 24"
+                          stroke="currentColor"
+                        >
+                          <path
+                            strokeLinecap="round"
+                            strokeLinejoin="round"
+                            strokeWidth={2}
+                            d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
+                          />
+                        </svg>
+                        <span className="font-medium text-gray-900">{collection.name}</span>
+                      </div>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4">
+                      {collection.settings?.is_system ? (
+                        <span className="inline-flex rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800">
+                          System
+                        </span>
+                      ) : (
+                        <span className="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
+                          User
+                        </span>
+                      )}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {collection.document_count ?? '-'}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {getFieldPermissionCount(collection) > 0 ? (
+                        <span className="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
+                          {getFieldPermissionCount(collection)} rules
+                        </span>
+                      ) : (
+                        <span className="text-gray-400">-</span>
+                      )}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {new Date(collection.created_at).toLocaleDateString()}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-right">
+                      <div className="flex justify-end gap-2">
+                        <button
+                          onClick={() => navigate(`/collections/${collection.name}`)}
+                          className="rounded px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
+                          title="View documents"
+                        >
+                          Documents
+                        </button>
+                        <button
+                          onClick={() => setEditingCollection(collection)}
+                          className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
+                          disabled={collection.settings?.is_system}
+                          title={
+                            collection.settings?.is_system
+                              ? 'Cannot edit system collections'
+                              : 'Edit'
+                          }
+                        >
+                          Edit
+                        </button>
+                        <button
+                          onClick={() => setDeletingCollection(collection)}
+                          className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
+                          disabled={collection.settings?.is_system}
+                          title={
+                            collection.settings?.is_system
+                              ? 'Cannot delete system collections'
+                              : 'Delete'
+                          }
+                        >
+                          Delete
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {showCreateModal && (
+        <CollectionModal
+          collection={null}
+          workspaceId={currentWorkspace.id}
+          availableGroups={groups}
+          onClose={() => setShowCreateModal(false)}
+          onSave={handleSave}
+        />
+      )}
+      {editingCollection && !editingCollection.settings?.is_system && (
+        <CollectionModal
+          collection={editingCollection}
+          workspaceId={currentWorkspace.id}
+          availableGroups={groups}
+          onClose={() => setEditingCollection(null)}
+          onSave={handleSave}
+        />
+      )}
+      {deletingCollection && !deletingCollection.settings?.is_system && (
+        <DeleteConfirmModal
+          collection={deletingCollection}
+          workspaceId={currentWorkspace.id}
+          onClose={() => setDeletingCollection(null)}
+          onConfirm={handleDelete}
+        />
+      )}
+    </div>
+  )
+}
+
+export default Collections

+ 44 - 0
webui/src/pages/Dashboard.tsx

@@ -0,0 +1,44 @@
+// Dashboard page content
+
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+
+function Dashboard() {
+  const { currentWorkspace } = useWorkspace()
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
+        <p className="mt-1 text-gray-600">
+          Welcome to SmartBotic CRM
+          {currentWorkspace && ` - ${currentWorkspace.name}`}
+        </p>
+      </div>
+
+      {/* Stats grid placeholder */}
+      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+        {[
+          { name: 'Total Users', value: '-', icon: 'users' },
+          { name: 'Collections', value: '-', icon: 'collections' },
+          { name: 'Documents', value: '-', icon: 'documents' },
+          { name: 'API Keys', value: '-', icon: 'keys' },
+        ].map((stat) => (
+          <div key={stat.name} className="rounded-lg bg-white p-6 shadow">
+            <p className="text-sm font-medium text-gray-500">{stat.name}</p>
+            <p className="mt-2 text-3xl font-semibold text-gray-900">{stat.value}</p>
+          </div>
+        ))}
+      </div>
+
+      {/* Quick actions */}
+      <div className="rounded-lg bg-white p-6 shadow">
+        <h2 className="text-lg font-semibold text-gray-900">Quick Actions</h2>
+        <p className="mt-2 text-gray-600">
+          Dashboard functionality will be fully implemented in future updates.
+        </p>
+      </div>
+    </div>
+  )
+}
+
+export default Dashboard

+ 1000 - 0
webui/src/pages/Documents.tsx

@@ -0,0 +1,1000 @@
+// Documents management page (US-034)
+// Dynamic data view with schema-driven list/forms
+
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+
+interface SchemaProperty {
+  type: string
+  title?: string
+  description?: string
+  enum?: string[]
+  format?: string
+  minimum?: number
+  maximum?: number
+  minLength?: number
+  maxLength?: number
+  pattern?: string
+  default?: unknown
+  required?: boolean
+  'x-display-order'?: number
+  'x-widget'?: string
+  'x-hidden'?: boolean
+}
+
+interface CollectionSchema {
+  type: string
+  properties?: Record<string, SchemaProperty>
+  required?: string[]
+}
+
+interface Collection {
+  name: string
+  schema?: CollectionSchema
+  is_system: boolean
+  created_at: string
+  updated_at: string
+  document_count?: number
+}
+
+interface Document {
+  _id: string
+  _created_at: string
+  _updated_at: string
+  [key: string]: unknown
+}
+
+interface DocumentsResponse {
+  documents: Document[]
+  total_count: number
+  limit: number
+  offset: number
+}
+
+// Get display columns from schema
+function getSchemaColumns(schema?: CollectionSchema): { key: string; title: string; type: string }[] {
+  if (!schema?.properties) {
+    return []
+  }
+
+  const columns = Object.entries(schema.properties)
+    .filter(([key, prop]) => !key.startsWith('_') && !prop['x-hidden'])
+    .map(([key, prop]) => ({
+      key,
+      title: prop.title || key,
+      type: prop.type,
+      order: prop['x-display-order'] ?? 999,
+    }))
+    .sort((a, b) => a.order - b.order)
+
+  return columns.slice(0, 6) // Limit to 6 columns for readability
+}
+
+// Format cell value based on type
+function formatCellValue(value: unknown, type: string): string {
+  if (value === null || value === undefined) return '-'
+
+  switch (type) {
+    case 'boolean':
+      return value ? 'Yes' : 'No'
+    case 'array':
+      return Array.isArray(value) ? value.join(', ') : String(value)
+    case 'object':
+      return JSON.stringify(value)
+    default:
+      return String(value)
+  }
+}
+
+// Dynamic form field component
+function SchemaField({
+  name,
+  property,
+  value,
+  onChange,
+  required,
+}: {
+  name: string
+  property: SchemaProperty
+  value: unknown
+  onChange: (name: string, value: unknown) => void
+  required?: boolean
+}) {
+  const label = property.title || name
+  const widget = property['x-widget']
+
+  // Handle different field types
+  if (property.enum) {
+    return (
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">
+          {label}
+          {required && <span className="ml-1 text-red-500">*</span>}
+        </label>
+        <select
+          value={String(value ?? '')}
+          onChange={(e) => onChange(name, e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2 transition focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
+          required={required}
+        >
+          <option value="">Select...</option>
+          {property.enum.map((opt) => (
+            <option key={opt} value={opt}>
+              {opt}
+            </option>
+          ))}
+        </select>
+        {property.description && (
+          <p className="mt-1 text-xs text-gray-500">{property.description}</p>
+        )}
+      </div>
+    )
+  }
+
+  switch (property.type) {
+    case 'boolean':
+      return (
+        <div>
+          <label className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              checked={Boolean(value)}
+              onChange={(e) => onChange(name, e.target.checked)}
+              className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
+            />
+            <span className="text-sm font-medium text-gray-700">{label}</span>
+          </label>
+          {property.description && (
+            <p className="mt-1 text-xs text-gray-500">{property.description}</p>
+          )}
+        </div>
+      )
+
+    case 'number':
+    case 'integer':
+      return (
+        <Input
+          type="number"
+          label={label}
+          value={value !== undefined && value !== null ? String(value) : ''}
+          onChange={(e) => onChange(name, e.target.value ? Number(e.target.value) : undefined)}
+          min={property.minimum}
+          max={property.maximum}
+          required={required}
+          placeholder={property.description}
+        />
+      )
+
+    case 'array':
+      return (
+        <div>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {label}
+            {required && <span className="ml-1 text-red-500">*</span>}
+          </label>
+          <textarea
+            value={Array.isArray(value) ? value.join('\n') : String(value ?? '')}
+            onChange={(e) => onChange(name, e.target.value.split('\n').filter(Boolean))}
+            placeholder="One item per line"
+            rows={3}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 transition focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
+            required={required}
+          />
+          {property.description && (
+            <p className="mt-1 text-xs text-gray-500">{property.description}</p>
+          )}
+        </div>
+      )
+
+    case 'object':
+      return (
+        <div>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {label}
+            {required && <span className="ml-1 text-red-500">*</span>}
+          </label>
+          <textarea
+            value={typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value ?? '{}')}
+            onChange={(e) => {
+              try {
+                onChange(name, JSON.parse(e.target.value))
+              } catch {
+                // Keep raw value while editing
+              }
+            }}
+            placeholder="{}"
+            rows={4}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm transition focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
+            required={required}
+          />
+          {property.description && (
+            <p className="mt-1 text-xs text-gray-500">{property.description}</p>
+          )}
+        </div>
+      )
+
+    default: {
+      // string and others
+      if (widget === 'textarea' || (property.maxLength && property.maxLength > 200)) {
+        return (
+          <div>
+            <label className="mb-1.5 block text-sm font-medium text-gray-700">
+              {label}
+              {required && <span className="ml-1 text-red-500">*</span>}
+            </label>
+            <textarea
+              value={String(value ?? '')}
+              onChange={(e) => onChange(name, e.target.value)}
+              placeholder={property.description}
+              rows={4}
+              minLength={property.minLength}
+              maxLength={property.maxLength}
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 transition focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
+              required={required}
+            />
+            {property.description && (
+              <p className="mt-1 text-xs text-gray-500">{property.description}</p>
+            )}
+          </div>
+        )
+      }
+
+      let inputType = 'text'
+      if (property.format === 'date') inputType = 'date'
+      if (property.format === 'date-time') inputType = 'datetime-local'
+      if (property.format === 'email') inputType = 'email'
+      if (property.format === 'uri') inputType = 'url'
+      if (property.format === 'password') inputType = 'password'
+
+      return (
+        <Input
+          type={inputType}
+          label={label}
+          value={String(value ?? '')}
+          onChange={(e) => onChange(name, e.target.value || undefined)}
+          minLength={property.minLength}
+          maxLength={property.maxLength}
+          pattern={property.pattern}
+          required={required}
+          placeholder={property.description}
+        />
+      )
+    }
+  }
+}
+
+// Document form modal
+function DocumentModal({
+  document,
+  schema,
+  workspaceId,
+  collectionName,
+  onClose,
+  onSave,
+}: {
+  document: Document | null
+  schema?: CollectionSchema
+  workspaceId: string
+  collectionName: string
+  onClose: () => void
+  onSave: () => void
+}) {
+  const isEditing = !!document
+
+  // Initialize form data from document or defaults
+  const initialData = useMemo(() => {
+    if (document) {
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      const { _id, _created_at, _updated_at, ...rest } = document
+      return rest
+    }
+
+    // Use defaults from schema
+    const defaults: Record<string, unknown> = {}
+    if (schema?.properties) {
+      for (const [key, prop] of Object.entries(schema.properties)) {
+        if (!key.startsWith('_') && prop.default !== undefined) {
+          defaults[key] = prop.default
+        }
+      }
+    }
+    return defaults
+  }, [document, schema])
+
+  const [formData, setFormData] = useState<Record<string, unknown>>(initialData)
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleFieldChange = (name: string, value: unknown) => {
+    setFormData((prev) => ({ ...prev, [name]: value }))
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const basePath = `/workspaces/${workspaceId}/collections/${collectionName}/documents`
+
+      if (isEditing && document) {
+        await apiClient.request(`${basePath}/${document._id}`, {
+          method: 'PUT',
+          body: formData,
+        })
+      } else {
+        await apiClient.post(basePath, formData)
+      }
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save document')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  // Get sorted fields from schema
+  const fields = useMemo(() => {
+    if (!schema?.properties) return []
+
+    return Object.entries(schema.properties)
+      .filter(([key]) => !key.startsWith('_'))
+      .map(([key, prop]) => ({
+        key,
+        property: prop,
+        required: schema.required?.includes(key) ?? false,
+        order: prop['x-display-order'] ?? 999,
+      }))
+      .sort((a, b) => a.order - b.order)
+  }, [schema])
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">
+          {isEditing ? 'Edit Document' : 'Create Document'}
+        </h2>
+
+        <form onSubmit={handleSubmit} className="mt-4 space-y-4">
+          {error && (
+            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+          )}
+
+          {fields.length === 0 ? (
+            <div className="rounded-lg bg-gray-50 p-4 text-center text-gray-500">
+              No schema defined. Add fields as JSON below.
+            </div>
+          ) : (
+            fields.map(({ key, property, required }) => (
+              <SchemaField
+                key={key}
+                name={key}
+                property={property}
+                value={formData[key]}
+                onChange={handleFieldChange}
+                required={required}
+              />
+            ))
+          )}
+
+          {/* Raw JSON editor for collections without schema */}
+          {fields.length === 0 && (
+            <div>
+              <label className="mb-1.5 block text-sm font-medium text-gray-700">
+                Document Data (JSON)
+              </label>
+              <textarea
+                value={JSON.stringify(formData, null, 2)}
+                onChange={(e) => {
+                  try {
+                    setFormData(JSON.parse(e.target.value))
+                  } catch {
+                    // Keep raw text while editing
+                  }
+                }}
+                rows={10}
+                className="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm transition focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
+              />
+            </div>
+          )}
+
+          <div className="flex justify-end gap-3 pt-4">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading}>
+              {isEditing ? 'Save Changes' : 'Create Document'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+// Delete confirmation modal
+function DeleteConfirmModal({
+  document,
+  workspaceId,
+  collectionName,
+  onClose,
+  onConfirm,
+}: {
+  document: Document
+  workspaceId: string
+  collectionName: string
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleDelete = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.delete(
+        `/workspaces/${workspaceId}/collections/${collectionName}/documents/${document._id}`
+      )
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete document')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Delete Document</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to delete this document? This action cannot be undone.
+        </p>
+        <div className="mt-2 rounded-lg bg-gray-50 p-3">
+          <p className="font-mono text-sm text-gray-700">ID: {document._id}</p>
+        </div>
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
+            Delete Document
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Document view modal
+function ViewDocumentModal({
+  document,
+  schema,
+  onClose,
+}: {
+  document: Document
+  schema?: CollectionSchema
+  onClose: () => void
+}) {
+  const fields = useMemo(() => {
+    const allFields: { key: string; title: string; value: unknown }[] = []
+
+    // Add schema fields first (in order)
+    if (schema?.properties) {
+      const schemaFields = Object.entries(schema.properties)
+        .filter(([key]) => !key.startsWith('_'))
+        .map(([key, prop]) => ({
+          key,
+          title: prop.title || key,
+          order: prop['x-display-order'] ?? 999,
+        }))
+        .sort((a, b) => a.order - b.order)
+
+      for (const { key, title } of schemaFields) {
+        allFields.push({ key, title, value: document[key] })
+      }
+    }
+
+    // Add any extra fields not in schema
+    for (const [key, value] of Object.entries(document)) {
+      if (!key.startsWith('_') && !allFields.some((f) => f.key === key)) {
+        allFields.push({ key, title: key, value })
+      }
+    }
+
+    // Add metadata fields last
+    allFields.push(
+      { key: '_id', title: 'ID', value: document._id },
+      { key: '_created_at', title: 'Created At', value: document._created_at },
+      { key: '_updated_at', title: 'Updated At', value: document._updated_at }
+    )
+
+    return allFields
+  }, [document, schema])
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">View Document</h2>
+
+        <div className="mt-4 space-y-3">
+          {fields.map(({ key, title, value }) => (
+            <div key={key} className="border-b border-gray-100 pb-3 last:border-0">
+              <dt className="text-sm font-medium text-gray-500">{title}</dt>
+              <dd className="mt-1 text-sm text-gray-900">
+                {value === null || value === undefined ? (
+                  <span className="text-gray-400">-</span>
+                ) : typeof value === 'object' ? (
+                  <pre className="rounded-lg bg-gray-50 p-2 text-xs">
+                    {JSON.stringify(value, null, 2)}
+                  </pre>
+                ) : typeof value === 'boolean' ? (
+                  value ? 'Yes' : 'No'
+                ) : (
+                  String(value)
+                )}
+              </dd>
+            </div>
+          ))}
+        </div>
+
+        <div className="mt-6 flex justify-end">
+          <Button type="button" variant="secondary" onClick={onClose}>
+            Close
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Main Documents page component
+function Documents() {
+  const { collectionName } = useParams<{ collectionName: string }>()
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+
+  const [collection, setCollection] = useState<Collection | null>(null)
+  const [documents, setDocuments] = useState<Document[]>([])
+  const [totalCount, setTotalCount] = useState(0)
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+
+  // Pagination
+  const [page, setPage] = useState(1)
+  const [limit] = useState(20)
+
+  // Sorting
+  const [sortField, setSortField] = useState<string>('_created_at')
+  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
+
+  // Search/filter
+  const [searchQuery, setSearchQuery] = useState('')
+
+  // Modals
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingDocument, setEditingDocument] = useState<Document | null>(null)
+  const [deletingDocument, setDeletingDocument] = useState<Document | null>(null)
+  const [viewingDocument, setViewingDocument] = useState<Document | null>(null)
+
+  // Fetch collection metadata
+  const fetchCollection = useCallback(async () => {
+    if (!currentWorkspace || !collectionName) return
+
+    try {
+      const response = await apiClient.get<Collection>(
+        `/workspaces/${currentWorkspace.id}/collections/${collectionName}`
+      )
+      setCollection(response)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch collection')
+    }
+  }, [currentWorkspace, collectionName])
+
+  // Fetch documents
+  const fetchDocuments = useCallback(async () => {
+    if (!currentWorkspace || !collectionName) return
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const offset = (page - 1) * limit
+      const params = new URLSearchParams({
+        limit: String(limit),
+        offset: String(offset),
+        sort: sortField,
+        order: sortOrder,
+      })
+
+      if (searchQuery) {
+        params.set('q', searchQuery)
+      }
+
+      const response = await apiClient.get<DocumentsResponse>(
+        `/workspaces/${currentWorkspace.id}/collections/${collectionName}/documents?${params}`
+      )
+
+      setDocuments(response.documents || [])
+      setTotalCount(response.total_count || 0)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch documents')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace, collectionName, page, limit, sortField, sortOrder, searchQuery])
+
+  useEffect(() => {
+    fetchCollection()
+  }, [fetchCollection])
+
+  useEffect(() => {
+    fetchDocuments()
+  }, [fetchDocuments])
+
+  // Get columns from schema
+  const columns = useMemo(() => getSchemaColumns(collection?.schema), [collection?.schema])
+
+  const handleSort = (field: string) => {
+    if (field === sortField) {
+      setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
+    } else {
+      setSortField(field)
+      setSortOrder('asc')
+    }
+    setPage(1)
+  }
+
+  const handleSave = () => {
+    setShowCreateModal(false)
+    setEditingDocument(null)
+    fetchDocuments()
+  }
+
+  const handleDelete = () => {
+    setDeletingDocument(null)
+    fetchDocuments()
+  }
+
+  const totalPages = Math.ceil(totalCount / limit)
+
+  if (!currentWorkspace) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Documents</h1>
+          <p className="mt-1 text-gray-600">View and manage collection documents</p>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">Please select a workspace first.</p>
+        </div>
+      </div>
+    )
+  }
+
+  if (!collectionName) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Documents</h1>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">No collection specified.</p>
+          <Button className="mt-4" onClick={() => navigate('/collections')}>
+            Go to Collections
+          </Button>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <div className="flex items-center gap-2">
+            <button
+              onClick={() => navigate('/collections')}
+              className="text-gray-400 hover:text-gray-600"
+            >
+              <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M15 19l-7-7 7-7"
+                />
+              </svg>
+            </button>
+            <h1 className="text-2xl font-bold text-gray-900">{collectionName}</h1>
+            {collection?.is_system && (
+              <span className="inline-flex rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800">
+                System
+              </span>
+            )}
+          </div>
+          <p className="mt-1 text-gray-600">
+            {totalCount} document{totalCount !== 1 ? 's' : ''}
+          </p>
+        </div>
+        <Button onClick={() => setShowCreateModal(true)}>
+          <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          Create Document
+        </Button>
+      </div>
+
+      {/* Search and filters */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
+        <div className="max-w-md flex-1">
+          <Input
+            type="search"
+            placeholder="Search documents..."
+            value={searchQuery}
+            onChange={(e) => {
+              setSearchQuery(e.target.value)
+              setPage(1)
+            }}
+          />
+        </div>
+      </div>
+
+      {/* Error */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* Documents table */}
+      {!isLoading && !error && (
+        <div className="overflow-hidden rounded-lg bg-white shadow">
+          {documents.length === 0 ? (
+            <div className="px-6 py-12 text-center">
+              <svg
+                className="mx-auto h-12 w-12 text-gray-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={1}
+                  d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
+                />
+              </svg>
+              <p className="mt-4 text-gray-500">
+                {searchQuery ? 'No documents match your search' : 'No documents found'}
+              </p>
+              {!searchQuery && (
+                <Button className="mt-4" onClick={() => setShowCreateModal(true)}>
+                  Create First Document
+                </Button>
+              )}
+            </div>
+          ) : (
+            <>
+              <div className="overflow-x-auto">
+                <table className="min-w-full divide-y divide-gray-200">
+                  <thead className="bg-gray-50">
+                    <tr>
+                      {columns.length > 0 ? (
+                        columns.map((col) => (
+                          <th
+                            key={col.key}
+                            onClick={() => handleSort(col.key)}
+                            className="cursor-pointer px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 hover:bg-gray-100"
+                          >
+                            <div className="flex items-center gap-1">
+                              {col.title}
+                              {sortField === col.key && (
+                                <svg
+                                  className={`h-4 w-4 transition ${sortOrder === 'desc' ? 'rotate-180' : ''}`}
+                                  fill="none"
+                                  viewBox="0 0 24 24"
+                                  stroke="currentColor"
+                                >
+                                  <path
+                                    strokeLinecap="round"
+                                    strokeLinejoin="round"
+                                    strokeWidth={2}
+                                    d="M5 15l7-7 7 7"
+                                  />
+                                </svg>
+                              )}
+                            </div>
+                          </th>
+                        ))
+                      ) : (
+                        <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                          ID
+                        </th>
+                      )}
+                      <th
+                        onClick={() => handleSort('_created_at')}
+                        className="cursor-pointer px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 hover:bg-gray-100"
+                      >
+                        <div className="flex items-center gap-1">
+                          Created
+                          {sortField === '_created_at' && (
+                            <svg
+                              className={`h-4 w-4 transition ${sortOrder === 'desc' ? 'rotate-180' : ''}`}
+                              fill="none"
+                              viewBox="0 0 24 24"
+                              stroke="currentColor"
+                            >
+                              <path
+                                strokeLinecap="round"
+                                strokeLinejoin="round"
+                                strokeWidth={2}
+                                d="M5 15l7-7 7 7"
+                              />
+                            </svg>
+                          )}
+                        </div>
+                      </th>
+                      <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                        Actions
+                      </th>
+                    </tr>
+                  </thead>
+                  <tbody className="divide-y divide-gray-200 bg-white">
+                    {documents.map((doc) => (
+                      <tr key={doc._id} className="hover:bg-gray-50">
+                        {columns.length > 0 ? (
+                          columns.map((col) => (
+                            <td
+                              key={col.key}
+                              className="max-w-xs truncate whitespace-nowrap px-6 py-4 text-sm text-gray-900"
+                            >
+                              {formatCellValue(doc[col.key], col.type)}
+                            </td>
+                          ))
+                        ) : (
+                          <td className="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-500">
+                            {doc._id.slice(0, 8)}...
+                          </td>
+                        )}
+                        <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                          {new Date(doc._created_at).toLocaleDateString()}
+                        </td>
+                        <td className="whitespace-nowrap px-6 py-4 text-right">
+                          <div className="flex justify-end gap-2">
+                            <button
+                              onClick={() => setViewingDocument(doc)}
+                              className="rounded px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
+                            >
+                              View
+                            </button>
+                            <button
+                              onClick={() => setEditingDocument(doc)}
+                              className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
+                            >
+                              Edit
+                            </button>
+                            <button
+                              onClick={() => setDeletingDocument(doc)}
+                              className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
+                            >
+                              Delete
+                            </button>
+                          </div>
+                        </td>
+                      </tr>
+                    ))}
+                  </tbody>
+                </table>
+              </div>
+
+              {/* Pagination */}
+              {totalPages > 1 && (
+                <div className="flex items-center justify-between border-t border-gray-200 bg-white px-6 py-3">
+                  <div className="text-sm text-gray-500">
+                    Showing {(page - 1) * limit + 1} to {Math.min(page * limit, totalCount)} of{' '}
+                    {totalCount}
+                  </div>
+                  <div className="flex gap-2">
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => setPage(page - 1)}
+                      disabled={page === 1}
+                    >
+                      Previous
+                    </Button>
+                    <span className="flex items-center px-3 text-sm text-gray-700">
+                      Page {page} of {totalPages}
+                    </span>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => setPage(page + 1)}
+                      disabled={page === totalPages}
+                    >
+                      Next
+                    </Button>
+                  </div>
+                </div>
+              )}
+            </>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {showCreateModal && (
+        <DocumentModal
+          document={null}
+          schema={collection?.schema}
+          workspaceId={currentWorkspace.id}
+          collectionName={collectionName}
+          onClose={() => setShowCreateModal(false)}
+          onSave={handleSave}
+        />
+      )}
+      {editingDocument && (
+        <DocumentModal
+          document={editingDocument}
+          schema={collection?.schema}
+          workspaceId={currentWorkspace.id}
+          collectionName={collectionName}
+          onClose={() => setEditingDocument(null)}
+          onSave={handleSave}
+        />
+      )}
+      {deletingDocument && (
+        <DeleteConfirmModal
+          document={deletingDocument}
+          workspaceId={currentWorkspace.id}
+          collectionName={collectionName}
+          onClose={() => setDeletingDocument(null)}
+          onConfirm={handleDelete}
+        />
+      )}
+      {viewingDocument && (
+        <ViewDocumentModal
+          document={viewingDocument}
+          schema={collection?.schema}
+          onClose={() => setViewingDocument(null)}
+        />
+      )}
+    </div>
+  )
+}
+
+export default Documents

+ 483 - 0
webui/src/pages/Groups.tsx

@@ -0,0 +1,483 @@
+// Groups management page (US-031) - Updated for RBAC
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import PermissionEditor from '@/components/PermissionEditor'
+import type { Group } from '@/types'
+
+interface GroupFormData {
+  name: string
+  permissions: string[]
+  parent_group_id?: string
+}
+
+function GroupModal({
+  group,
+  workspaceId,
+  availableGroups,
+  onClose,
+  onSave,
+}: {
+  group: Group | null
+  workspaceId: string
+  availableGroups: Group[]
+  onClose: () => void
+  onSave: () => void
+}) {
+  const [formData, setFormData] = useState<GroupFormData>({
+    name: group?.name || '',
+    permissions: group?.permissions || [],
+    parent_group_id: group?.parent_group_id || '',
+  })
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  // Filter out the current group from parent options (can't be parent of itself)
+  const parentOptions = availableGroups.filter(g => g.id !== group?.id)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const payload = {
+        name: formData.name,
+        permissions: formData.permissions,
+        parent_group_id: formData.parent_group_id || undefined,
+      }
+
+      if (group) {
+        await apiClient.patch(`/workspaces/${workspaceId}/groups/${group.id}`, payload)
+      } else {
+        await apiClient.post(`/workspaces/${workspaceId}/groups`, payload)
+      }
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save group')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">
+          {group ? 'Edit Group' : 'Create Group'}
+        </h2>
+
+        <form onSubmit={handleSubmit} className="mt-4 space-y-6">
+          {error && (
+            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+          )}
+
+          <Input
+            label="Group Name"
+            name="name"
+            value={formData.name}
+            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+            placeholder="Enter group name"
+            required
+            autoFocus
+          />
+
+          {/* Parent Group Selection */}
+          <div>
+            <label className="block text-sm font-medium text-gray-700 mb-1">
+              Parent Group (optional)
+            </label>
+            <select
+              value={formData.parent_group_id || ''}
+              onChange={(e) => setFormData({ ...formData, parent_group_id: e.target.value })}
+              className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
+            >
+              <option value="">None (Top-level group)</option>
+              {parentOptions.map((g) => (
+                <option key={g.id} value={g.id}>
+                  {g.name}
+                  {g.is_system && ' (System)'}
+                </option>
+              ))}
+            </select>
+            <p className="mt-1 text-xs text-gray-500">
+              Child groups inherit permissions from their parent group.
+            </p>
+          </div>
+
+          {/* Permission Editor */}
+          <PermissionEditor
+            permissions={formData.permissions}
+            onChange={(permissions) => setFormData({ ...formData, permissions })}
+            workspaceId={workspaceId}
+          />
+
+          <div className="flex justify-end gap-3 pt-4 border-t">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading}>
+              {group ? 'Save Changes' : 'Create Group'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+function DeleteConfirmModal({
+  group,
+  workspaceId,
+  onClose,
+  onConfirm,
+}: {
+  group: Group
+  workspaceId: string
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleDelete = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.delete(`/workspaces/${workspaceId}/groups/${group.id}`)
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete group')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Delete Group</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to delete <strong>{group.name}</strong>? Users assigned to this
+          group will lose these permissions.
+        </p>
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
+            Delete Group
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function Groups() {
+  const { currentWorkspace } = useWorkspace()
+  const [groups, setGroups] = useState<Group[]>([])
+  const [filteredGroups, setFilteredGroups] = useState<Group[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState('')
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingGroup, setEditingGroup] = useState<Group | null>(null)
+  const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
+  const [showSystemGroups, setShowSystemGroups] = useState(false)
+
+  const fetchGroups = useCallback(async () => {
+    if (!currentWorkspace) {
+      setGroups([])
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+    try {
+      const response = await apiClient.get<{ groups: Group[] }>(
+        `/workspaces/${currentWorkspace.id}/groups`
+      )
+      setGroups(response.groups || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch groups')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace])
+
+  useEffect(() => {
+    fetchGroups()
+  }, [fetchGroups])
+
+  useEffect(() => {
+    const query = searchQuery.toLowerCase()
+    let filtered = groups.filter(
+      (g) =>
+        g.name.toLowerCase().includes(query) ||
+        g.permissions.some((p) => p.toLowerCase().includes(query))
+    )
+
+    // Filter out system groups unless showing them
+    if (!showSystemGroups) {
+      filtered = filtered.filter((g) => !g.is_system)
+    }
+
+    setFilteredGroups(filtered)
+  }, [groups, searchQuery, showSystemGroups])
+
+  const handleSave = () => {
+    setShowCreateModal(false)
+    setEditingGroup(null)
+    fetchGroups()
+  }
+
+  const handleDelete = () => {
+    setDeletingGroup(null)
+    fetchGroups()
+  }
+
+  const getParentGroupName = (parentId?: string): string | null => {
+    if (!parentId) return null
+    const parent = groups.find((g) => g.id === parentId)
+    return parent?.name || null
+  }
+
+  // Check if any system groups exist
+  const hasSystemGroups = groups.some((g) => g.is_system)
+
+  if (!currentWorkspace) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Groups</h1>
+          <p className="mt-1 text-gray-600">Manage permission groups</p>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">Please select a workspace to manage groups.</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Groups</h1>
+          <p className="mt-1 text-gray-600">Manage permission groups in {currentWorkspace.name}</p>
+        </div>
+        <Button onClick={() => setShowCreateModal(true)}>
+          <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          Create Group
+        </Button>
+      </div>
+
+      {/* Search and filters */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
+        <div className="max-w-md flex-1">
+          <Input
+            type="search"
+            placeholder="Search groups..."
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+          />
+        </div>
+        {hasSystemGroups && (
+          <label className="inline-flex items-center gap-2 text-sm text-gray-600">
+            <input
+              type="checkbox"
+              checked={showSystemGroups}
+              onChange={(e) => setShowSystemGroups(e.target.checked)}
+              className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
+            />
+            Show system groups
+          </label>
+        )}
+      </div>
+
+      {/* Error state */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading state */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* Group list */}
+      {!isLoading && !error && (
+        <div className="overflow-hidden rounded-lg bg-white shadow">
+          {filteredGroups.length === 0 ? (
+            <div className="px-6 py-12 text-center">
+              <p className="text-gray-500">
+                {searchQuery ? 'No groups match your search' : 'No groups found'}
+              </p>
+            </div>
+          ) : (
+            <table className="min-w-full divide-y divide-gray-200">
+              <thead className="bg-gray-50">
+                <tr>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Name
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Permissions
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Parent
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Created
+                  </th>
+                  <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Actions
+                  </th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-gray-200 bg-white">
+                {filteredGroups.map((group) => (
+                  <tr key={group.id} className="hover:bg-gray-50">
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <div className="flex items-center gap-2">
+                        <div className="font-medium text-gray-900">{group.name}</div>
+                        {group.is_system && (
+                          <span className="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800">
+                            System
+                          </span>
+                        )}
+                      </div>
+                    </td>
+                    <td className="px-6 py-4">
+                      <div className="flex flex-wrap gap-1">
+                        {group.permissions.includes('*') ? (
+                          <span className="inline-flex rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
+                            Superadmin (*)
+                          </span>
+                        ) : (
+                          <>
+                            {group.permissions.slice(0, 3).map((perm) => (
+                              <span
+                                key={perm}
+                                className="inline-flex rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800"
+                                title={perm}
+                              >
+                                {perm.length > 25 ? `${perm.substring(0, 22)}...` : perm}
+                              </span>
+                            ))}
+                            {group.permissions.length > 3 && (
+                              <span
+                                className="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
+                                title={group.permissions.slice(3).join(', ')}
+                              >
+                                +{group.permissions.length - 3} more
+                              </span>
+                            )}
+                          </>
+                        )}
+                        {group.permissions.length === 0 && (
+                          <span className="text-sm text-gray-400">No permissions</span>
+                        )}
+                      </div>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {getParentGroupName(group.parent_group_id) || (
+                        <span className="text-gray-400">-</span>
+                      )}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {new Date(group.created_at).toLocaleDateString()}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-right">
+                      {group.is_system ? (
+                        <span className="text-sm text-gray-400" title="System groups cannot be modified">
+                          Protected
+                        </span>
+                      ) : (
+                        <div className="flex justify-end gap-2">
+                          <button
+                            onClick={() => setEditingGroup(group)}
+                            className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
+                          >
+                            Edit
+                          </button>
+                          <button
+                            onClick={() => setDeletingGroup(group)}
+                            className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
+                          >
+                            Delete
+                          </button>
+                        </div>
+                      )}
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {showCreateModal && (
+        <GroupModal
+          group={null}
+          workspaceId={currentWorkspace.id}
+          availableGroups={groups.filter((g) => !g.is_system)}
+          onClose={() => setShowCreateModal(false)}
+          onSave={handleSave}
+        />
+      )}
+      {editingGroup && !editingGroup.is_system && (
+        <GroupModal
+          group={editingGroup}
+          workspaceId={currentWorkspace.id}
+          availableGroups={groups.filter((g) => !g.is_system)}
+          onClose={() => setEditingGroup(null)}
+          onSave={handleSave}
+        />
+      )}
+      {deletingGroup && !deletingGroup.is_system && (
+        <DeleteConfirmModal
+          group={deletingGroup}
+          workspaceId={currentWorkspace.id}
+          onClose={() => setDeletingGroup(null)}
+          onConfirm={handleDelete}
+        />
+      )}
+    </div>
+  )
+}
+
+export default Groups

+ 119 - 0
webui/src/pages/Login.tsx

@@ -0,0 +1,119 @@
+// Login page with email/password form
+
+import { useState, useEffect } from 'react'
+import type { FormEvent } from 'react'
+import { useNavigate, useLocation, Link } from 'react-router-dom'
+import { useAuth } from '@/contexts/AuthContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+
+interface LocationState {
+  from?: { pathname: string }
+}
+
+function Login() {
+  const [email, setEmail] = useState('')
+  const [password, setPassword] = useState('')
+  const [formError, setFormError] = useState<string | null>(null)
+
+  const { login, isLoading, error, isAuthenticated, clearError } = useAuth()
+  const navigate = useNavigate()
+  const location = useLocation()
+
+  const from = (location.state as LocationState)?.from?.pathname || '/dashboard'
+
+  // Redirect if already authenticated
+  useEffect(() => {
+    if (isAuthenticated) {
+      navigate(from, { replace: true })
+    }
+  }, [isAuthenticated, navigate, from])
+
+  // Clear error on unmount
+  useEffect(() => {
+    return () => clearError()
+  }, [clearError])
+
+  const handleSubmit = async (e: FormEvent) => {
+    e.preventDefault()
+    setFormError(null)
+
+    // Basic validation
+    if (!email.trim()) {
+      setFormError('Email is required')
+      return
+    }
+    if (!password) {
+      setFormError('Password is required')
+      return
+    }
+
+    try {
+      await login(email, password)
+      navigate(from, { replace: true })
+    } catch {
+      // Error is handled by AuthContext
+    }
+  }
+
+  const displayError = formError || error
+
+  return (
+    <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
+      <div className="w-full max-w-md">
+        <div className="rounded-xl bg-white px-8 py-10 shadow-lg">
+          <div className="mb-8 text-center">
+            <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600">
+              <span className="text-2xl font-bold text-white">S</span>
+            </div>
+            <h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
+            <p className="mt-2 text-gray-600">Sign in to your SmartBotic account</p>
+          </div>
+
+          <form onSubmit={handleSubmit} className="space-y-5">
+            {displayError && (
+              <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
+                {displayError}
+              </div>
+            )}
+
+            <Input
+              type="email"
+              name="email"
+              label="Email address"
+              placeholder="you@example.com"
+              value={email}
+              onChange={(e) => setEmail(e.target.value)}
+              autoComplete="email"
+              autoFocus
+              disabled={isLoading}
+            />
+
+            <Input
+              type="password"
+              name="password"
+              label="Password"
+              placeholder="Enter your password"
+              value={password}
+              onChange={(e) => setPassword(e.target.value)}
+              autoComplete="current-password"
+              disabled={isLoading}
+            />
+
+            <Button type="submit" className="w-full" size="lg" isLoading={isLoading}>
+              {isLoading ? 'Signing in...' : 'Sign in'}
+            </Button>
+          </form>
+
+          <div className="mt-6 text-center text-sm text-gray-600">
+            <Link to="/" className="font-medium text-primary-600 hover:text-primary-500">
+              Back to home
+            </Link>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default Login

+ 378 - 0
webui/src/pages/Users.tsx

@@ -0,0 +1,378 @@
+// Users management page (US-030)
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import type { User } from '@/types'
+
+interface UserFormData {
+  email: string
+  name: string
+  password: string
+}
+
+function UserModal({
+  user,
+  onClose,
+  onSave,
+}: {
+  user: User | null
+  onClose: () => void
+  onSave: () => void
+}) {
+  const [formData, setFormData] = useState<UserFormData>({
+    email: user?.email || '',
+    name: user?.name || '',
+    password: '',
+  })
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      if (user) {
+        // Update user - only send non-empty password
+        const updateData: Partial<UserFormData> = {
+          name: formData.name,
+        }
+        if (formData.password) {
+          updateData.password = formData.password
+        }
+        await apiClient.patch(`/users/${user.id}`, updateData)
+      } else {
+        // Create user
+        await apiClient.post('/users', formData)
+      }
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save user')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">
+          {user ? 'Edit User' : 'Create User'}
+        </h2>
+
+        <form onSubmit={handleSubmit} className="mt-4 space-y-4">
+          {error && (
+            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+          )}
+
+          <Input
+            label="Email"
+            type="email"
+            name="email"
+            value={formData.email}
+            onChange={(e) => setFormData({ ...formData, email: e.target.value })}
+            placeholder="user@example.com"
+            required
+            disabled={!!user}
+            autoFocus={!user}
+          />
+
+          <Input
+            label="Name"
+            name="name"
+            value={formData.name}
+            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+            placeholder="Full name"
+            autoFocus={!!user}
+          />
+
+          <Input
+            label={user ? 'New Password (leave empty to keep current)' : 'Password'}
+            type="password"
+            name="password"
+            value={formData.password}
+            onChange={(e) => setFormData({ ...formData, password: e.target.value })}
+            placeholder={user ? 'Leave empty to keep current password' : 'Enter password'}
+            required={!user}
+          />
+
+          <div className="flex justify-end gap-3 pt-4">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading}>
+              {user ? 'Save Changes' : 'Create User'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+function DeleteConfirmModal({
+  user,
+  onClose,
+  onConfirm,
+}: {
+  user: User
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleDelete = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.delete(`/users/${user.id}`)
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete user')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Delete User</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to delete <strong>{user.name || user.email}</strong>? This action
+          cannot be undone.
+        </p>
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
+            Delete User
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function Users() {
+  const { currentWorkspace } = useWorkspace()
+  const [users, setUsers] = useState<User[]>([])
+  const [filteredUsers, setFilteredUsers] = useState<User[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState('')
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingUser, setEditingUser] = useState<User | null>(null)
+  const [deletingUser, setDeletingUser] = useState<User | null>(null)
+
+  const fetchUsers = useCallback(async () => {
+    setIsLoading(true)
+    setError(null)
+    try {
+      // If workspace is selected, filter by workspace, otherwise get all users
+      const endpoint = currentWorkspace ? `/workspaces/${currentWorkspace.id}/members` : '/users'
+      const response = await apiClient.get<{
+        users?: User[]
+        members?: Array<{ user_id: string; user?: User }>
+      }>(endpoint)
+
+      if (currentWorkspace && response.members) {
+        // Extract users from members response
+        const memberUsers = response.members.filter((m) => m.user).map((m) => m.user as User)
+        setUsers(memberUsers)
+      } else {
+        setUsers(response.users || [])
+      }
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch users')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace])
+
+  useEffect(() => {
+    fetchUsers()
+  }, [fetchUsers])
+
+  useEffect(() => {
+    const query = searchQuery.toLowerCase()
+    setFilteredUsers(
+      users.filter(
+        (u) =>
+          u.email.toLowerCase().includes(query) ||
+          (u.name && u.name.toLowerCase().includes(query)) ||
+          u.id.toLowerCase().includes(query)
+      )
+    )
+  }, [users, searchQuery])
+
+  const handleSave = () => {
+    setShowCreateModal(false)
+    setEditingUser(null)
+    fetchUsers()
+  }
+
+  const handleDelete = () => {
+    setDeletingUser(null)
+    fetchUsers()
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Users</h1>
+          <p className="mt-1 text-gray-600">
+            {currentWorkspace ? `Manage users in ${currentWorkspace.name}` : 'Manage all users'}
+          </p>
+        </div>
+        <Button onClick={() => setShowCreateModal(true)}>
+          <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          Create User
+        </Button>
+      </div>
+
+      {/* Search */}
+      <div className="max-w-md">
+        <Input
+          type="search"
+          placeholder="Search users..."
+          value={searchQuery}
+          onChange={(e) => setSearchQuery(e.target.value)}
+        />
+      </div>
+
+      {/* Error state */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading state */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* User list */}
+      {!isLoading && !error && (
+        <div className="overflow-hidden rounded-lg bg-white shadow">
+          {filteredUsers.length === 0 ? (
+            <div className="px-6 py-12 text-center">
+              <p className="text-gray-500">
+                {searchQuery ? 'No users match your search' : 'No users found'}
+              </p>
+            </div>
+          ) : (
+            <table className="min-w-full divide-y divide-gray-200">
+              <thead className="bg-gray-50">
+                <tr>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    User
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    ID
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Created
+                  </th>
+                  <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Actions
+                  </th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-gray-200 bg-white">
+                {filteredUsers.map((user) => (
+                  <tr key={user.id} className="hover:bg-gray-50">
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <div className="flex items-center">
+                        <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 text-primary-700">
+                          {user.name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
+                        </div>
+                        <div className="ml-4">
+                          <div className="font-medium text-gray-900">{user.name || 'No name'}</div>
+                          <div className="text-sm text-gray-500">{user.email}</div>
+                        </div>
+                      </div>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <code className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600">
+                        {user.id.slice(0, 8)}...
+                      </code>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {new Date(user.created_at).toLocaleDateString()}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-right">
+                      <div className="flex justify-end gap-2">
+                        <button
+                          onClick={() => setEditingUser(user)}
+                          className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
+                        >
+                          Edit
+                        </button>
+                        <button
+                          onClick={() => setDeletingUser(user)}
+                          className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
+                        >
+                          Delete
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {showCreateModal && (
+        <UserModal user={null} onClose={() => setShowCreateModal(false)} onSave={handleSave} />
+      )}
+      {editingUser && (
+        <UserModal user={editingUser} onClose={() => setEditingUser(null)} onSave={handleSave} />
+      )}
+      {deletingUser && (
+        <DeleteConfirmModal
+          user={deletingUser}
+          onClose={() => setDeletingUser(null)}
+          onConfirm={handleDelete}
+        />
+      )}
+    </div>
+  )
+}
+
+export default Users

+ 938 - 0
webui/src/pages/Views.tsx

@@ -0,0 +1,938 @@
+// View/Schema Designer page (US-033)
+// Visual schema designer for creating custom views
+
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+
+// Field type definitions matching backend
+const FIELD_TYPES = [
+  { value: 'text', label: 'Text', icon: 'T' },
+  { value: 'number', label: 'Number', icon: '#' },
+  { value: 'email', label: 'Email', icon: '@' },
+  { value: 'url', label: 'URL', icon: '🔗' },
+  { value: 'date', label: 'Date', icon: '📅' },
+  { value: 'datetime', label: 'Date & Time', icon: '🕐' },
+  { value: 'boolean', label: 'Boolean', icon: '✓' },
+  { value: 'select', label: 'Select', icon: '▼' },
+  { value: 'reference', label: 'Reference', icon: '→' },
+] as const
+
+const WIDGET_TYPES = [
+  { value: '', label: 'Auto' },
+  { value: 'textarea', label: 'Text Area' },
+  { value: 'select', label: 'Dropdown' },
+  { value: 'radio', label: 'Radio Buttons' },
+  { value: 'checkbox', label: 'Checkbox' },
+  { value: 'color', label: 'Color Picker' },
+] as const
+
+interface SchemaField {
+  _id: string // Client-side ID for React keys
+  name: string
+  type: string
+  label: string
+  description: string
+  required: boolean
+  display_order: number
+  widget: string
+  group: string
+  default_value?: unknown
+  options?: string[]
+  reference_collection?: string
+}
+
+interface ViewSchema {
+  title?: string
+  description?: string
+  layout?: unknown
+  fields?: Omit<SchemaField, '_id'>[] // Full view detail has fields
+  field_count?: number // List view has field_count
+}
+
+interface View {
+  id: string
+  name: string
+  collection_name: string
+  workspace_id: string
+  schema: ViewSchema
+  settings?: Record<string, unknown>
+  created_at: string
+  updated_at: string
+}
+
+interface Collection {
+  name: string
+  schema?: Record<string, unknown>
+  is_system: boolean
+}
+
+// Field editor component
+function FieldEditor({
+  field,
+  onUpdate,
+  onDelete,
+  onMoveUp,
+  onMoveDown,
+  isFirst,
+  isLast,
+  collections,
+}: {
+  field: SchemaField
+  onUpdate: (field: SchemaField) => void
+  onDelete: () => void
+  onMoveUp: () => void
+  onMoveDown: () => void
+  isFirst: boolean
+  isLast: boolean
+  collections: Collection[]
+}) {
+  const [isExpanded, setIsExpanded] = useState(false)
+  const [optionsInput, setOptionsInput] = useState(field.options?.join('\n') || '')
+
+  const handleOptionsChange = (value: string) => {
+    setOptionsInput(value)
+    const options = value.split('\n').filter(Boolean)
+    onUpdate({ ...field, options: options.length > 0 ? options : undefined })
+  }
+
+  return (
+    <div className="rounded-lg border border-gray-200 bg-white">
+      {/* Field header */}
+      <div className="flex items-center gap-2 border-b border-gray-100 p-3">
+        {/* Drag handle placeholder */}
+        <div className="cursor-move text-gray-400">
+          <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
+          </svg>
+        </div>
+
+        {/* Move buttons */}
+        <div className="flex flex-col gap-0.5">
+          <button
+            onClick={onMoveUp}
+            disabled={isFirst}
+            className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
+            title="Move up"
+            type="button"
+          >
+            <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
+            </svg>
+          </button>
+          <button
+            onClick={onMoveDown}
+            disabled={isLast}
+            className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
+            title="Move down"
+            type="button"
+          >
+            <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+            </svg>
+          </button>
+        </div>
+
+        {/* Type badge */}
+        <span className="flex h-6 w-6 items-center justify-center rounded bg-gray-100 text-xs font-medium text-gray-600">
+          {FIELD_TYPES.find((t) => t.value === field.type)?.icon || '?'}
+        </span>
+
+        {/* Field name and label */}
+        <div className="flex-1">
+          <span className="font-mono text-sm font-medium text-gray-900">{field.name}</span>
+          {field.label && field.label !== field.name && (
+            <span className="ml-2 text-sm text-gray-500">({field.label})</span>
+          )}
+        </div>
+
+        {/* Required badge */}
+        {field.required && (
+          <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700">
+            Required
+          </span>
+        )}
+
+        {/* Type selector */}
+        <select
+          value={field.type}
+          onChange={(e) => onUpdate({ ...field, type: e.target.value })}
+          className="rounded border border-gray-200 px-2 py-1 text-sm"
+        >
+          {FIELD_TYPES.map((type) => (
+            <option key={type.value} value={type.value}>
+              {type.label}
+            </option>
+          ))}
+        </select>
+
+        {/* Expand/collapse */}
+        <button
+          onClick={() => setIsExpanded(!isExpanded)}
+          className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+          type="button"
+        >
+          <svg
+            className={`h-5 w-5 transition ${isExpanded ? 'rotate-180' : ''}`}
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+          </svg>
+        </button>
+
+        {/* Delete button */}
+        <button
+          onClick={onDelete}
+          className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
+          title="Delete field"
+          type="button"
+        >
+          <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {/* Field properties (expanded) */}
+      {isExpanded && (
+        <div className="grid gap-4 p-4 sm:grid-cols-2">
+          <Input
+            label="Display Label"
+            value={field.label}
+            onChange={(e) => onUpdate({ ...field, label: e.target.value })}
+            placeholder={field.name}
+          />
+
+          <Input
+            label="Description"
+            value={field.description}
+            onChange={(e) => onUpdate({ ...field, description: e.target.value })}
+            placeholder="Help text for this field"
+          />
+
+          <div>
+            <label className="mb-1.5 block text-sm font-medium text-gray-700">Widget</label>
+            <select
+              value={field.widget}
+              onChange={(e) => onUpdate({ ...field, widget: e.target.value })}
+              className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            >
+              {WIDGET_TYPES.map((w) => (
+                <option key={w.value} value={w.value}>
+                  {w.label}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          <Input
+            label="Field Group"
+            value={field.group}
+            onChange={(e) => onUpdate({ ...field, group: e.target.value })}
+            placeholder="e.g., Basic Info, Contact"
+          />
+
+          {field.type === 'select' && (
+            <div className="sm:col-span-2">
+              <label className="mb-1.5 block text-sm font-medium text-gray-700">
+                Options (one per line)
+              </label>
+              <textarea
+                value={optionsInput}
+                onChange={(e) => handleOptionsChange(e.target.value)}
+                placeholder="Option 1&#10;Option 2&#10;Option 3"
+                rows={3}
+                className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+              />
+            </div>
+          )}
+
+          {field.type === 'reference' && (
+            <div>
+              <label className="mb-1.5 block text-sm font-medium text-gray-700">
+                Reference Collection
+              </label>
+              <select
+                value={field.reference_collection || ''}
+                onChange={(e) => onUpdate({ ...field, reference_collection: e.target.value })}
+                className="w-full rounded-lg border border-gray-300 px-3 py-2"
+              >
+                <option value="">Select collection...</option>
+                {collections
+                  .filter((c) => !c.is_system)
+                  .map((c) => (
+                    <option key={c.name} value={c.name}>
+                      {c.name}
+                    </option>
+                  ))}
+              </select>
+            </div>
+          )}
+
+          <div className="flex items-center gap-4 sm:col-span-2">
+            <label className="flex items-center gap-2">
+              <input
+                type="checkbox"
+                checked={field.required}
+                onChange={(e) => onUpdate({ ...field, required: e.target.checked })}
+                className="h-4 w-4 rounded border-gray-300"
+              />
+              <span className="text-sm text-gray-700">Required field</span>
+            </label>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+// View form modal
+function ViewModal({
+  view,
+  collections,
+  workspaceId,
+  onClose,
+  onSave,
+}: {
+  view: View | null
+  collections: Collection[]
+  workspaceId: string
+  onClose: () => void
+  onSave: () => void
+}) {
+  const isEditing = !!view
+
+  const [name, setName] = useState(view?.name || '')
+  const [collectionName, setCollectionName] = useState(view?.collection_name || '')
+  const [fields, setFields] = useState<SchemaField[]>(() => {
+    if (view?.schema?.fields) {
+      return view.schema.fields.map((f, index) => ({
+        _id: crypto.randomUUID(),
+        name: f.name,
+        type: f.type || 'text',
+        label: f.label || '',
+        description: f.description || '',
+        required: f.required || false,
+        display_order: f.display_order ?? index,
+        widget: f.widget || '',
+        group: f.group || '',
+        default_value: f.default_value,
+        options: f.options,
+        reference_collection: f.reference_collection,
+      }))
+    }
+    return []
+  })
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  // Local state for field name editing to avoid re-renders
+  const [editingFieldId, setEditingFieldId] = useState<string | null>(null)
+  const [editingFieldName, setEditingFieldName] = useState('')
+
+  const sortedFields = useMemo(
+    () => [...fields].sort((a, b) => a.display_order - b.display_order),
+    [fields]
+  )
+
+  const addField = () => {
+    const newName = `field_${fields.length + 1}`
+    setFields([
+      ...fields,
+      {
+        _id: crypto.randomUUID(),
+        name: newName,
+        type: 'text',
+        label: '',
+        description: '',
+        required: false,
+        display_order: fields.length,
+        widget: '',
+        group: '',
+      },
+    ])
+  }
+
+  const updateField = (id: string, updated: SchemaField) => {
+    setFields(fields.map((f) => (f._id === id ? updated : f)))
+  }
+
+  const deleteField = (id: string) => {
+    setFields(fields.filter((f) => f._id !== id))
+  }
+
+  const moveField = (index: number, direction: 'up' | 'down') => {
+    const field = sortedFields[index]
+    const swapWith = sortedFields[direction === 'up' ? index - 1 : index + 1]
+    if (!swapWith) return
+
+    const fieldOrder = field.display_order
+    const swapOrder = swapWith.display_order
+
+    setFields(
+      fields.map((f) => {
+        if (f._id === field._id) return { ...f, display_order: swapOrder }
+        if (f._id === swapWith._id) return { ...f, display_order: fieldOrder }
+        return f
+      })
+    )
+  }
+
+  const startEditingFieldName = (field: SchemaField) => {
+    setEditingFieldId(field._id)
+    setEditingFieldName(field.name)
+  }
+
+  const finishEditingFieldName = () => {
+    if (editingFieldId && editingFieldName.trim()) {
+      const sanitized = editingFieldName.replace(/[^a-zA-Z0-9_]/g, '')
+      if (sanitized && !fields.some((f) => f._id !== editingFieldId && f.name === sanitized)) {
+        setFields(
+          fields.map((f) => (f._id === editingFieldId ? { ...f, name: sanitized } : f))
+        )
+      }
+    }
+    setEditingFieldId(null)
+    setEditingFieldName('')
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+
+    if (!name.trim()) {
+      setError('View name is required')
+      return
+    }
+
+    if (!collectionName) {
+      setError('Please select a collection')
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      // Build schema in backend format
+      const schemaFields = sortedFields.map((f) => {
+        const field: Omit<SchemaField, '_id'> = {
+          name: f.name,
+          type: f.type,
+          label: f.label || f.name,
+          description: f.description,
+          required: f.required,
+          display_order: f.display_order,
+          widget: f.widget,
+          group: f.group,
+        }
+        if (f.default_value !== undefined) {
+          field.default_value = f.default_value
+        }
+        if (f.options && f.options.length > 0) {
+          field.options = f.options
+        }
+        if (f.reference_collection) {
+          field.reference_collection = f.reference_collection
+        }
+        return field
+      })
+
+      const payload = {
+        name,
+        collection_name: collectionName,
+        schema: {
+          fields: schemaFields,
+        },
+      }
+
+      if (isEditing && view) {
+        await apiClient.request(`/workspaces/${workspaceId}/views/${view.id}`, {
+          method: 'PATCH',
+          body: payload,
+        })
+      } else {
+        await apiClient.post(`/workspaces/${workspaceId}/views`, payload)
+      }
+
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save view')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
+      <div className="max-h-[95vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white shadow-xl">
+        <div className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-6 py-4">
+          <h2 className="text-xl font-semibold text-gray-900">
+            {isEditing ? 'Edit View' : 'Create View'}
+          </h2>
+          <button onClick={onClose} className="rounded p-1 text-gray-400 hover:text-gray-600">
+            <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
+
+        <form onSubmit={handleSubmit}>
+          <div className="p-6">
+            {error && (
+              <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+            )}
+
+            <div className="grid gap-4 sm:grid-cols-2">
+              <Input
+                label="View Name"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                placeholder="e.g., Customer List"
+                required
+              />
+
+              <div>
+                <label className="mb-1.5 block text-sm font-medium text-gray-700">
+                  Collection
+                  <span className="ml-1 text-red-500">*</span>
+                </label>
+                <select
+                  value={collectionName}
+                  onChange={(e) => setCollectionName(e.target.value)}
+                  className="w-full rounded-lg border border-gray-300 px-3 py-2"
+                  required
+                  disabled={isEditing}
+                >
+                  <option value="">Select a collection...</option>
+                  {collections
+                    .filter((c) => !c.is_system)
+                    .map((c) => (
+                      <option key={c.name} value={c.name}>
+                        {c.name}
+                      </option>
+                    ))}
+                </select>
+              </div>
+            </div>
+
+            {/* Schema Designer */}
+            <div className="mt-6">
+              <div className="mb-3 flex items-center justify-between">
+                <h3 className="text-lg font-medium text-gray-900">Fields</h3>
+                <Button type="button" size="sm" onClick={addField}>
+                  <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+                  </svg>
+                  Add Field
+                </Button>
+              </div>
+
+              {fields.length === 0 ? (
+                <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
+                  <svg
+                    className="mx-auto h-12 w-12 text-gray-400"
+                    fill="none"
+                    viewBox="0 0 24 24"
+                    stroke="currentColor"
+                  >
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={1}
+                      d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
+                    />
+                  </svg>
+                  <p className="mt-2 text-gray-500">No fields yet. Click "Add Field" to get started.</p>
+                </div>
+              ) : (
+                <div className="space-y-3">
+                  {sortedFields.map((field, index) => (
+                    <div key={field._id}>
+                      <div className="mb-1 flex items-center gap-2">
+                        <span className="text-xs text-gray-500">Field name:</span>
+                        {editingFieldId === field._id ? (
+                          <input
+                            type="text"
+                            value={editingFieldName}
+                            onChange={(e) => setEditingFieldName(e.target.value)}
+                            onBlur={finishEditingFieldName}
+                            onKeyDown={(e) => {
+                              if (e.key === 'Enter') {
+                                e.preventDefault()
+                                finishEditingFieldName()
+                              }
+                              if (e.key === 'Escape') {
+                                setEditingFieldId(null)
+                                setEditingFieldName('')
+                              }
+                            }}
+                            autoFocus
+                            className="max-w-[150px] rounded border border-primary-300 px-2 py-0.5 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-primary-500"
+                          />
+                        ) : (
+                          <button
+                            type="button"
+                            onClick={() => startEditingFieldName(field)}
+                            className="max-w-[150px] truncate rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700 hover:bg-gray-200"
+                          >
+                            {field.name}
+                          </button>
+                        )}
+                      </div>
+                      <FieldEditor
+                        field={field}
+                        onUpdate={(updated) => updateField(field._id, updated)}
+                        onDelete={() => deleteField(field._id)}
+                        onMoveUp={() => moveField(index, 'up')}
+                        onMoveDown={() => moveField(index, 'down')}
+                        isFirst={index === 0}
+                        isLast={index === sortedFields.length - 1}
+                        collections={collections}
+                      />
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+          </div>
+
+          <div className="sticky bottom-0 flex justify-end gap-3 border-t bg-gray-50 px-6 py-4">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading}>
+              {isEditing ? 'Save Changes' : 'Create View'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+// Main Views page component
+function Views() {
+  const { collectionName } = useParams<{ collectionName?: string }>()
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+
+  const [views, setViews] = useState<View[]>([])
+  const [collections, setCollections] = useState<Collection[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState('')
+
+  // Modals
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingView, setEditingView] = useState<View | null>(null)
+  const [deletingView, setDeletingView] = useState<View | null>(null)
+  const [isLoadingViewDetails, setIsLoadingViewDetails] = useState(false)
+
+  // Fetch full view details before editing
+  const loadViewForEditing = useCallback(async (viewId: string) => {
+    if (!currentWorkspace) return
+
+    setIsLoadingViewDetails(true)
+    try {
+      const fullView = await apiClient.get<View>(
+        `/workspaces/${currentWorkspace.id}/views/${viewId}`
+      )
+      setEditingView(fullView)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to load view details')
+    } finally {
+      setIsLoadingViewDetails(false)
+    }
+  }, [currentWorkspace])
+
+  // Fetch collections for the dropdown
+  const fetchCollections = useCallback(async () => {
+    if (!currentWorkspace) return
+
+    try {
+      const response = await apiClient.get<{ collections: Collection[] }>(
+        `/workspaces/${currentWorkspace.id}/collections`
+      )
+      setCollections(response.collections || [])
+    } catch (err) {
+      console.error('Failed to fetch collections:', err)
+    }
+  }, [currentWorkspace])
+
+  // Fetch views
+  const fetchViews = useCallback(async () => {
+    if (!currentWorkspace) {
+      setViews([])
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      let url = `/workspaces/${currentWorkspace.id}/views`
+      if (collectionName) {
+        url += `?collection=${encodeURIComponent(collectionName)}`
+      }
+
+      const response = await apiClient.get<{ views: View[] }>(url)
+      setViews(response.views || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch views')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace, collectionName])
+
+  useEffect(() => {
+    fetchCollections()
+  }, [fetchCollections])
+
+  useEffect(() => {
+    fetchViews()
+  }, [fetchViews])
+
+  const filteredViews = useMemo(() => {
+    const query = searchQuery.toLowerCase()
+    return views.filter(
+      (v) => v.name.toLowerCase().includes(query) || v.collection_name.toLowerCase().includes(query)
+    )
+  }, [views, searchQuery])
+
+  const handleDelete = async () => {
+    if (!deletingView || !currentWorkspace) return
+
+    try {
+      await apiClient.delete(`/workspaces/${currentWorkspace.id}/views/${deletingView.id}`)
+      setDeletingView(null)
+      fetchViews()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete view')
+    }
+  }
+
+  const handleSave = () => {
+    setShowCreateModal(false)
+    setEditingView(null)
+    fetchViews()
+  }
+
+  if (!currentWorkspace) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Views</h1>
+          <p className="mt-1 text-gray-600">Design custom views for your collections</p>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">Please select a workspace first.</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <div className="flex items-center gap-2">
+            {collectionName && (
+              <button onClick={() => navigate('/views')} className="text-gray-400 hover:text-gray-600">
+                <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
+                </svg>
+              </button>
+            )}
+            <h1 className="text-2xl font-bold text-gray-900">Views</h1>
+          </div>
+          <p className="mt-1 text-gray-600">
+            {collectionName
+              ? `Views for ${collectionName}`
+              : `Design custom views in ${currentWorkspace.name}`}
+          </p>
+        </div>
+        <Button onClick={() => setShowCreateModal(true)}>
+          <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          Create View
+        </Button>
+      </div>
+
+      {/* Search */}
+      <div className="max-w-md">
+        <Input
+          type="search"
+          placeholder="Search views..."
+          value={searchQuery}
+          onChange={(e) => setSearchQuery(e.target.value)}
+        />
+      </div>
+
+      {/* Error */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* Views grid */}
+      {!isLoading && !error && (
+        <div>
+          {filteredViews.length === 0 ? (
+            <div className="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
+              <svg
+                className="mx-auto h-12 w-12 text-gray-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={1}
+                  d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
+                />
+              </svg>
+              <p className="mt-4 text-gray-500">{searchQuery ? 'No views match your search' : 'No views yet'}</p>
+              {!searchQuery && (
+                <Button className="mt-4" onClick={() => setShowCreateModal(true)}>
+                  Create Your First View
+                </Button>
+              )}
+            </div>
+          ) : (
+            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+              {filteredViews.map((view) => (
+                <div
+                  key={view.id}
+                  className="group relative rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
+                >
+                  <div className="flex items-start justify-between">
+                    <div>
+                      <h3 className="font-medium text-gray-900">{view.name}</h3>
+                      <p className="mt-1 text-sm text-gray-500">Collection: {view.collection_name}</p>
+                      <p className="mt-1 text-xs text-gray-400">
+                        {view.schema?.fields?.length ?? view.schema?.field_count ?? 0} fields
+                      </p>
+                    </div>
+                    <div className="flex gap-1 opacity-0 transition group-hover:opacity-100">
+                      <button
+                        onClick={() => loadViewForEditing(view.id)}
+                        className="rounded p-1 text-gray-400 hover:bg-primary-50 hover:text-primary-600"
+                        title="Edit"
+                        disabled={isLoadingViewDetails}
+                      >
+                        <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                          <path
+                            strokeLinecap="round"
+                            strokeLinejoin="round"
+                            strokeWidth={2}
+                            d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+                          />
+                        </svg>
+                      </button>
+                      <button
+                        onClick={() => setDeletingView(view)}
+                        className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
+                        title="Delete"
+                      >
+                        <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                          <path
+                            strokeLinecap="round"
+                            strokeLinejoin="round"
+                            strokeWidth={2}
+                            d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+                          />
+                        </svg>
+                      </button>
+                    </div>
+                  </div>
+                  <div className="mt-3 flex flex-wrap gap-1">
+                    {(view.schema?.fields || []).slice(0, 4).map((field) => (
+                      <span key={field.name} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
+                        {field.name}
+                      </span>
+                    ))}
+                    {(view.schema?.fields?.length || 0) > 4 && (
+                      <span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
+                        +{(view.schema?.fields?.length || 0) - 4} more
+                      </span>
+                    )}
+                  </div>
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {(showCreateModal || editingView) && (
+        <ViewModal
+          view={editingView}
+          collections={collections}
+          workspaceId={currentWorkspace.id}
+          onClose={() => {
+            setShowCreateModal(false)
+            setEditingView(null)
+          }}
+          onSave={handleSave}
+        />
+      )}
+
+      {deletingView && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+          <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+            <h2 className="text-xl font-semibold text-gray-900">Delete View</h2>
+            <p className="mt-2 text-gray-600">
+              Are you sure you want to delete the view "{deletingView.name}"? This action cannot be undone.
+            </p>
+            <div className="mt-6 flex justify-end gap-3">
+              <Button variant="secondary" onClick={() => setDeletingView(null)}>
+                Cancel
+              </Button>
+              <Button variant="danger" onClick={handleDelete}>
+                Delete View
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default Views

+ 332 - 0
webui/src/pages/Workspaces.tsx

@@ -0,0 +1,332 @@
+// Workspaces management page (US-029)
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import type { Workspace } from '@/types'
+
+interface WorkspaceFormData {
+  name: string
+  settings: Record<string, unknown>
+}
+
+function WorkspaceModal({
+  workspace,
+  onClose,
+  onSave,
+}: {
+  workspace: Workspace | null
+  onClose: () => void
+  onSave: () => void
+}) {
+  const [formData, setFormData] = useState<WorkspaceFormData>({
+    name: workspace?.name || '',
+    settings: workspace?.settings || {},
+  })
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      if (workspace) {
+        await apiClient.patch(`/workspaces/${workspace.id}`, formData)
+      } else {
+        await apiClient.post('/workspaces', formData)
+      }
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save workspace')
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">
+          {workspace ? 'Edit Workspace' : 'Create Workspace'}
+        </h2>
+
+        <form onSubmit={handleSubmit} className="mt-4 space-y-4">
+          {error && (
+            <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+          )}
+
+          <Input
+            label="Workspace Name"
+            name="name"
+            value={formData.name}
+            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+            placeholder="Enter workspace name"
+            required
+            autoFocus
+          />
+
+          <div className="flex justify-end gap-3 pt-4">
+            <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+              Cancel
+            </Button>
+            <Button type="submit" isLoading={isLoading}>
+              {workspace ? 'Save Changes' : 'Create Workspace'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+function DeleteConfirmModal({
+  workspace,
+  onClose,
+  onConfirm,
+}: {
+  workspace: Workspace
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleDelete = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.delete(`/workspaces/${workspace.id}`)
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete workspace')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Delete Workspace</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to delete <strong>{workspace.name}</strong>? This action cannot be
+          undone.
+        </p>
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
+            Delete Workspace
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function Workspaces() {
+  const { fetchWorkspaces: refreshWorkspaces } = useWorkspace()
+  const [workspaces, setWorkspaces] = useState<Workspace[]>([])
+  const [filteredWorkspaces, setFilteredWorkspaces] = useState<Workspace[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState('')
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingWorkspace, setEditingWorkspace] = useState<Workspace | null>(null)
+  const [deletingWorkspace, setDeletingWorkspace] = useState<Workspace | null>(null)
+
+  const fetchWorkspaces = useCallback(async () => {
+    setIsLoading(true)
+    setError(null)
+    try {
+      const response = await apiClient.get<{ workspaces: Workspace[] }>('/workspaces')
+      setWorkspaces(response.workspaces || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch workspaces')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [])
+
+  useEffect(() => {
+    fetchWorkspaces()
+  }, [fetchWorkspaces])
+
+  useEffect(() => {
+    const query = searchQuery.toLowerCase()
+    setFilteredWorkspaces(
+      workspaces.filter(
+        (w) => w.name.toLowerCase().includes(query) || w.id.toLowerCase().includes(query)
+      )
+    )
+  }, [workspaces, searchQuery])
+
+  const handleSave = () => {
+    setShowCreateModal(false)
+    setEditingWorkspace(null)
+    fetchWorkspaces()
+    refreshWorkspaces()
+  }
+
+  const handleDelete = () => {
+    setDeletingWorkspace(null)
+    fetchWorkspaces()
+    refreshWorkspaces()
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Workspaces</h1>
+          <p className="mt-1 text-gray-600">Manage your organization's workspaces</p>
+        </div>
+        <Button onClick={() => setShowCreateModal(true)}>
+          <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          Create Workspace
+        </Button>
+      </div>
+
+      {/* Search */}
+      <div className="max-w-md">
+        <Input
+          type="search"
+          placeholder="Search workspaces..."
+          value={searchQuery}
+          onChange={(e) => setSearchQuery(e.target.value)}
+        />
+      </div>
+
+      {/* Error state */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading state */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* Workspace list */}
+      {!isLoading && !error && (
+        <div className="overflow-hidden rounded-lg bg-white shadow">
+          {filteredWorkspaces.length === 0 ? (
+            <div className="px-6 py-12 text-center">
+              <p className="text-gray-500">
+                {searchQuery ? 'No workspaces match your search' : 'No workspaces found'}
+              </p>
+            </div>
+          ) : (
+            <table className="min-w-full divide-y divide-gray-200">
+              <thead className="bg-gray-50">
+                <tr>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Name
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    ID
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Created
+                  </th>
+                  <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Actions
+                  </th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-gray-200 bg-white">
+                {filteredWorkspaces.map((workspace) => (
+                  <tr key={workspace.id} className="hover:bg-gray-50">
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <div className="font-medium text-gray-900">{workspace.name}</div>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4">
+                      <code className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600">
+                        {workspace.id.slice(0, 8)}...
+                      </code>
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                      {new Date(workspace.created_at).toLocaleDateString()}
+                    </td>
+                    <td className="whitespace-nowrap px-6 py-4 text-right">
+                      <div className="flex justify-end gap-2">
+                        <button
+                          onClick={() => setEditingWorkspace(workspace)}
+                          className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
+                        >
+                          Edit
+                        </button>
+                        <button
+                          onClick={() => setDeletingWorkspace(workspace)}
+                          className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
+                        >
+                          Delete
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {showCreateModal && (
+        <WorkspaceModal
+          workspace={null}
+          onClose={() => setShowCreateModal(false)}
+          onSave={handleSave}
+        />
+      )}
+      {editingWorkspace && (
+        <WorkspaceModal
+          workspace={editingWorkspace}
+          onClose={() => setEditingWorkspace(null)}
+          onSave={handleSave}
+        />
+      )}
+      {deletingWorkspace && (
+        <DeleteConfirmModal
+          workspace={deletingWorkspace}
+          onClose={() => setDeletingWorkspace(null)}
+          onConfirm={handleDelete}
+        />
+      )}
+    </div>
+  )
+}
+
+export default Workspaces

+ 189 - 0
webui/src/types/index.ts

@@ -0,0 +1,189 @@
+// Common types for the SmartBotic CRM WebUI
+
+export interface User {
+  id: string
+  email: string
+  name: string
+  is_superadmin?: boolean
+  created_at: string
+  updated_at: string
+}
+
+export interface Workspace {
+  id: string
+  name: string
+  settings: Record<string, unknown>
+  created_at: string
+  updated_at: string
+}
+
+export interface AuthTokens {
+  access_token: string
+  refresh_token: string
+  token_type: string
+  expires_in: number
+}
+
+export interface ApiError {
+  error: string
+}
+
+// RBAC Types
+
+export interface Group {
+  id: string
+  workspace_id: string
+  name: string
+  permissions: string[]
+  is_system: boolean
+  parent_group_id?: string
+  created_at: string
+  updated_at: string
+  deleted_at?: string
+}
+
+export interface CreateGroupRequest {
+  workspace_id: string
+  name: string
+  permissions: string[]
+  is_system?: boolean
+  parent_group_id?: string
+}
+
+export interface UpdateGroupRequest {
+  name?: string
+  permissions?: string[]
+  parent_group_id?: string
+}
+
+// Field-level permissions for collections
+export interface FieldPermission {
+  field_name: string
+  read_groups: string[]
+  write_groups: string[]
+}
+
+export interface CollectionSettings {
+  schema: string
+  is_system: boolean
+  encrypted_fields: string[]
+  ttl_seconds: number
+  field_permissions: FieldPermission[]
+}
+
+export interface Collection {
+  name: string
+  workspace_id: string
+  document_count: number
+  size_bytes: number
+  created_at: string
+  updated_at: string
+  settings: CollectionSettings
+}
+
+// Permission scope types
+export type PermissionScope = 'system' | 'workspace' | 'collection' | 'field'
+
+// Permission string format: <scope>:<resource>:<action>[:<qualifier>]
+export interface ParsedPermission {
+  scope: PermissionScope
+  resource: string
+  action: string
+  qualifier?: string
+}
+
+// Standard permission actions by scope
+export const SystemActions = ['read', 'create', 'update', 'delete'] as const
+export const WorkspaceActions = ['admin', 'member', 'read', 'manage_members', 'manage_groups', 'manage_collections', 'manage_views'] as const
+export const CollectionActions = ['read_all', 'read_own', 'write_all', 'write_own', 'create', 'delete_all', 'delete_own', 'manage'] as const
+export const FieldActions = ['read', 'write'] as const
+
+// System resources
+export const SystemResources = ['login', 'users', 'groups', 'system_groups', 'workspaces', 'collections', 'api_keys', 'memberships', 'views'] as const
+
+export type SystemAction = typeof SystemActions[number]
+export type WorkspaceAction = typeof WorkspaceActions[number]
+export type CollectionAction = typeof CollectionActions[number]
+export type FieldAction = typeof FieldActions[number]
+export type SystemResource = typeof SystemResources[number]
+
+// Helper functions for building permission strings
+export function buildSystemPermission(resource: SystemResource, action: SystemAction): string {
+  return `system:${resource}:${action}`
+}
+
+export function buildWorkspacePermission(workspaceId: string, action: WorkspaceAction): string {
+  return `workspace:${workspaceId}:${action}`
+}
+
+export function buildCollectionPermission(workspaceId: string, collection: string, action: CollectionAction): string {
+  return `collection:${workspaceId}:${collection}:${action}`
+}
+
+export function buildFieldPermission(workspaceId: string, collection: string, field: string, action: FieldAction): string {
+  return `field:${workspaceId}:${collection}:${field}:${action}`
+}
+
+// Parse a permission string into its components
+export function parsePermission(permission: string): ParsedPermission | null {
+  const parts = permission.split(':')
+  if (parts.length < 3) return null
+
+  const scope = parts[0] as PermissionScope
+  if (!['system', 'workspace', 'collection', 'field'].includes(scope)) return null
+
+  return {
+    scope,
+    resource: parts[1],
+    action: parts[2],
+    qualifier: parts[3]
+  }
+}
+
+// Check if a permission matches a pattern (with wildcard support)
+export function matchesPermission(required: string, granted: string): boolean {
+  // Wildcard matches everything
+  if (granted === '*') return true
+
+  // Exact match
+  if (required === granted) return true
+
+  const requiredParts = required.split(':')
+  const grantedParts = granted.split(':')
+
+  // Compare part by part
+  for (let i = 0; i < requiredParts.length; i++) {
+    if (i >= grantedParts.length) {
+      // Check if last granted part is wildcard
+      return grantedParts[grantedParts.length - 1] === '*'
+    }
+    if (grantedParts[i] === '*') continue
+    if (grantedParts[i] !== requiredParts[i]) return false
+  }
+
+  return true
+}
+
+// Document with ownership tracking
+export interface Document {
+  id: string
+  collection: string
+  workspace_id: string
+  data: Record<string, unknown>
+  created_at: string
+  updated_at: string
+  version: number
+  _created_by?: string
+  _updated_by?: string
+}
+
+// Membership types
+export interface Membership {
+  id: string
+  user_id: string
+  workspace_id: string
+  groups: string[]
+  role?: string
+  created_at: string
+  updated_at: string
+}

+ 1 - 0
webui/src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 27 - 0
webui/tailwind.config.js

@@ -0,0 +1,27 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  content: [
+    "./index.html",
+    "./src/**/*.{js,ts,jsx,tsx}",
+  ],
+  theme: {
+    extend: {
+      colors: {
+        primary: {
+          50: '#eff6ff',
+          100: '#dbeafe',
+          200: '#bfdbfe',
+          300: '#93c5fd',
+          400: '#60a5fa',
+          500: '#3b82f6',
+          600: '#2563eb',
+          700: '#1d4ed8',
+          800: '#1e40af',
+          900: '#1e3a8a',
+          950: '#172554',
+        },
+      },
+    },
+  },
+  plugins: [],
+}

+ 32 - 0
webui/tsconfig.json

@@ -0,0 +1,32 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true,
+
+    /* Path aliases */
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": ["src"]
+}

+ 24 - 0
webui/tsconfig.node.json

@@ -0,0 +1,24 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "ES2022",
+    "lib": ["ES2023"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 30 - 0
webui/vite.config.ts

@@ -0,0 +1,30 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [react()],
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, './src'),
+    },
+  },
+  build: {
+    outDir: 'dist',
+    sourcemap: true,
+  },
+  server: {
+    port: 3200,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:18080',
+        changeOrigin: true,
+      },
+      '/ws': {
+        target: 'ws://localhost:18081',
+        ws: true,
+      },
+    },
+  },
+})