Ver código fonte

build: optimize dependencies and add page builder foundation

Build optimizations:
- Use system packages for spdlog, nlohmann_json, gRPC, protobuf
- Replace libsodium with OpenSSL 3.x for crypto (scrypt, SHA256, RAND)
- Remove GoogleTest and tests subdirectory
- Update jwt-cpp to v0.7.1
- Reduce build targets from ~2900 to 40

Page builder foundation:
- Add PageService with CRUD operations and sharing support
- Add page permission system (create, read_all/own, write_all/own, delete_all/own)
- Add PageBuilder components (EditorCanvas, ComponentPalette, PropertyPanel)
- Add PageRenderer for displaying pages with component filtering
- Add dynamic sidebar pages based on RBAC permissions
- Add usePermissions and useSidebarPages hooks

Required system packages:
libspdlog-dev nlohmann-json3-dev libgrpc++-dev libprotobuf-dev
protobuf-compiler-grpc libwebsockets-dev libssl-dev
Fszontagh 6 meses atrás
pai
commit
f090c8f381
32 arquivos alterados com 5394 adições e 154 exclusões
  1. 38 110
      CMakeLists.txt
  2. 0 1
      database/CMakeLists.txt
  3. 7 9
      proto/CMakeLists.txt
  4. 1 2
      webserver/CMakeLists.txt
  5. 1 1
      webserver/include/smartbotic/webserver/auth_service.hpp
  6. 60 0
      webserver/include/smartbotic/webserver/authorization_service.hpp
  7. 16 0
      webserver/include/smartbotic/webserver/http_server.hpp
  8. 164 0
      webserver/include/smartbotic/webserver/page_service.hpp
  9. 41 0
      webserver/include/smartbotic/webserver/permissions.hpp
  10. 27 7
      webserver/src/api_key_service.cpp
  11. 134 19
      webserver/src/auth_service.cpp
  12. 145 0
      webserver/src/authorization_service.cpp
  13. 835 0
      webserver/src/http_server.cpp
  14. 556 0
      webserver/src/page_service.cpp
  15. 30 0
      webserver/src/permissions.cpp
  16. 6 0
      webui/src/App.tsx
  17. 132 0
      webui/src/components/PageBuilder/ComponentPalette.tsx
  18. 442 0
      webui/src/components/PageBuilder/PropertyPanel.tsx
  19. 394 0
      webui/src/components/PageBuilder/index.tsx
  20. 413 0
      webui/src/components/PageRenderer/DocumentListRenderer.tsx
  21. 59 0
      webui/src/components/PageRenderer/HeaderRenderer.tsx
  22. 20 0
      webui/src/components/PageRenderer/SpacerRenderer.tsx
  23. 215 0
      webui/src/components/PageRenderer/StatCardRenderer.tsx
  24. 27 0
      webui/src/components/PageRenderer/TextBlockRenderer.tsx
  25. 245 0
      webui/src/components/PageRenderer/index.tsx
  26. 2 1
      webui/src/components/PermissionEditor.tsx
  27. 131 0
      webui/src/components/layout/Sidebar.tsx
  28. 260 0
      webui/src/hooks/usePermissions.ts
  29. 72 0
      webui/src/hooks/useSidebarPages.ts
  30. 174 0
      webui/src/pages/PageDisplay.tsx
  31. 626 0
      webui/src/pages/Pages.tsx
  32. 121 4
      webui/src/types/index.ts

+ 38 - 110
CMakeLists.txt

@@ -16,7 +16,6 @@ set(CMAKE_CXX_EXTENSIONS OFF)
 set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
 
 # Build options
-option(BUILD_TESTING "Build tests" ON)
 option(ENABLE_SANITIZERS "Enable address and undefined behavior sanitizers" OFF)
 
 # Compiler warnings - applied only to project targets, not dependencies
@@ -35,63 +34,32 @@ if(ENABLE_SANITIZERS)
     add_link_options(-fsanitize=address,undefined)
 endif()
 
-# Include FetchContent module
-include(FetchContent)
-
 # ============================================================================
-# External Dependencies via FetchContent
+# System Dependencies
+# Required packages: libspdlog-dev nlohmann-json3-dev
 # ============================================================================
+find_package(spdlog REQUIRED)
+find_package(nlohmann_json REQUIRED)
 
-# spdlog - Fast C++ logging library
-FetchContent_Declare(
-    spdlog
-    GIT_REPOSITORY https://github.com/gabime/spdlog.git
-    GIT_TAG v1.13.0
-)
-
-# nlohmann_json - JSON for Modern C++
-FetchContent_Declare(
-    nlohmann_json
-    GIT_REPOSITORY https://github.com/nlohmann/json.git
-    GIT_TAG v3.11.3
-)
+# ============================================================================
+# FetchContent Dependencies (latest versions)
+# ============================================================================
+include(FetchContent)
 
-# cpp-httplib - Header-only HTTP library
+# cpp-httplib - Header-only HTTP library (always use latest)
 FetchContent_Declare(
     httplib
     GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
     GIT_TAG v0.30.1
 )
 
-# jwt-cpp - Header-only JWT library
+# jwt-cpp - Header-only JWT library (no compatible system package)
 FetchContent_Declare(
     jwt-cpp
     GIT_REPOSITORY https://github.com/Thalhammer/jwt-cpp.git
-    GIT_TAG v0.7.0
-)
-
-# gRPC and Protobuf
-FetchContent_Declare(
-    grpc
-    GIT_REPOSITORY https://github.com/grpc/grpc.git
-    GIT_TAG v1.60.0
-    GIT_SHALLOW TRUE
-)
-
-# libsodium - Cryptography library
-FetchContent_Declare(
-    libsodium
-    GIT_REPOSITORY https://github.com/jedisct1/libsodium.git
-    GIT_TAG 1.0.19
+    GIT_TAG v0.7.1
 )
 
-# Make dependencies available
-message(STATUS "Fetching spdlog...")
-FetchContent_MakeAvailable(spdlog)
-
-message(STATUS "Fetching nlohmann_json...")
-FetchContent_MakeAvailable(nlohmann_json)
-
 message(STATUS "Fetching cpp-httplib...")
 set(HTTPLIB_REQUIRE_OPENSSL ON CACHE BOOL "" FORCE)
 set(HTTPLIB_COMPILE OFF CACHE BOOL "" FORCE)  # Header-only mode
@@ -103,73 +71,38 @@ set(JWT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
 set(JWT_EXTERNAL_PICOJSON OFF CACHE BOOL "" FORCE)  # Use bundled picojson
 FetchContent_MakeAvailable(jwt-cpp)
 
-message(STATUS "Fetching gRPC (this may take a while)...")
-set(gRPC_BUILD_TESTS OFF CACHE BOOL "" FORCE)
-set(gRPC_INSTALL OFF CACHE BOOL "" FORCE)
-set(gRPC_BUILD_GRPC_CSHARP_PLUGIN OFF CACHE BOOL "" FORCE)
-set(gRPC_BUILD_GRPC_NODE_PLUGIN OFF CACHE BOOL "" FORCE)
-set(gRPC_BUILD_GRPC_OBJECTIVE_C_PLUGIN OFF CACHE BOOL "" FORCE)
-set(gRPC_BUILD_GRPC_PHP_PLUGIN OFF CACHE BOOL "" FORCE)
-set(gRPC_BUILD_GRPC_PYTHON_PLUGIN OFF CACHE BOOL "" FORCE)
-set(gRPC_BUILD_GRPC_RUBY_PLUGIN OFF CACHE BOOL "" FORCE)
-set(ABSL_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
-set(ABSL_PROPAGATE_CXX_STD ON CACHE BOOL "" FORCE)
-set(protobuf_INSTALL OFF CACHE BOOL "" FORCE)
-set(utf8_range_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
-# Disable -Werror for gRPC and its dependencies (abseil uses __int128 which triggers -Wpedantic)
-set(CMAKE_CXX_FLAGS_BACKUP "${CMAKE_CXX_FLAGS}")
-set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-pedantic -Wno-overflow -Wno-error")
-FetchContent_MakeAvailable(grpc)
-set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS_BACKUP}")
+# ============================================================================
+# System gRPC and Protobuf (fast builds using system packages)
+# Required packages: libgrpc++-dev libprotobuf-dev protobuf-compiler-grpc
+# ============================================================================
+message(STATUS "Using system gRPC and Protobuf...")
+find_package(Protobuf REQUIRED)
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GRPC REQUIRED IMPORTED_TARGET grpc++)
+
+# Find reflection library (no pkg-config file in Ubuntu package)
+find_library(GRPC_REFLECTION_LIB grpc++_reflection REQUIRED)
+message(STATUS "  grpc++_reflection: ${GRPC_REFLECTION_LIB}")
+
+# Create imported target for reflection library
+add_library(grpc_reflection SHARED IMPORTED)
+set_target_properties(grpc_reflection PROPERTIES
+    IMPORTED_LOCATION ${GRPC_REFLECTION_LIB}
+    INTERFACE_LINK_LIBRARIES PkgConfig::GRPC
+)
+
+# Find protoc and grpc_cpp_plugin executables
+find_program(PROTOC_EXECUTABLE protoc REQUIRED)
+find_program(GRPC_CPP_PLUGIN grpc_cpp_plugin REQUIRED)
+message(STATUS "  protoc: ${PROTOC_EXECUTABLE}")
+message(STATUS "  grpc_cpp_plugin: ${GRPC_CPP_PLUGIN}")
 
 # libwebsockets via pkg-config (system library)
-find_package(PkgConfig REQUIRED)
 pkg_check_modules(LWS REQUIRED IMPORTED_TARGET libwebsockets)
 
-# OpenSSL for HTTPS support
+# OpenSSL for HTTPS support and crypto operations (replaces libsodium)
 find_package(OpenSSL REQUIRED)
-
-# libsodium requires special handling as it uses autotools
-FetchContent_GetProperties(libsodium)
-if(NOT libsodium_POPULATED)
-    FetchContent_Populate(libsodium)
-
-    # Build libsodium using autotools
-    execute_process(
-        COMMAND ${libsodium_SOURCE_DIR}/configure
-                --prefix=${libsodium_BINARY_DIR}/install
-                --disable-shared
-                --enable-static
-        WORKING_DIRECTORY ${libsodium_SOURCE_DIR}
-        RESULT_VARIABLE SODIUM_CONFIGURE_RESULT
-    )
-
-    if(NOT SODIUM_CONFIGURE_RESULT EQUAL 0)
-        message(WARNING "libsodium configure failed, will try system libsodium")
-        find_package(PkgConfig REQUIRED)
-        pkg_check_modules(SODIUM REQUIRED libsodium)
-    else()
-        execute_process(
-            COMMAND make -j${CMAKE_BUILD_PARALLEL_LEVEL}
-            WORKING_DIRECTORY ${libsodium_SOURCE_DIR}
-        )
-        execute_process(
-            COMMAND make install
-            WORKING_DIRECTORY ${libsodium_SOURCE_DIR}
-        )
-
-        set(SODIUM_INCLUDE_DIRS ${libsodium_BINARY_DIR}/install/include)
-        set(SODIUM_LIBRARY_DIRS ${libsodium_BINARY_DIR}/install/lib)
-        set(SODIUM_LIBRARIES sodium)
-    endif()
-endif()
-
-# Create imported target for libsodium
-add_library(sodium STATIC IMPORTED GLOBAL)
-set_target_properties(sodium PROPERTIES
-    IMPORTED_LOCATION ${SODIUM_LIBRARY_DIRS}/libsodium.a
-    INTERFACE_INCLUDE_DIRECTORIES ${SODIUM_INCLUDE_DIRS}
-)
+message(STATUS "  OpenSSL version: ${OPENSSL_VERSION}")
 
 # ============================================================================
 # Project Subdirectories
@@ -181,10 +114,6 @@ add_subdirectory(database)
 add_subdirectory(webserver)
 add_subdirectory(webui)
 
-if(BUILD_TESTING)
-    add_subdirectory(tests)
-endif()
-
 # ============================================================================
 # Summary
 # ============================================================================
@@ -196,6 +125,5 @@ message(STATUS "Version:           ${PROJECT_VERSION}")
 message(STATUS "C++ Standard:      ${CMAKE_CXX_STANDARD}")
 message(STATUS "Build Type:        ${CMAKE_BUILD_TYPE}")
 message(STATUS "Compiler:          ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
-message(STATUS "Build Testing:     ${BUILD_TESTING}")
 message(STATUS "Sanitizers:        ${ENABLE_SANITIZERS}")
 message(STATUS "")

+ 0 - 1
database/CMakeLists.txt

@@ -80,5 +80,4 @@ target_compile_options(smartbotic-crm-database PRIVATE ${SMARTBOTIC_GRPC_WARNING
 target_link_libraries(smartbotic-crm-database
     PRIVATE
         smartbotic::database_grpc
-        sodium
 )

+ 7 - 9
proto/CMakeLists.txt

@@ -1,8 +1,5 @@
 # Protocol Buffers and gRPC service definitions
-
-# Get protobuf and gRPC plugin locations
-set(_PROTOBUF_PROTOC $<TARGET_FILE:protobuf::protoc>)
-set(_GRPC_CPP_PLUGIN_EXECUTABLE $<TARGET_FILE:grpc_cpp_plugin>)
+# Uses system-installed protobuf and gRPC packages
 
 # Proto files directory
 set(PROTO_DIR ${CMAKE_CURRENT_SOURCE_DIR}/proto)
@@ -37,10 +34,10 @@ foreach(PROTO_FILE ${PROTO_FILES})
             "${PROTO_GEN_DIR}/${PROTO_NAME}.pb.h"
             "${PROTO_GEN_DIR}/${PROTO_NAME}.grpc.pb.cc"
             "${PROTO_GEN_DIR}/${PROTO_NAME}.grpc.pb.h"
-        COMMAND ${_PROTOBUF_PROTOC}
+        COMMAND ${PROTOC_EXECUTABLE}
             --cpp_out=${PROTO_GEN_DIR}
             --grpc_out=${PROTO_GEN_DIR}
-            --plugin=protoc-gen-grpc=${_GRPC_CPP_PLUGIN_EXECUTABLE}
+            --plugin=protoc-gen-grpc=${GRPC_CPP_PLUGIN}
             -I${PROTO_DIR}
             ${PROTO_FILE}
         DEPENDS ${PROTO_FILE}
@@ -60,11 +57,12 @@ target_include_directories(smartbotic_proto
         $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
 )
 
+# Link against system gRPC and protobuf
 target_link_libraries(smartbotic_proto
     PUBLIC
-        grpc++
-        grpc++_reflection
-        libprotobuf
+        PkgConfig::GRPC
+        grpc_reflection
+        protobuf::libprotobuf
 )
 
 add_library(smartbotic::proto ALIAS smartbotic_proto)

+ 1 - 2
webserver/CMakeLists.txt

@@ -26,6 +26,7 @@ add_library(smartbotic_webserver STATIC
     src/document_service.cpp
     src/ws_handler.cpp
     src/view_service.cpp
+    src/page_service.cpp
     src/permissions.cpp
     src/authorization_service.cpp
 )
@@ -49,7 +50,6 @@ target_link_libraries(smartbotic_webserver
         OpenSSL::SSL
         OpenSSL::Crypto
         jwt-cpp::jwt-cpp
-        sodium
 )
 
 add_library(smartbotic::webserver ALIAS smartbotic_webserver)
@@ -73,5 +73,4 @@ target_link_libraries(smartbotic-crm-webserver
         smartbotic::proto
         spdlog::spdlog
         nlohmann_json::nlohmann_json
-        sodium
 )

+ 1 - 1
webserver/include/smartbotic/webserver/auth_service.hpp

@@ -87,7 +87,7 @@ public:
     /// @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)
+    /// Hash a password using scrypt (via OpenSSL 3.x)
     /// @param password The plain text password
     /// @return The hashed password
     [[nodiscard]] static auto HashPassword(const std::string& password) -> std::string;

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

@@ -209,6 +209,66 @@ public:
     [[nodiscard]] auto CanManageWorkspaceGroups(const AuthUser& user,
                                                  const std::string& workspace_id) -> bool;
 
+    // =========================================================================
+    // Page Permissions (Ownership-Based)
+    // =========================================================================
+
+    /// Check if user can create pages in a workspace
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @return True if user has page:ws:create permission
+    [[nodiscard]] auto CanCreatePage(const AuthUser& user,
+                                      const std::string& workspace_id) -> bool;
+
+    /// Check if user can view a page
+    /// User can view if: has read_all, OR owns the page, OR page is shared with user's groups
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param page_owner User ID of page owner (created_by)
+    /// @param shared_with_groups Groups the page is shared with
+    /// @return True if user can view this page
+    [[nodiscard]] auto CanViewPage(const AuthUser& user,
+                                    const std::string& workspace_id,
+                                    const std::string& page_owner,
+                                    const std::vector<std::string>& shared_with_groups) -> bool;
+
+    /// Check if user can edit a page
+    /// User can edit if: has write_all, OR (owns the page AND has write_own)
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param page_owner User ID of page owner (created_by)
+    /// @return True if user can edit this page
+    [[nodiscard]] auto CanEditPage(const AuthUser& user,
+                                    const std::string& workspace_id,
+                                    const std::string& page_owner) -> bool;
+
+    /// Check if user can delete a page
+    /// User can delete if: has delete_all, OR (owns the page AND has delete_own)
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param page_owner User ID of page owner (created_by)
+    /// @return True if user can delete this page
+    [[nodiscard]] auto CanDeletePage(const AuthUser& user,
+                                      const std::string& workspace_id,
+                                      const std::string& page_owner) -> bool;
+
+    /// Check if user can share a page (modify shared_with_groups)
+    /// User can share if: owns the page, OR has write_all
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @param page_owner User ID of page owner (created_by)
+    /// @return True if user can share this page
+    [[nodiscard]] auto CanSharePage(const AuthUser& user,
+                                     const std::string& workspace_id,
+                                     const std::string& page_owner) -> bool;
+
+    /// Check if user can manage all pages in workspace (full page management)
+    /// @param user The authenticated user
+    /// @param workspace_id Workspace ID
+    /// @return True if user has workspace:ws:manage_pages permission
+    [[nodiscard]] auto CanManageWorkspacePages(const AuthUser& user,
+                                                const std::string& workspace_id) -> bool;
+
     // =========================================================================
     // Group Visibility
     // =========================================================================

+ 16 - 0
webserver/include/smartbotic/webserver/http_server.hpp

@@ -22,6 +22,7 @@
 #include "smartbotic/webserver/document_service.hpp"
 #include "smartbotic/webserver/group_service.hpp"
 #include "smartbotic/webserver/membership_service.hpp"
+#include "smartbotic/webserver/page_service.hpp"
 #include "smartbotic/webserver/user_service.hpp"
 #include "smartbotic/webserver/view_service.hpp"
 #include "smartbotic/webserver/workspace_service.hpp"
@@ -183,6 +184,9 @@ public:
     /// Get the view service
     [[nodiscard]] auto GetViewService() -> ViewService& { return *viewService_; }
 
+    /// Get the page service
+    [[nodiscard]] auto GetPageService() -> PageService& { return *pageService_; }
+
     /// Get the authorization service
     [[nodiscard]] auto GetAuthorizationService() -> AuthorizationService& { return *authorizationService_; }
 
@@ -207,6 +211,7 @@ private:
     void SetupCollectionRoutes();
     void SetupDocumentRoutes();
     void SetupViewRoutes();
+    void SetupPageRoutes();
     void RunHttpServer();
     [[nodiscard]] auto ConnectToDatabase() -> bool;
     [[nodiscard]] auto InitializeServices() -> bool;
@@ -276,6 +281,16 @@ private:
     void HandleUpdateView(const httplib::Request& req, httplib::Response& res);
     void HandleDeleteView(const httplib::Request& req, httplib::Response& res);
 
+    // Page route handlers
+    void HandleCreatePage(const httplib::Request& req, httplib::Response& res);
+    void HandleListPages(const httplib::Request& req, httplib::Response& res);
+    void HandleListSidebarPages(const httplib::Request& req, httplib::Response& res);
+    void HandleGetPageBySlug(const httplib::Request& req, httplib::Response& res);
+    void HandleGetPage(const httplib::Request& req, httplib::Response& res);
+    void HandleUpdatePage(const httplib::Request& req, httplib::Response& res);
+    void HandleUpdatePageSharing(const httplib::Request& req, httplib::Response& res);
+    void HandleDeletePage(const httplib::Request& req, httplib::Response& res);
+
     // Authentication middleware helper
     [[nodiscard]] auto AuthenticateRequest(const httplib::Request& req) -> std::optional<AuthUser>;
 
@@ -303,6 +318,7 @@ private:
     std::unique_ptr<CollectionService> collectionService_;
     std::unique_ptr<DocumentService> documentService_;
     std::unique_ptr<ViewService> viewService_;
+    std::unique_ptr<PageService> pageService_;
     std::unique_ptr<AuthorizationService> authorizationService_;
     std::unique_ptr<WsHandler> wsHandler_;
 

+ 164 - 0
webserver/include/smartbotic/webserver/page_service.hpp

@@ -0,0 +1,164 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+/// Grid position for a layout component
+struct GridPosition {
+    int x = 0;       // Column start (0-11)
+    int y = 0;       // Row position
+    int width = 12;  // Columns to span (1-12)
+    int height = 1;  // Row height units
+};
+
+/// A component in the page layout
+struct LayoutComponent {
+    std::string id;           // UUID
+    std::string type;         // 'header', 'document-list', 'stat-card', etc.
+    GridPosition position;    // Grid position
+    nlohmann::json config;    // Type-specific configuration
+};
+
+/// Page layout containing all components
+struct PageLayout {
+    int version = 1;
+    int grid_columns = 12;
+    std::vector<LayoutComponent> components;
+};
+
+/// Page settings
+struct PageSettings {
+    bool show_in_sidebar = true;
+    std::string icon;
+    int menu_order = 0;
+};
+
+/// Page information
+struct PageInfo {
+    std::string id;
+    std::string workspace_id;
+    std::string name;
+    std::string slug;
+    PageLayout layout;
+    PageSettings settings;
+    std::string created_at;
+    std::string updated_at;
+    std::string created_by;                       // Owner - user ID
+    std::vector<std::string> shared_with_groups;  // Groups that can view this page
+};
+
+/// Request to create a page
+struct CreatePageRequest {
+    std::string workspace_id;
+    std::string name;
+    std::string slug;
+    PageLayout layout;
+    PageSettings settings;
+    std::string created_by;  // Owner - user ID
+};
+
+/// Request to update a page
+struct UpdatePageRequest {
+    std::string workspace_id;
+    std::string id;
+    std::optional<std::string> name;
+    std::optional<std::string> slug;
+    std::optional<PageLayout> layout;
+    std::optional<PageSettings> settings;
+    std::optional<std::vector<std::string>> shared_with_groups;
+};
+
+/// Result of a page operation
+struct PageResult {
+    bool success = false;
+    std::string error;
+    std::optional<PageInfo> page;
+};
+
+/// Result of listing pages
+struct PageListResult {
+    bool success = false;
+    std::string error;
+    std::vector<PageInfo> pages;
+};
+
+/// Service for managing pages
+class PageService {
+public:
+    explicit PageService(DatabaseClient& db_client);
+    ~PageService();
+
+    // Disable copy
+    PageService(const PageService&) = delete;
+    auto operator=(const PageService&) -> PageService& = delete;
+
+    // Enable move
+    PageService(PageService&&) noexcept;
+    auto operator=(PageService&&) noexcept -> PageService&;
+
+    /// Initialize the service (create system collection if needed)
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Create a new page
+    [[nodiscard]] auto CreatePage(const CreatePageRequest& request) -> PageResult;
+
+    /// Get a page by ID
+    [[nodiscard]] auto GetPage(const std::string& workspace_id, const std::string& id) -> PageResult;
+
+    /// Get a page by slug
+    [[nodiscard]] auto GetPageBySlug(const std::string& workspace_id,
+                                      const std::string& slug) -> PageResult;
+
+    /// List all pages in a workspace
+    [[nodiscard]] auto ListPages(const std::string& workspace_id) -> PageListResult;
+
+    /// List pages for sidebar (show_in_sidebar = true)
+    [[nodiscard]] auto ListSidebarPages(const std::string& workspace_id) -> PageListResult;
+
+    /// Update a page
+    [[nodiscard]] auto UpdatePage(const UpdatePageRequest& request) -> PageResult;
+
+    /// Update page sharing (shared_with_groups)
+    [[nodiscard]] auto UpdatePageSharing(const std::string& workspace_id,
+                                          const std::string& id,
+                                          const std::vector<std::string>& shared_with_groups) -> PageResult;
+
+    /// Delete a page
+    [[nodiscard]] auto DeletePage(const std::string& workspace_id, const std::string& id) -> PageResult;
+
+private:
+    /// System collection name for pages
+    static constexpr const char* kPagesCollection = "_pages";
+
+    /// Convert PageInfo to JSON for storage
+    [[nodiscard]] static auto PageToJson(const PageInfo& page) -> nlohmann::json;
+
+    /// Convert JSON from storage to PageInfo
+    [[nodiscard]] static auto JsonToPage(const nlohmann::json& json) -> PageInfo;
+
+    /// Convert PageLayout to JSON
+    [[nodiscard]] static auto LayoutToJson(const PageLayout& layout) -> nlohmann::json;
+
+    /// Convert JSON to PageLayout
+    [[nodiscard]] static auto JsonToLayout(const nlohmann::json& json) -> PageLayout;
+
+    /// Convert PageSettings to JSON
+    [[nodiscard]] static auto SettingsToJson(const PageSettings& settings) -> nlohmann::json;
+
+    /// Convert JSON to PageSettings
+    [[nodiscard]] static auto JsonToSettings(const nlohmann::json& json) -> PageSettings;
+
+    /// Generate a URL-friendly slug from name
+    [[nodiscard]] static auto GenerateSlug(const std::string& name) -> std::string;
+
+    DatabaseClient& db_client_;
+};
+
+}  // namespace smartbotic::webserver

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

@@ -62,6 +62,12 @@ constexpr std::string_view kViewsCreate = "system:views:create";
 constexpr std::string_view kViewsUpdate = "system:views:update";
 constexpr std::string_view kViewsDelete = "system:views:delete";
 
+// Page management (system-level)
+constexpr std::string_view kPagesRead = "system:pages:read";
+constexpr std::string_view kPagesCreate = "system:pages:create";
+constexpr std::string_view kPagesUpdate = "system:pages:update";
+constexpr std::string_view kPagesDelete = "system:pages:delete";
+
 // ============================================================================
 // WORKSPACE SCOPE - Per-workspace operations
 // Format: workspace:{workspace_id}:{action}
@@ -82,6 +88,33 @@ constexpr std::string_view kWorkspaceManageCollections = "workspace:*:manage_col
 // Workspace view management
 constexpr std::string_view kWorkspaceManageViews = "workspace:*:manage_views";
 
+// Workspace page management
+constexpr std::string_view kWorkspaceManagePages = "workspace:*:manage_pages";
+
+// ============================================================================
+// PAGE SCOPE - Per-workspace page operations (ownership-based)
+// Format: page:{workspace_id}:{action}
+// Similar to collections with read_all/read_own, write_all/write_own pattern
+// ============================================================================
+
+// Create permission
+constexpr std::string_view kPageCreate = "page:*:create";        // Create new pages
+
+// Read permissions
+constexpr std::string_view kPageReadAll = "page:*:read_all";     // Read any page
+constexpr std::string_view kPageReadOwn = "page:*:read_own";     // Read own pages only
+
+// Write permissions
+constexpr std::string_view kPageWriteAll = "page:*:write_all";   // Write any page
+constexpr std::string_view kPageWriteOwn = "page:*:write_own";   // Write own pages only
+
+// Delete permissions
+constexpr std::string_view kPageDeleteAll = "page:*:delete_all"; // Delete any page
+constexpr std::string_view kPageDeleteOwn = "page:*:delete_own"; // Delete own pages only
+
+// Share permission (implicit for owners)
+constexpr std::string_view kPageShare = "page:*:share";          // Share pages with groups
+
 // ============================================================================
 // COLLECTION SCOPE - Per-collection operations
 // Format: collection:{workspace_id}:{collection_name}:{action}
@@ -142,6 +175,11 @@ constexpr std::string_view kAuthenticatedGroupName = "authenticated";
                                               std::string_view collection,
                                               std::string_view action) -> std::string;
 
+/// Build a specific page permission
+/// e.g., BuildPagePermission("ws1", "read_all") -> "page:ws1:read_all"
+[[nodiscard]] auto BuildPagePermission(std::string_view workspace_id,
+                                        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,
@@ -168,4 +206,7 @@ constexpr std::string_view kAuthenticatedGroupName = "authenticated";
 /// Get all defined field actions (for UI enumeration)
 [[nodiscard]] auto GetAllFieldActions() -> std::vector<std::string_view>;
 
+/// Get all defined page actions (for UI enumeration)
+[[nodiscard]] auto GetAllPageActions() -> std::vector<std::string_view>;
+
 }  // namespace smartbotic::permissions

+ 27 - 7
webserver/src/api_key_service.cpp

@@ -1,11 +1,13 @@
 #include "smartbotic/webserver/api_key_service.hpp"
 
 #include <spdlog/spdlog.h>
-#include <sodium.h>
+#include <openssl/rand.h>
+#include <openssl/evp.h>
 
 #include <chrono>
 #include <iomanip>
 #include <sstream>
+#include <stdexcept>
 
 namespace smartbotic::webserver {
 
@@ -93,9 +95,11 @@ auto ApiKeyService::GetCurrentTimestamp() -> std::string {
 }
 
 auto ApiKeyService::GenerateRawKey() -> std::string {
-    // Generate random bytes
+    // Generate random bytes using OpenSSL's CSPRNG
     std::vector<unsigned char> random_bytes(kKeyLength);
-    randombytes_buf(random_bytes.data(), random_bytes.size());
+    if (RAND_bytes(random_bytes.data(), static_cast<int>(random_bytes.size())) != 1) {
+        throw std::runtime_error("Failed to generate random bytes for API key");
+    }
 
     // Convert to hex string
     std::ostringstream oss;
@@ -109,13 +113,29 @@ auto ApiKeyService::GenerateRawKey() -> std::string {
 
 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());
+    constexpr size_t kSha256DigestLength = 32;
+    unsigned char hash[kSha256DigestLength];
+    unsigned int hash_len = 0;
+
+    EVP_MD_CTX* ctx = EVP_MD_CTX_new();
+    if (ctx == nullptr) {
+        throw std::runtime_error("Failed to create EVP_MD_CTX for SHA256");
+    }
+
+    bool success = EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr) == 1 &&
+                   EVP_DigestUpdate(ctx, raw_key.data(), raw_key.size()) == 1 &&
+                   EVP_DigestFinal_ex(ctx, hash, &hash_len) == 1;
+
+    EVP_MD_CTX_free(ctx);
+
+    if (!success || hash_len != kSha256DigestLength) {
+        throw std::runtime_error("Failed to compute SHA256 hash");
+    }
 
     // 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);
+    for (size_t i = 0; i < kSha256DigestLength; ++i) {
+        oss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(hash[i]);
     }
 
     return oss.str();

+ 134 - 19
webserver/src/auth_service.cpp

@@ -1,11 +1,17 @@
 #include "smartbotic/webserver/auth_service.hpp"
 
 #include <jwt-cpp/traits/nlohmann-json/traits.h>
-#include <sodium.h>
+#include <openssl/evp.h>
+#include <openssl/kdf.h>
+#include <openssl/params.h>
+#include <openssl/rand.h>
 #include <spdlog/spdlog.h>
 
 #include <chrono>
+#include <cstring>
 #include <random>
+#include <sstream>
+#include <iomanip>
 
 namespace smartbotic::webserver {
 
@@ -29,13 +35,35 @@ auto GenerateUuid() -> std::string {
     return uuid;
 }
 
+auto BytesToHex(const unsigned char* data, size_t len) -> std::string {
+    std::ostringstream oss;
+    for (size_t i = 0; i < len; ++i) {
+        oss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(data[i]);
+    }
+    return oss.str();
+}
+
+auto HexToBytes(const std::string& hex) -> std::vector<unsigned char> {
+    std::vector<unsigned char> bytes;
+    bytes.reserve(hex.length() / 2);
+    for (size_t i = 0; i < hex.length(); i += 2) {
+        unsigned char byte = static_cast<unsigned char>(std::stoi(hex.substr(i, 2), nullptr, 16));
+        bytes.push_back(byte);
+    }
+    return bytes;
+}
+
+// Password hashing constants (using scrypt for OpenSSL 3.x compatibility)
+constexpr size_t kSaltLength = 16;
+constexpr size_t kHashLength = 32;
+constexpr uint64_t kScryptN = 16384;  // CPU/memory cost parameter
+constexpr uint32_t kScryptR = 8;       // Block size
+constexpr uint32_t kScryptP = 1;       // Parallelization
+
 }  // 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");
-    }
+    // OpenSSL 3.x initializes automatically, no explicit init needed
 }
 
 AuthService::~AuthService() = default;
@@ -261,28 +289,115 @@ auto AuthService::ExtractBearerToken(const std::string& auth_header) -> std::opt
 }
 
 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");
+    // Generate random salt using OpenSSL's CSPRNG
+    std::vector<unsigned char> salt(kSaltLength);
+    if (RAND_bytes(salt.data(), static_cast<int>(kSaltLength)) != 1) {
+        throw std::runtime_error("Failed to generate random salt");
     }
 
-    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)");
+    // Derive key using scrypt via EVP_KDF (OpenSSL 3.x API)
+    std::vector<unsigned char> hash(kHashLength);
+
+    EVP_KDF* kdf = EVP_KDF_fetch(nullptr, "SCRYPT", nullptr);
+    if (kdf == nullptr) {
+        throw std::runtime_error("Failed to fetch scrypt KDF");
+    }
+
+    EVP_KDF_CTX* kctx = EVP_KDF_CTX_new(kdf);
+    EVP_KDF_free(kdf);
+
+    if (kctx == nullptr) {
+        throw std::runtime_error("Failed to create KDF context");
     }
 
-    return std::string(hash);
+    OSSL_PARAM params[6];
+    params[0] = OSSL_PARAM_construct_octet_string("pass",
+                    const_cast<char*>(password.data()), password.size());
+    params[1] = OSSL_PARAM_construct_octet_string("salt", salt.data(), salt.size());
+    params[2] = OSSL_PARAM_construct_uint64("n", const_cast<uint64_t*>(&kScryptN));
+    params[3] = OSSL_PARAM_construct_uint32("r", const_cast<uint32_t*>(&kScryptR));
+    params[4] = OSSL_PARAM_construct_uint32("p", const_cast<uint32_t*>(&kScryptP));
+    params[5] = OSSL_PARAM_construct_end();
+
+    int result = EVP_KDF_derive(kctx, hash.data(), hash.size(), params);
+    EVP_KDF_CTX_free(kctx);
+
+    if (result != 1) {
+        throw std::runtime_error("Password hashing failed");
+    }
+
+    // Format: $scrypt$n=16384,r=8,p=1$<salt_hex>$<hash_hex>
+    std::ostringstream oss;
+    oss << "$scrypt$n=" << kScryptN << ",r=" << kScryptR << ",p=" << kScryptP
+        << "$" << BytesToHex(salt.data(), salt.size())
+        << "$" << BytesToHex(hash.data(), hash.size());
+
+    return oss.str();
 }
 
-auto AuthService::VerifyPassword(const std::string& password, const std::string& hash) -> bool {
-    // Ensure libsodium is initialized
-    if (sodium_init() < 0) {
-        return false;
+auto AuthService::VerifyPassword(const std::string& password, const std::string& stored_hash) -> bool {
+    // Parse the stored hash format: $scrypt$n=N,r=R,p=P$<salt_hex>$<hash_hex>
+    if (stored_hash.substr(0, 8) == "$scrypt$") {
+        // Parse scrypt format
+        size_t params_start = 8;
+        size_t params_end = stored_hash.find('$', params_start);
+        if (params_end == std::string::npos) return false;
+
+        // Parse parameters (simplified - assumes our exact format)
+        uint64_t n = kScryptN;
+        uint32_t r = kScryptR;
+        uint32_t p = kScryptP;
+
+        std::string params_str = stored_hash.substr(params_start, params_end - params_start);
+        // Parse n=, r=, p= from params_str
+        if (sscanf(params_str.c_str(), "n=%lu,r=%u,p=%u", &n, &r, &p) != 3) {
+            // Use defaults if parsing fails
+            n = kScryptN;
+            r = kScryptR;
+            p = kScryptP;
+        }
+
+        size_t salt_start = params_end + 1;
+        size_t salt_end = stored_hash.find('$', salt_start);
+        if (salt_end == std::string::npos) return false;
+
+        std::string salt_hex = stored_hash.substr(salt_start, salt_end - salt_start);
+        std::string hash_hex = stored_hash.substr(salt_end + 1);
+
+        auto salt = HexToBytes(salt_hex);
+        auto expected_hash = HexToBytes(hash_hex);
+
+        // Derive hash with same parameters
+        std::vector<unsigned char> computed_hash(expected_hash.size());
+
+        EVP_KDF* kdf = EVP_KDF_fetch(nullptr, "SCRYPT", nullptr);
+        if (kdf == nullptr) return false;
+
+        EVP_KDF_CTX* kctx = EVP_KDF_CTX_new(kdf);
+        EVP_KDF_free(kdf);
+        if (kctx == nullptr) return false;
+
+        OSSL_PARAM params[6];
+        params[0] = OSSL_PARAM_construct_octet_string("pass",
+                        const_cast<char*>(password.data()), password.size());
+        params[1] = OSSL_PARAM_construct_octet_string("salt", salt.data(), salt.size());
+        params[2] = OSSL_PARAM_construct_uint64("n", &n);
+        params[3] = OSSL_PARAM_construct_uint32("r", &r);
+        params[4] = OSSL_PARAM_construct_uint32("p", &p);
+        params[5] = OSSL_PARAM_construct_end();
+
+        int result = EVP_KDF_derive(kctx, computed_hash.data(), computed_hash.size(), params);
+        EVP_KDF_CTX_free(kctx);
+
+        if (result != 1) return false;
+
+        // Constant-time comparison
+        return CRYPTO_memcmp(computed_hash.data(), expected_hash.data(), expected_hash.size()) == 0;
     }
 
-    return crypto_pwhash_str_verify(hash.c_str(), password.c_str(), password.length()) == 0;
+    // Unknown format
+    spdlog::warn("Unknown password hash format");
+    return false;
 }
 
 auto AuthService::GenerateJti() -> std::string {

+ 145 - 0
webserver/src/authorization_service.cpp

@@ -376,6 +376,151 @@ auto AuthorizationService::CanManageWorkspaceGroups(const AuthUser& user,
     return HasPermission(user, perm);
 }
 
+// ============================================================================
+// Page Permissions (Ownership-Based)
+// ============================================================================
+
+auto AuthorizationService::CanCreatePage(const AuthUser& user,
+                                          const std::string& workspace_id) -> bool {
+    // System admin or workspace admin can always create
+    if (HasPermission(user, permissions::kPagesCreate) || IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for workspace manage_pages permission
+    if (CanManageWorkspacePages(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for specific page:create permission
+    auto perm = permissions::BuildPagePermission(workspace_id, "create");
+    return HasPermission(user, perm);
+}
+
+auto AuthorizationService::CanViewPage(const AuthUser& user,
+                                        const std::string& workspace_id,
+                                        const std::string& page_owner,
+                                        const std::vector<std::string>& shared_with_groups) -> bool {
+    // System admin or workspace admin can always view
+    if (HasPermission(user, permissions::kPagesRead) || IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for workspace manage_pages permission
+    if (CanManageWorkspacePages(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for read_all permission
+    auto read_all = permissions::BuildPagePermission(workspace_id, "read_all");
+    if (HasPermission(user, read_all)) {
+        return true;
+    }
+
+    // Check if user owns the page
+    if (user.user_id == page_owner) {
+        auto read_own = permissions::BuildPagePermission(workspace_id, "read_own");
+        return HasPermission(user, read_own);
+    }
+
+    // Check if page is shared with any of user's groups
+    return IsMemberOfAnyGroup(user, shared_with_groups);
+}
+
+auto AuthorizationService::CanEditPage(const AuthUser& user,
+                                        const std::string& workspace_id,
+                                        const std::string& page_owner) -> bool {
+    // System admin or workspace admin can always edit
+    if (HasPermission(user, permissions::kPagesUpdate) || IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for workspace manage_pages permission
+    if (CanManageWorkspacePages(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for write_all permission
+    auto write_all = permissions::BuildPagePermission(workspace_id, "write_all");
+    if (HasPermission(user, write_all)) {
+        return true;
+    }
+
+    // Check if user owns the page and has write_own
+    if (user.user_id == page_owner) {
+        auto write_own = permissions::BuildPagePermission(workspace_id, "write_own");
+        return HasPermission(user, write_own);
+    }
+
+    return false;
+}
+
+auto AuthorizationService::CanDeletePage(const AuthUser& user,
+                                          const std::string& workspace_id,
+                                          const std::string& page_owner) -> bool {
+    // System admin or workspace admin can always delete
+    if (HasPermission(user, permissions::kPagesDelete) || IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for workspace manage_pages permission
+    if (CanManageWorkspacePages(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for delete_all permission
+    auto delete_all = permissions::BuildPagePermission(workspace_id, "delete_all");
+    if (HasPermission(user, delete_all)) {
+        return true;
+    }
+
+    // Check if user owns the page and has delete_own
+    if (user.user_id == page_owner) {
+        auto delete_own = permissions::BuildPagePermission(workspace_id, "delete_own");
+        return HasPermission(user, delete_own);
+    }
+
+    return false;
+}
+
+auto AuthorizationService::CanSharePage(const AuthUser& user,
+                                         const std::string& workspace_id,
+                                         const std::string& page_owner) -> bool {
+    // System admin or workspace admin can always share
+    if (HasPermission(user, permissions::kPagesUpdate) || IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for workspace manage_pages permission
+    if (CanManageWorkspacePages(user, workspace_id)) {
+        return true;
+    }
+
+    // Check for write_all permission (can modify any page including sharing)
+    auto write_all = permissions::BuildPagePermission(workspace_id, "write_all");
+    if (HasPermission(user, write_all)) {
+        return true;
+    }
+
+    // Owners can always share their own pages
+    if (user.user_id == page_owner) {
+        return true;
+    }
+
+    return false;
+}
+
+auto AuthorizationService::CanManageWorkspacePages(const AuthUser& user,
+                                                    const std::string& workspace_id) -> bool {
+    // Workspace admin can manage pages
+    if (IsWorkspaceAdmin(user, workspace_id)) {
+        return true;
+    }
+
+    auto perm = permissions::BuildWorkspacePermission(workspace_id, "manage_pages");
+    return HasPermission(user, perm);
+}
+
 // ============================================================================
 // Group Visibility
 // ============================================================================

+ 835 - 0
webserver/src/http_server.cpp

@@ -529,6 +529,14 @@ auto HttpServer::InitializeServices() -> bool {
     }
     spdlog::info("ViewService initialized successfully");
 
+    // Initialize PageService
+    pageService_ = std::make_unique<PageService>(*dbClient_);
+    if (!pageService_->Initialize()) {
+        spdlog::error("Failed to initialize PageService");
+        return false;
+    }
+    spdlog::info("PageService initialized successfully");
+
     // Initialize AuthorizationService
     authorizationService_ = std::make_unique<AuthorizationService>(*groupService_, *collectionService_);
     if (!authorizationService_->Initialize()) {
@@ -626,6 +634,9 @@ void HttpServer::SetupRoutes() {
     // Setup view routes (schema definitions)
     SetupViewRoutes();
 
+    // Setup page routes (page builder)
+    SetupPageRoutes();
+
     // Setup collection routes (before workspace routes since they're more specific)
     SetupCollectionRoutes();
 
@@ -4585,4 +4596,828 @@ void HttpServer::HandleDeleteView(const httplib::Request& req, httplib::Response
     }
 }
 
+// ============================================================================
+// Page Routes
+// ============================================================================
+
+void HttpServer::SetupPageRoutes() {
+    // POST /api/workspaces/:workspace_id/pages - Create a new page
+    httpServer_->Post(R"(/api/workspaces/([^/]+)/pages$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleCreatePage(req, res);
+        });
+
+    // GET /api/workspaces/:workspace_id/pages - List all pages
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/pages$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleListPages(req, res);
+        });
+
+    // GET /api/workspaces/:workspace_id/pages/sidebar - List sidebar pages
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/pages/sidebar$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleListSidebarPages(req, res);
+        });
+
+    // GET /api/workspaces/:workspace_id/pages/slug/:slug - Get page by slug
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/pages/slug/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleGetPageBySlug(req, res);
+        });
+
+    // GET /api/workspaces/:workspace_id/pages/:page_id - Get a specific page
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/pages/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleGetPage(req, res);
+        });
+
+    // PATCH /api/workspaces/:workspace_id/pages/:page_id - Update a page
+    httpServer_->Patch(R"(/api/workspaces/([^/]+)/pages/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleUpdatePage(req, res);
+        });
+
+    // PATCH /api/workspaces/:workspace_id/pages/:page_id/share - Update page sharing
+    httpServer_->Patch(R"(/api/workspaces/([^/]+)/pages/([^/]+)/share$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleUpdatePageSharing(req, res);
+        });
+
+    // DELETE /api/workspaces/:workspace_id/pages/:page_id - Delete a page
+    httpServer_->Delete(R"(/api/workspaces/([^/]+)/pages/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleDeletePage(req, res);
+        });
+
+    spdlog::info("Page routes configured");
+}
+
+void HttpServer::HandleCreatePage(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID from path
+    std::string workspace_id = req.matches[1].str();
+
+    // Verify workspace exists
+    auto ws_result = workspaceService_->GetWorkspace(workspace_id);
+    if (!ws_result.success) {
+        res.status = 404;
+        res.set_content(R"({"error":"Workspace not found"})", "application/json");
+        return;
+    }
+
+    // Check permission to create pages
+    if (!authorizationService_->CanCreatePage(*auth_user, workspace_id)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions to create pages"})", "application/json");
+        return;
+    }
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    // Parse request body
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error& /*e*/) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    // Validate required fields
+    if (!body.contains("name") || !body["name"].is_string()) {
+        res.status = 400;
+        res.set_content(R"({"error":"name is required"})", "application/json");
+        return;
+    }
+
+    try {
+        CreatePageRequest request;
+        request.workspace_id = workspace_id;
+        request.name = body["name"].get<std::string>();
+        request.slug = body.value("slug", "");
+        request.created_by = auth_user->user_id;  // Set owner to current user
+
+        // Parse layout if provided
+        if (body.contains("layout") && body["layout"].is_object()) {
+            const auto& layout_json = body["layout"];
+            request.layout.version = layout_json.value("version", 1);
+            request.layout.grid_columns = layout_json.value("grid_columns", 12);
+
+            if (layout_json.contains("components") && layout_json["components"].is_array()) {
+                for (const auto& comp_json : layout_json["components"]) {
+                    LayoutComponent comp;
+                    comp.id = comp_json.value("id", "");
+                    comp.type = comp_json.value("type", "");
+                    comp.config = comp_json.value("config", nlohmann::json::object());
+
+                    if (comp_json.contains("position") && comp_json["position"].is_object()) {
+                        const auto& pos = comp_json["position"];
+                        comp.position.x = pos.value("x", 0);
+                        comp.position.y = pos.value("y", 0);
+                        comp.position.width = pos.value("width", 12);
+                        comp.position.height = pos.value("height", 1);
+                    }
+
+                    request.layout.components.push_back(comp);
+                }
+            }
+        }
+
+        // Parse settings if provided
+        if (body.contains("settings") && body["settings"].is_object()) {
+            const auto& settings_json = body["settings"];
+            request.settings.show_in_sidebar = settings_json.value("show_in_sidebar", true);
+            request.settings.icon = settings_json.value("icon", "");
+            request.settings.menu_order = settings_json.value("menu_order", 0);
+        }
+
+        auto result = pageService_->CreatePage(request);
+
+        if (!result.success) {
+            res.status = 400;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Build response with page info
+        nlohmann::json page_json = {
+            {"id", result.page->id},
+            {"workspace_id", result.page->workspace_id},
+            {"name", result.page->name},
+            {"slug", result.page->slug},
+            {"created_at", result.page->created_at},
+            {"updated_at", result.page->updated_at},
+            {"created_by", result.page->created_by},
+            {"shared_with_groups", result.page->shared_with_groups}
+        };
+
+        // Add layout
+        nlohmann::json layout_json = {
+            {"version", result.page->layout.version},
+            {"grid_columns", result.page->layout.grid_columns}
+        };
+        nlohmann::json components_json = nlohmann::json::array();
+        for (const auto& comp : result.page->layout.components) {
+            nlohmann::json comp_json = {
+                {"id", comp.id},
+                {"type", comp.type},
+                {"position", {
+                    {"x", comp.position.x},
+                    {"y", comp.position.y},
+                    {"width", comp.position.width},
+                    {"height", comp.position.height}
+                }},
+                {"config", comp.config}
+            };
+            components_json.push_back(comp_json);
+        }
+        layout_json["components"] = components_json;
+        page_json["layout"] = layout_json;
+
+        // Add settings
+        page_json["settings"] = {
+            {"show_in_sidebar", result.page->settings.show_in_sidebar},
+            {"icon", result.page->settings.icon},
+            {"menu_order", result.page->settings.menu_order}
+        };
+
+        res.status = 201;
+        res.set_content(page_json.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("Create page error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleListPages(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID from path
+    std::string workspace_id = req.matches[1].str();
+
+    // Verify workspace exists
+    auto ws_result = workspaceService_->GetWorkspace(workspace_id);
+    if (!ws_result.success) {
+        res.status = 404;
+        res.set_content(R"({"error":"Workspace not found"})", "application/json");
+        return;
+    }
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = pageService_->ListPages(workspace_id);
+
+        if (!result.success) {
+            res.status = 500;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Filter pages based on user permissions
+        nlohmann::json pages_json = nlohmann::json::array();
+        for (const auto& page : result.pages) {
+            // Check if user can view this page
+            if (!authorizationService_->CanViewPage(*auth_user, workspace_id,
+                                                     page.created_by, page.shared_with_groups)) {
+                continue;  // Skip pages user cannot view
+            }
+
+            nlohmann::json page_json = {
+                {"id", page.id},
+                {"workspace_id", page.workspace_id},
+                {"name", page.name},
+                {"slug", page.slug},
+                {"created_at", page.created_at},
+                {"updated_at", page.updated_at},
+                {"created_by", page.created_by},
+                {"shared_with_groups", page.shared_with_groups},
+                {"settings", {
+                    {"show_in_sidebar", page.settings.show_in_sidebar},
+                    {"icon", page.settings.icon},
+                    {"menu_order", page.settings.menu_order}
+                }}
+            };
+            pages_json.push_back(page_json);
+        }
+
+        res.set_content(pages_json.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("List pages error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleListSidebarPages(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID from path
+    std::string workspace_id = req.matches[1].str();
+
+    // Verify workspace exists
+    auto ws_result = workspaceService_->GetWorkspace(workspace_id);
+    if (!ws_result.success) {
+        res.status = 404;
+        res.set_content(R"({"error":"Workspace not found"})", "application/json");
+        return;
+    }
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = pageService_->ListSidebarPages(workspace_id);
+
+        if (!result.success) {
+            res.status = 500;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Filter pages based on user permissions
+        nlohmann::json pages_json = nlohmann::json::array();
+        for (const auto& page : result.pages) {
+            // Check if user can view this page
+            if (!authorizationService_->CanViewPage(*auth_user, workspace_id,
+                                                     page.created_by, page.shared_with_groups)) {
+                continue;  // Skip pages user cannot view
+            }
+
+            nlohmann::json page_json = {
+                {"id", page.id},
+                {"workspace_id", page.workspace_id},
+                {"name", page.name},
+                {"slug", page.slug},
+                {"created_by", page.created_by},
+                {"settings", {
+                    {"show_in_sidebar", page.settings.show_in_sidebar},
+                    {"icon", page.settings.icon},
+                    {"menu_order", page.settings.menu_order}
+                }}
+            };
+            pages_json.push_back(page_json);
+        }
+
+        res.set_content(pages_json.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("List sidebar pages error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleGetPageBySlug(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID and slug from path
+    std::string workspace_id = req.matches[1].str();
+    std::string slug = req.matches[2].str();
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = pageService_->GetPageBySlug(workspace_id, slug);
+
+        if (!result.success) {
+            res.status = 404;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Check if user can view this page
+        if (!authorizationService_->CanViewPage(*auth_user, workspace_id,
+                                                 result.page->created_by, result.page->shared_with_groups)) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - no access to this page"})", "application/json");
+            return;
+        }
+
+        // Build full response with layout
+        nlohmann::json page_json = {
+            {"id", result.page->id},
+            {"workspace_id", result.page->workspace_id},
+            {"name", result.page->name},
+            {"slug", result.page->slug},
+            {"created_at", result.page->created_at},
+            {"updated_at", result.page->updated_at},
+            {"created_by", result.page->created_by},
+            {"shared_with_groups", result.page->shared_with_groups}
+        };
+
+        // Add layout
+        nlohmann::json layout_json = {
+            {"version", result.page->layout.version},
+            {"grid_columns", result.page->layout.grid_columns}
+        };
+        nlohmann::json components_json = nlohmann::json::array();
+        for (const auto& comp : result.page->layout.components) {
+            nlohmann::json comp_json = {
+                {"id", comp.id},
+                {"type", comp.type},
+                {"position", {
+                    {"x", comp.position.x},
+                    {"y", comp.position.y},
+                    {"width", comp.position.width},
+                    {"height", comp.position.height}
+                }},
+                {"config", comp.config}
+            };
+            components_json.push_back(comp_json);
+        }
+        layout_json["components"] = components_json;
+        page_json["layout"] = layout_json;
+
+        // Add settings
+        page_json["settings"] = {
+            {"show_in_sidebar", result.page->settings.show_in_sidebar},
+            {"icon", result.page->settings.icon},
+            {"menu_order", result.page->settings.menu_order}
+        };
+
+        res.set_content(page_json.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("Get page by slug error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleGetPage(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID and page ID from path
+    std::string workspace_id = req.matches[1].str();
+    std::string page_id = req.matches[2].str();
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = pageService_->GetPage(workspace_id, page_id);
+
+        if (!result.success) {
+            res.status = 404;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Check if user can view this page
+        if (!authorizationService_->CanViewPage(*auth_user, workspace_id,
+                                                 result.page->created_by, result.page->shared_with_groups)) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - no access to this page"})", "application/json");
+            return;
+        }
+
+        // Build full response with layout
+        nlohmann::json page_json = {
+            {"id", result.page->id},
+            {"workspace_id", result.page->workspace_id},
+            {"name", result.page->name},
+            {"slug", result.page->slug},
+            {"created_at", result.page->created_at},
+            {"updated_at", result.page->updated_at},
+            {"created_by", result.page->created_by},
+            {"shared_with_groups", result.page->shared_with_groups}
+        };
+
+        // Add layout
+        nlohmann::json layout_json = {
+            {"version", result.page->layout.version},
+            {"grid_columns", result.page->layout.grid_columns}
+        };
+        nlohmann::json components_json = nlohmann::json::array();
+        for (const auto& comp : result.page->layout.components) {
+            nlohmann::json comp_json = {
+                {"id", comp.id},
+                {"type", comp.type},
+                {"position", {
+                    {"x", comp.position.x},
+                    {"y", comp.position.y},
+                    {"width", comp.position.width},
+                    {"height", comp.position.height}
+                }},
+                {"config", comp.config}
+            };
+            components_json.push_back(comp_json);
+        }
+        layout_json["components"] = components_json;
+        page_json["layout"] = layout_json;
+
+        // Add settings
+        page_json["settings"] = {
+            {"show_in_sidebar", result.page->settings.show_in_sidebar},
+            {"icon", result.page->settings.icon},
+            {"menu_order", result.page->settings.menu_order}
+        };
+
+        res.set_content(page_json.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("Get page error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleUpdatePage(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID and page ID from path
+    std::string workspace_id = req.matches[1].str();
+    std::string page_id = req.matches[2].str();
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    // Get existing page to check ownership
+    auto existing = pageService_->GetPage(workspace_id, page_id);
+    if (!existing.success || !existing.page) {
+        res.status = 404;
+        res.set_content(R"({"error":"Page not found"})", "application/json");
+        return;
+    }
+
+    // Check permission to edit
+    if (!authorizationService_->CanEditPage(*auth_user, workspace_id, existing.page->created_by)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions to edit this page"})", "application/json");
+        return;
+    }
+
+    // Parse request body
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error& /*e*/) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    try {
+        UpdatePageRequest request;
+        request.workspace_id = workspace_id;
+        request.id = page_id;
+
+        if (body.contains("name") && body["name"].is_string()) {
+            request.name = body["name"].get<std::string>();
+        }
+        if (body.contains("slug") && body["slug"].is_string()) {
+            request.slug = body["slug"].get<std::string>();
+        }
+
+        // Parse layout if provided
+        if (body.contains("layout") && body["layout"].is_object()) {
+            PageLayout layout;
+            const auto& layout_json = body["layout"];
+            layout.version = layout_json.value("version", 1);
+            layout.grid_columns = layout_json.value("grid_columns", 12);
+
+            if (layout_json.contains("components") && layout_json["components"].is_array()) {
+                for (const auto& comp_json : layout_json["components"]) {
+                    LayoutComponent comp;
+                    comp.id = comp_json.value("id", "");
+                    comp.type = comp_json.value("type", "");
+                    comp.config = comp_json.value("config", nlohmann::json::object());
+
+                    if (comp_json.contains("position") && comp_json["position"].is_object()) {
+                        const auto& pos = comp_json["position"];
+                        comp.position.x = pos.value("x", 0);
+                        comp.position.y = pos.value("y", 0);
+                        comp.position.width = pos.value("width", 12);
+                        comp.position.height = pos.value("height", 1);
+                    }
+
+                    layout.components.push_back(comp);
+                }
+            }
+            request.layout = layout;
+        }
+
+        // Parse settings if provided
+        if (body.contains("settings") && body["settings"].is_object()) {
+            PageSettings settings;
+            const auto& settings_json = body["settings"];
+            settings.show_in_sidebar = settings_json.value("show_in_sidebar", true);
+            settings.icon = settings_json.value("icon", "");
+            settings.menu_order = settings_json.value("menu_order", 0);
+            request.settings = settings;
+        }
+
+        auto result = pageService_->UpdatePage(request);
+
+        if (!result.success) {
+            res.status = 400;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Build response with page info
+        nlohmann::json page_json = {
+            {"id", result.page->id},
+            {"workspace_id", result.page->workspace_id},
+            {"name", result.page->name},
+            {"slug", result.page->slug},
+            {"created_at", result.page->created_at},
+            {"updated_at", result.page->updated_at},
+            {"created_by", result.page->created_by},
+            {"shared_with_groups", result.page->shared_with_groups}
+        };
+
+        // Add layout
+        nlohmann::json layout_json = {
+            {"version", result.page->layout.version},
+            {"grid_columns", result.page->layout.grid_columns}
+        };
+        nlohmann::json components_json = nlohmann::json::array();
+        for (const auto& comp : result.page->layout.components) {
+            nlohmann::json comp_json = {
+                {"id", comp.id},
+                {"type", comp.type},
+                {"position", {
+                    {"x", comp.position.x},
+                    {"y", comp.position.y},
+                    {"width", comp.position.width},
+                    {"height", comp.position.height}
+                }},
+                {"config", comp.config}
+            };
+            components_json.push_back(comp_json);
+        }
+        layout_json["components"] = components_json;
+        page_json["layout"] = layout_json;
+
+        // Add settings
+        page_json["settings"] = {
+            {"show_in_sidebar", result.page->settings.show_in_sidebar},
+            {"icon", result.page->settings.icon},
+            {"menu_order", result.page->settings.menu_order}
+        };
+
+        res.set_content(page_json.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("Update page error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleUpdatePageSharing(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID and page ID from path
+    std::string workspace_id = req.matches[1].str();
+    std::string page_id = req.matches[2].str();
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    // Get existing page to check ownership
+    auto existing = pageService_->GetPage(workspace_id, page_id);
+    if (!existing.success || !existing.page) {
+        res.status = 404;
+        res.set_content(R"({"error":"Page not found"})", "application/json");
+        return;
+    }
+
+    // Check permission to share
+    if (!authorizationService_->CanSharePage(*auth_user, workspace_id, existing.page->created_by)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions to share this page"})", "application/json");
+        return;
+    }
+
+    // Parse request body
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const nlohmann::json::parse_error& /*e*/) {
+        res.status = 400;
+        res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
+        return;
+    }
+
+    // Validate shared_with_groups field
+    if (!body.contains("shared_with_groups") || !body["shared_with_groups"].is_array()) {
+        res.status = 400;
+        res.set_content(R"({"error":"shared_with_groups array is required"})", "application/json");
+        return;
+    }
+
+    try {
+        std::vector<std::string> shared_with_groups;
+        for (const auto& group : body["shared_with_groups"]) {
+            if (group.is_string()) {
+                shared_with_groups.push_back(group.get<std::string>());
+            }
+        }
+
+        auto result = pageService_->UpdatePageSharing(workspace_id, page_id, shared_with_groups);
+
+        if (!result.success) {
+            res.status = 400;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        // Build response
+        nlohmann::json response = {
+            {"id", result.page->id},
+            {"shared_with_groups", result.page->shared_with_groups},
+            {"message", "Page sharing updated successfully"}
+        };
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("Update page sharing error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleDeletePage(const httplib::Request& req, httplib::Response& res) {
+    // Authenticate the request
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Extract workspace ID and page ID from path
+    std::string workspace_id = req.matches[1].str();
+    std::string page_id = req.matches[2].str();
+
+    // Check if PageService is available
+    if (!pageService_) {
+        res.status = 503;
+        res.set_content(R"({"error":"Page service not available"})", "application/json");
+        return;
+    }
+
+    // Get existing page to check ownership
+    auto existing = pageService_->GetPage(workspace_id, page_id);
+    if (!existing.success || !existing.page) {
+        res.status = 404;
+        res.set_content(R"({"error":"Page not found"})", "application/json");
+        return;
+    }
+
+    // Check permission to delete
+    if (!authorizationService_->CanDeletePage(*auth_user, workspace_id, existing.page->created_by)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions to delete this page"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = pageService_->DeletePage(workspace_id, page_id);
+
+        if (!result.success) {
+            res.status = 404;
+            nlohmann::json error_response = {{"error", result.error}};
+            res.set_content(error_response.dump(), "application/json");
+            return;
+        }
+
+        res.set_content(R"({"message":"Page deleted successfully"})", "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("Delete page error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
 }  // namespace smartbotic::webserver

+ 556 - 0
webserver/src/page_service.cpp

@@ -0,0 +1,556 @@
+#include "smartbotic/webserver/page_service.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+#include <regex>
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::webserver {
+
+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
+
+PageService::PageService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+PageService::~PageService() = default;
+
+PageService::PageService(PageService&&) noexcept = default;
+
+auto PageService::operator=(PageService&& /*other*/) noexcept -> PageService& {
+    // Cannot reassign reference member
+    return *this;
+}
+
+auto PageService::Initialize() -> bool {
+    // Check if _pages collection exists, create if not
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetCollectionMetadataRequest req;
+    ::smartbotic::database::CollectionMetadata resp;
+
+    req.set_name(kPagesCollection);
+    auto status = db_client_.GetCollectionService()->GetCollectionMetadata(&ctx, req, &resp);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kPagesCollection);
+        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(kPagesCollection);
+        auto create_status = db_client_.GetCollectionService()->CreateCollection(
+            &create_ctx, create_req, &create_resp);
+
+        if (!create_status.ok()) {
+            spdlog::error("Failed to create system collection {}: {}",
+                          kPagesCollection, create_status.error_message());
+            return false;
+        }
+        spdlog::info("Created system collection {}", kPagesCollection);
+        return true;
+    }
+
+    spdlog::error("Failed to check system collection {}: {}",
+                  kPagesCollection, status.error_message());
+    return false;
+}
+
+auto PageService::CreatePage(const CreatePageRequest& request) -> PageResult {
+    // Check if page with same slug already exists in workspace
+    auto slug = request.slug.empty() ? GenerateSlug(request.name) : request.slug;
+    auto existing = GetPageBySlug(request.workspace_id, slug);
+    if (existing.success && existing.page) {
+        return {.success = false, .error = "Page with this slug already exists", .page = std::nullopt};
+    }
+
+    // Create page document
+    PageInfo page;
+    page.workspace_id = request.workspace_id;
+    page.name = request.name;
+    page.slug = slug;
+    page.layout = request.layout;
+    page.settings = request.settings;
+    page.created_at = GetCurrentTimestamp();
+    page.updated_at = page.created_at;
+    page.created_by = request.created_by;
+    page.shared_with_groups = {};
+
+    auto json_data = PageToJson(page);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kPagesCollection);
+
+    // 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_number_integer()) {
+            value.set_int_value(it.value().get<int64_t>());
+        } 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 page {}: {}", request.name, status.error_message());
+        return {.success = false, .error = "Failed to create page: " + status.error_message(), .page = std::nullopt};
+    }
+
+    page.id = resp.id();
+    spdlog::info("Created page {} in workspace {}", page.name, page.workspace_id);
+    return {.success = true, .error = "", .page = page};
+}
+
+auto PageService::GetPage(const std::string& workspace_id, const std::string& id) -> PageResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kPagesCollection);
+    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 = "Page not found", .page = std::nullopt};
+        }
+        return {.success = false, .error = "Failed to get page: " + status.error_message(), .page = 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 page = JsonToPage(json_data);
+
+    // Verify workspace matches
+    if (page.workspace_id != workspace_id) {
+        return {.success = false, .error = "Page not found", .page = std::nullopt};
+    }
+
+    return {.success = true, .error = "", .page = page};
+}
+
+auto PageService::GetPageBySlug(const std::string& workspace_id,
+                                 const std::string& slug) -> PageResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(kPagesCollection);
+    req.set_limit(1);
+
+    // Build filter for workspace_id and slug
+    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* slug_filter = composite->add_filters()->mutable_field();
+    slug_filter->set_field("slug");
+    slug_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    slug_filter->mutable_value()->set_string_value(slug);
+
+    auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = "Failed to query pages: " + status.error_message(), .page = std::nullopt};
+    }
+
+    if (resp.documents_size() == 0) {
+        return {.success = false, .error = "Page not found", .page = 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();
+        } else if (value.has_int_value()) {
+            json_data[key] = value.int_value();
+        }
+    }
+    json_data["id"] = doc.id();
+
+    auto page = JsonToPage(json_data);
+    return {.success = true, .error = "", .page = page};
+}
+
+auto PageService::ListPages(const std::string& workspace_id) -> PageListResult {
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(kPagesCollection);
+    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 pages: " + status.error_message(), .pages = {}};
+    }
+
+    std::vector<PageInfo> pages;
+    pages.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();
+            } else if (value.has_int_value()) {
+                json_data[key] = value.int_value();
+            }
+        }
+        json_data["id"] = doc.id();
+        pages.push_back(JsonToPage(json_data));
+    }
+
+    return {.success = true, .error = "", .pages = std::move(pages)};
+}
+
+auto PageService::ListSidebarPages(const std::string& workspace_id) -> PageListResult {
+    // First list all pages, then filter by show_in_sidebar
+    // (We could optimize this with a composite filter, but keeping simple for now)
+    auto result = ListPages(workspace_id);
+    if (!result.success) {
+        return result;
+    }
+
+    std::vector<PageInfo> sidebar_pages;
+    for (auto& page : result.pages) {
+        if (page.settings.show_in_sidebar) {
+            sidebar_pages.push_back(std::move(page));
+        }
+    }
+
+    // Sort by menu_order
+    std::sort(sidebar_pages.begin(), sidebar_pages.end(),
+              [](const PageInfo& a, const PageInfo& b) {
+                  return a.settings.menu_order < b.settings.menu_order;
+              });
+
+    return {.success = true, .error = "", .pages = std::move(sidebar_pages)};
+}
+
+auto PageService::UpdatePage(const UpdatePageRequest& request) -> PageResult {
+    // Get existing page
+    auto existing = GetPage(request.workspace_id, request.id);
+    if (!existing.success || !existing.page) {
+        return {.success = false, .error = "Page not found", .page = std::nullopt};
+    }
+
+    PageInfo updated = *existing.page;
+
+    // Apply updates
+    if (request.name) {
+        updated.name = *request.name;
+    }
+    if (request.slug) {
+        // Check for slug conflict
+        if (*request.slug != updated.slug) {
+            auto conflict = GetPageBySlug(request.workspace_id, *request.slug);
+            if (conflict.success && conflict.page) {
+                return {.success = false, .error = "Page with this slug already exists", .page = std::nullopt};
+            }
+        }
+        updated.slug = *request.slug;
+    }
+    if (request.layout) {
+        updated.layout = *request.layout;
+    }
+    if (request.settings) {
+        updated.settings = *request.settings;
+    }
+    if (request.shared_with_groups) {
+        updated.shared_with_groups = *request.shared_with_groups;
+    }
+    updated.updated_at = GetCurrentTimestamp();
+
+    auto json_data = PageToJson(updated);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kPagesCollection);
+    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_number_integer()) {
+            value.set_int_value(it.value().get<int64_t>());
+        } 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 page {}: {}", request.id, status.error_message());
+        return {.success = false, .error = "Failed to update page: " + status.error_message(), .page = std::nullopt};
+    }
+
+    spdlog::info("Updated page {} in workspace {}", updated.name, updated.workspace_id);
+    return {.success = true, .error = "", .page = updated};
+}
+
+auto PageService::UpdatePageSharing(const std::string& workspace_id,
+                                     const std::string& id,
+                                     const std::vector<std::string>& shared_with_groups) -> PageResult {
+    UpdatePageRequest request;
+    request.workspace_id = workspace_id;
+    request.id = id;
+    request.shared_with_groups = shared_with_groups;
+    return UpdatePage(request);
+}
+
+auto PageService::DeletePage(const std::string& workspace_id, const std::string& id) -> PageResult {
+    // Verify page exists and belongs to workspace
+    auto existing = GetPage(workspace_id, id);
+    if (!existing.success || !existing.page) {
+        return {.success = false, .error = "Page not found", .page = std::nullopt};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::DeleteDocumentRequest req;
+    ::smartbotic::database::DeleteDocumentResponse resp;
+
+    req.set_collection(kPagesCollection);
+    req.set_id(id);
+
+    auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("Failed to delete page {}: {}", id, status.error_message());
+        return {.success = false, .error = "Failed to delete page: " + status.error_message(), .page = std::nullopt};
+    }
+
+    spdlog::info("Deleted page {} from workspace {}", existing.page->name, workspace_id);
+    return {.success = true, .error = "", .page = std::nullopt};
+}
+
+auto PageService::PageToJson(const PageInfo& page) -> nlohmann::json {
+    return {
+        {"workspace_id", page.workspace_id},
+        {"name", page.name},
+        {"slug", page.slug},
+        {"layout", LayoutToJson(page.layout)},
+        {"settings", SettingsToJson(page.settings)},
+        {"created_at", page.created_at},
+        {"updated_at", page.updated_at},
+        {"created_by", page.created_by},
+        {"shared_with_groups", page.shared_with_groups}
+    };
+}
+
+auto PageService::JsonToPage(const nlohmann::json& json) -> PageInfo {
+    PageInfo page;
+    page.id = json.value("id", "");
+    page.workspace_id = json.value("workspace_id", "");
+    page.name = json.value("name", "");
+    page.slug = json.value("slug", "");
+    page.created_at = json.value("created_at", "");
+    page.updated_at = json.value("updated_at", "");
+    page.created_by = json.value("created_by", "");
+
+    if (json.contains("layout") && json["layout"].is_object()) {
+        page.layout = JsonToLayout(json["layout"]);
+    }
+    if (json.contains("settings") && json["settings"].is_object()) {
+        page.settings = JsonToSettings(json["settings"]);
+    }
+    if (json.contains("shared_with_groups") && json["shared_with_groups"].is_array()) {
+        for (const auto& group : json["shared_with_groups"]) {
+            if (group.is_string()) {
+                page.shared_with_groups.push_back(group.get<std::string>());
+            }
+        }
+    }
+
+    return page;
+}
+
+auto PageService::LayoutToJson(const PageLayout& layout) -> nlohmann::json {
+    nlohmann::json components_json = nlohmann::json::array();
+    for (const auto& comp : layout.components) {
+        nlohmann::json comp_json = {
+            {"id", comp.id},
+            {"type", comp.type},
+            {"position", {
+                {"x", comp.position.x},
+                {"y", comp.position.y},
+                {"width", comp.position.width},
+                {"height", comp.position.height}
+            }},
+            {"config", comp.config}
+        };
+        components_json.push_back(comp_json);
+    }
+
+    return {
+        {"version", layout.version},
+        {"grid_columns", layout.grid_columns},
+        {"components", components_json}
+    };
+}
+
+auto PageService::JsonToLayout(const nlohmann::json& json) -> PageLayout {
+    PageLayout layout;
+    layout.version = json.value("version", 1);
+    layout.grid_columns = json.value("grid_columns", 12);
+
+    if (json.contains("components") && json["components"].is_array()) {
+        for (const auto& comp_json : json["components"]) {
+            LayoutComponent comp;
+            comp.id = comp_json.value("id", "");
+            comp.type = comp_json.value("type", "");
+            comp.config = comp_json.value("config", nlohmann::json::object());
+
+            if (comp_json.contains("position") && comp_json["position"].is_object()) {
+                const auto& pos = comp_json["position"];
+                comp.position.x = pos.value("x", 0);
+                comp.position.y = pos.value("y", 0);
+                comp.position.width = pos.value("width", 12);
+                comp.position.height = pos.value("height", 1);
+            }
+
+            layout.components.push_back(comp);
+        }
+    }
+
+    return layout;
+}
+
+auto PageService::SettingsToJson(const PageSettings& settings) -> nlohmann::json {
+    return {
+        {"show_in_sidebar", settings.show_in_sidebar},
+        {"icon", settings.icon},
+        {"menu_order", settings.menu_order}
+    };
+}
+
+auto PageService::JsonToSettings(const nlohmann::json& json) -> PageSettings {
+    PageSettings settings;
+    settings.show_in_sidebar = json.value("show_in_sidebar", true);
+    settings.icon = json.value("icon", "");
+    settings.menu_order = json.value("menu_order", 0);
+    return settings;
+}
+
+auto PageService::GenerateSlug(const std::string& name) -> std::string {
+    std::string slug;
+    slug.reserve(name.size());
+
+    for (char c : name) {
+        if (std::isalnum(static_cast<unsigned char>(c))) {
+            slug += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+        } else if (c == ' ' || c == '-' || c == '_') {
+            if (!slug.empty() && slug.back() != '-') {
+                slug += '-';
+            }
+        }
+    }
+
+    // Remove trailing dash
+    while (!slug.empty() && slug.back() == '-') {
+        slug.pop_back();
+    }
+
+    return slug.empty() ? "page" : slug;
+}
+
+}  // namespace smartbotic::webserver

+ 30 - 0
webserver/src/permissions.cpp

@@ -27,6 +27,17 @@ auto BuildCollectionPermission(std::string_view workspace_id,
     return result;
 }
 
+auto BuildPagePermission(std::string_view workspace_id,
+                          std::string_view action) -> std::string {
+    std::string result;
+    result.reserve(6 + workspace_id.size() + action.size());
+    result.append("page:");
+    result.append(workspace_id);
+    result.append(":");
+    result.append(action);
+    return result;
+}
+
 auto BuildFieldPermission(std::string_view workspace_id,
                            std::string_view collection,
                            std::string_view field,
@@ -168,6 +179,12 @@ auto GetAllSystemPermissions() -> std::vector<std::string_view> {
         kViewsCreate,
         kViewsUpdate,
         kViewsDelete,
+
+        // Page management
+        kPagesRead,
+        kPagesCreate,
+        kPagesUpdate,
+        kPagesDelete,
     };
 }
 
@@ -191,4 +208,17 @@ auto GetAllFieldActions() -> std::vector<std::string_view> {
     };
 }
 
+auto GetAllPageActions() -> std::vector<std::string_view> {
+    return {
+        "create",
+        "read_all",
+        "read_own",
+        "write_all",
+        "write_own",
+        "delete_all",
+        "delete_own",
+        "share",
+    };
+}
+
 }  // namespace smartbotic::permissions

+ 6 - 0
webui/src/App.tsx

@@ -5,6 +5,9 @@ import Dashboard from '@/pages/Dashboard'
 import Collections from '@/pages/Collections'
 import Documents from '@/pages/Documents'
 import Views from '@/pages/Views'
+import Pages from '@/pages/Pages'
+import PageDisplay from '@/pages/PageDisplay'
+import PageBuilder from '@/components/PageBuilder'
 import Users from '@/pages/Users'
 import Groups from '@/pages/Groups'
 import ApiKeys from '@/pages/ApiKeys'
@@ -59,6 +62,9 @@ function App() {
         <Route path="/collections/:collectionName" element={<Documents />} />
         <Route path="/views" element={<Views />} />
         <Route path="/views/:collectionName" element={<Views />} />
+        <Route path="/pages" element={<Pages />} />
+        <Route path="/pages/:pageId/edit" element={<PageBuilder />} />
+        <Route path="/p/:slug" element={<PageDisplay />} />
         <Route path="/users" element={<Users />} />
         <Route path="/groups" element={<Groups />} />
         <Route path="/api-keys" element={<ApiKeys />} />

+ 132 - 0
webui/src/components/PageBuilder/ComponentPalette.tsx

@@ -0,0 +1,132 @@
+// Component palette - lists available components to add to the page
+
+import type { ComponentType } from '@/types'
+
+interface ComponentDefinition {
+  type: ComponentType
+  label: string
+  description: string
+  icon: React.ReactNode
+  category: 'layout' | 'data'
+}
+
+const COMPONENTS: ComponentDefinition[] = [
+  {
+    type: 'header',
+    label: 'Header',
+    description: 'Page title and subtitle',
+    category: 'layout',
+    icon: (
+      <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 12h8m-8 6h16" />
+      </svg>
+    ),
+  },
+  {
+    type: 'text-block',
+    label: 'Text Block',
+    description: 'Rich text content',
+    category: 'layout',
+    icon: (
+      <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 10h16M4 14h16M4 18h16" />
+      </svg>
+    ),
+  },
+  {
+    type: 'spacer',
+    label: 'Spacer',
+    description: 'Add vertical spacing',
+    category: 'layout',
+    icon: (
+      <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+      </svg>
+    ),
+  },
+  {
+    type: 'document-list',
+    label: 'Document List',
+    description: 'Display documents from a view',
+    category: 'data',
+    icon: (
+      <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 10h16M4 14h16M4 18h16" />
+      </svg>
+    ),
+  },
+  {
+    type: 'stat-card',
+    label: 'Stat Card',
+    description: 'Display aggregated statistics',
+    category: 'data',
+    icon: (
+      <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
+      </svg>
+    ),
+  },
+]
+
+interface ComponentPaletteProps {
+  onAddComponent: (type: ComponentType) => void
+}
+
+export function ComponentPalette({ onAddComponent }: ComponentPaletteProps) {
+  const layoutComponents = COMPONENTS.filter((c) => c.category === 'layout')
+  const dataComponents = COMPONENTS.filter((c) => c.category === 'data')
+
+  return (
+    <div className="space-y-6">
+      {/* Layout Components */}
+      <div>
+        <h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
+          Layout
+        </h3>
+        <div className="space-y-2">
+          {layoutComponents.map((component) => (
+            <button
+              key={component.type}
+              onClick={() => onAddComponent(component.type)}
+              className="flex w-full items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 text-left transition hover:border-primary-300 hover:shadow-sm"
+            >
+              <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-gray-100 text-gray-600">
+                {component.icon}
+              </div>
+              <div className="min-w-0 flex-1">
+                <p className="font-medium text-gray-900">{component.label}</p>
+                <p className="truncate text-xs text-gray-500">{component.description}</p>
+              </div>
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {/* Data Components */}
+      <div>
+        <h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
+          Data
+        </h3>
+        <div className="space-y-2">
+          {dataComponents.map((component) => (
+            <button
+              key={component.type}
+              onClick={() => onAddComponent(component.type)}
+              className="flex w-full items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 text-left transition hover:border-primary-300 hover:shadow-sm"
+            >
+              <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-blue-100 text-blue-600">
+                {component.icon}
+              </div>
+              <div className="min-w-0 flex-1">
+                <p className="font-medium text-gray-900">{component.label}</p>
+                <p className="truncate text-xs text-gray-500">{component.description}</p>
+              </div>
+            </button>
+          ))}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default ComponentPalette

+ 442 - 0
webui/src/components/PageBuilder/PropertyPanel.tsx

@@ -0,0 +1,442 @@
+// Property panel - edit selected component configuration
+
+import { useState, useEffect } from 'react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Input from '@/components/Input'
+import Button from '@/components/Button'
+import type {
+  LayoutComponent,
+  HeaderConfig,
+  TextBlockConfig,
+  SpacerConfig,
+  DocumentListConfig,
+  StatCardConfig,
+} from '@/types'
+
+interface View {
+  id: string
+  name: string
+  collection_name: string
+}
+
+interface PropertyPanelProps {
+  component: LayoutComponent | null
+  onUpdate: (component: LayoutComponent) => void
+  onDelete: () => void
+  onClose: () => void
+}
+
+export function PropertyPanel({
+  component,
+  onUpdate,
+  onDelete,
+  onClose,
+}: PropertyPanelProps) {
+  const { currentWorkspace } = useWorkspace()
+  const [views, setViews] = useState<View[]>([])
+  const [localConfig, setLocalConfig] = useState<Record<string, unknown>>({})
+
+  // Fetch views for view selector
+  useEffect(() => {
+    const fetchViews = async () => {
+      if (!currentWorkspace) return
+      try {
+        const response = await apiClient.get<{ views: View[] }>(
+          `/workspaces/${currentWorkspace.id}/views`
+        )
+        setViews(response.views || [])
+      } catch (err) {
+        console.error('Failed to fetch views:', err)
+      }
+    }
+    fetchViews()
+  }, [currentWorkspace])
+
+  // Update local config when component changes
+  useEffect(() => {
+    if (component) {
+      setLocalConfig({ ...component.config })
+    }
+  }, [component])
+
+  if (!component) {
+    return (
+      <div className="p-4 text-center text-gray-500">
+        Select a component to edit its properties
+      </div>
+    )
+  }
+
+  const updateConfig = (key: string, value: unknown) => {
+    const newConfig = { ...localConfig, [key]: value }
+    setLocalConfig(newConfig)
+    onUpdate({ ...component, config: newConfig })
+  }
+
+  // Render property editors based on component type
+  const renderProperties = () => {
+    switch (component.type) {
+      case 'header':
+        return <HeaderProperties config={localConfig as unknown as HeaderConfig} onChange={updateConfig} />
+      case 'text-block':
+        return <TextBlockProperties config={localConfig as unknown as TextBlockConfig} onChange={updateConfig} />
+      case 'spacer':
+        return <SpacerProperties config={localConfig as unknown as SpacerConfig} onChange={updateConfig} />
+      case 'document-list':
+        return (
+          <DocumentListProperties
+            config={localConfig as unknown as DocumentListConfig}
+            onChange={updateConfig}
+            views={views}
+          />
+        )
+      case 'stat-card':
+        return (
+          <StatCardProperties
+            config={localConfig as unknown as StatCardConfig}
+            onChange={updateConfig}
+            views={views}
+          />
+        )
+      default:
+        return <div className="text-gray-500">No properties available</div>
+    }
+  }
+
+  return (
+    <div className="flex h-full flex-col">
+      {/* Header */}
+      <div className="flex items-center justify-between border-b px-4 py-3">
+        <h3 className="font-medium text-gray-900">
+          {component.type.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
+        </h3>
+        <button
+          onClick={onClose}
+          className="rounded p-1 text-gray-400 hover:bg-gray-100 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="M6 18L18 6M6 6l12 12" />
+          </svg>
+        </button>
+      </div>
+
+      {/* Properties */}
+      <div className="flex-1 overflow-y-auto p-4">
+        <div className="space-y-4">{renderProperties()}</div>
+      </div>
+
+      {/* Position Controls */}
+      <div className="border-t p-4">
+        <h4 className="mb-3 text-sm font-medium text-gray-700">Position & Size</h4>
+        <div className="grid grid-cols-2 gap-3">
+          <div>
+            <label className="mb-1 block text-xs text-gray-500">Width (columns)</label>
+            <select
+              value={component.position.width}
+              onChange={(e) => onUpdate({
+                ...component,
+                position: { ...component.position, width: parseInt(e.target.value) }
+              })}
+              className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
+            >
+              {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((n) => (
+                <option key={n} value={n}>{n} col{n !== 1 ? 's' : ''}</option>
+              ))}
+            </select>
+          </div>
+          <div>
+            <label className="mb-1 block text-xs text-gray-500">Height (rows)</label>
+            <select
+              value={component.position.height}
+              onChange={(e) => onUpdate({
+                ...component,
+                position: { ...component.position, height: parseInt(e.target.value) }
+              })}
+              className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
+            >
+              {[1, 2, 3, 4, 5, 6].map((n) => (
+                <option key={n} value={n}>{n} row{n !== 1 ? 's' : ''}</option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </div>
+
+      {/* Delete button */}
+      <div className="border-t p-4">
+        <Button variant="danger" className="w-full" onClick={onDelete}>
+          Delete Component
+        </Button>
+      </div>
+    </div>
+  )
+}
+
+// Header properties
+function HeaderProperties({
+  config,
+  onChange,
+}: {
+  config: HeaderConfig
+  onChange: (key: string, value: unknown) => void
+}) {
+  return (
+    <>
+      <Input
+        label="Title"
+        value={config.title || ''}
+        onChange={(e) => onChange('title', e.target.value)}
+        placeholder="Page Title"
+      />
+      <Input
+        label="Subtitle"
+        value={config.subtitle || ''}
+        onChange={(e) => onChange('subtitle', e.target.value)}
+        placeholder="Optional subtitle"
+      />
+      <div>
+        <label className="flex items-center gap-2">
+          <input
+            type="checkbox"
+            checked={config.showIcon || false}
+            onChange={(e) => onChange('showIcon', e.target.checked)}
+            className="h-4 w-4 rounded border-gray-300"
+          />
+          <span className="text-sm text-gray-700">Show icon</span>
+        </label>
+      </div>
+      {config.showIcon && (
+        <div>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">Icon</label>
+          <select
+            value={config.icon || ''}
+            onChange={(e) => onChange('icon', e.target.value)}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+          >
+            <option value="">Select icon...</option>
+            <option value="home">Home</option>
+            <option value="chart">Chart</option>
+            <option value="users">Users</option>
+            <option value="star">Star</option>
+          </select>
+        </div>
+      )}
+    </>
+  )
+}
+
+// Text block properties
+function TextBlockProperties({
+  config,
+  onChange,
+}: {
+  config: TextBlockConfig
+  onChange: (key: string, value: unknown) => void
+}) {
+  return (
+    <>
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">Content</label>
+        <textarea
+          value={config.content || ''}
+          onChange={(e) => onChange('content', e.target.value)}
+          placeholder="Enter text content..."
+          rows={5}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+        />
+      </div>
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">Alignment</label>
+        <select
+          value={config.alignment || 'left'}
+          onChange={(e) => onChange('alignment', e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2"
+        >
+          <option value="left">Left</option>
+          <option value="center">Center</option>
+          <option value="right">Right</option>
+        </select>
+      </div>
+    </>
+  )
+}
+
+// Spacer properties
+function SpacerProperties({
+  config,
+  onChange,
+}: {
+  config: SpacerConfig
+  onChange: (key: string, value: unknown) => void
+}) {
+  return (
+    <div>
+      <label className="mb-1.5 block text-sm font-medium text-gray-700">Size</label>
+      <select
+        value={config.size || 'md'}
+        onChange={(e) => onChange('size', e.target.value)}
+        className="w-full rounded-lg border border-gray-300 px-3 py-2"
+      >
+        <option value="sm">Small</option>
+        <option value="md">Medium</option>
+        <option value="lg">Large</option>
+        <option value="xl">Extra Large</option>
+      </select>
+    </div>
+  )
+}
+
+// Document list properties
+function DocumentListProperties({
+  config,
+  onChange,
+  views,
+}: {
+  config: DocumentListConfig
+  onChange: (key: string, value: unknown) => void
+  views: View[]
+}) {
+  return (
+    <>
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">View</label>
+        <select
+          value={config.viewId || ''}
+          onChange={(e) => onChange('viewId', e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2"
+        >
+          <option value="">Select a view...</option>
+          {views.map((view) => (
+            <option key={view.id} value={view.id}>
+              {view.name} ({view.collection_name})
+            </option>
+          ))}
+        </select>
+      </div>
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">Display Mode</label>
+        <select
+          value={config.displayMode || 'table'}
+          onChange={(e) => onChange('displayMode', e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2"
+        >
+          <option value="table">Table</option>
+          <option value="cards">Cards</option>
+          <option value="kanban">Kanban</option>
+        </select>
+      </div>
+      <div>
+        <label className="flex items-center gap-2">
+          <input
+            type="checkbox"
+            checked={config.showCreateButton !== false}
+            onChange={(e) => onChange('showCreateButton', e.target.checked)}
+            className="h-4 w-4 rounded border-gray-300"
+          />
+          <span className="text-sm text-gray-700">Show create button</span>
+        </label>
+      </div>
+      <Input
+        label="Page Size"
+        type="number"
+        value={String(config.pageSize || 20)}
+        onChange={(e) => onChange('pageSize', parseInt(e.target.value) || 20)}
+        placeholder="20"
+      />
+    </>
+  )
+}
+
+// Stat card properties
+function StatCardProperties({
+  config,
+  onChange,
+  views,
+}: {
+  config: StatCardConfig
+  onChange: (key: string, value: unknown) => void
+  views: View[]
+}) {
+  return (
+    <>
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">View</label>
+        <select
+          value={config.viewId || ''}
+          onChange={(e) => onChange('viewId', e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2"
+        >
+          <option value="">Select a view...</option>
+          {views.map((view) => (
+            <option key={view.id} value={view.id}>
+              {view.name} ({view.collection_name})
+            </option>
+          ))}
+        </select>
+      </div>
+      <Input
+        label="Label"
+        value={config.label || ''}
+        onChange={(e) => onChange('label', e.target.value)}
+        placeholder="Total Records"
+      />
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">Aggregation</label>
+        <select
+          value={config.aggregation || 'count'}
+          onChange={(e) => onChange('aggregation', e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2"
+        >
+          <option value="count">Count</option>
+          <option value="sum">Sum</option>
+          <option value="avg">Average</option>
+          <option value="min">Minimum</option>
+          <option value="max">Maximum</option>
+        </select>
+      </div>
+      {config.aggregation !== 'count' && (
+        <Input
+          label="Field"
+          value={config.field || ''}
+          onChange={(e) => onChange('field', e.target.value)}
+          placeholder="Field name for aggregation"
+        />
+      )}
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">Icon</label>
+        <select
+          value={config.icon || ''}
+          onChange={(e) => onChange('icon', e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2"
+        >
+          <option value="">Default</option>
+          <option value="users">Users</option>
+          <option value="chart">Chart</option>
+          <option value="document">Document</option>
+          <option value="currency">Currency</option>
+          <option value="check">Check</option>
+          <option value="trending">Trending</option>
+        </select>
+      </div>
+      <div>
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">Color</label>
+        <select
+          value={config.color || 'gray'}
+          onChange={(e) => onChange('color', e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2"
+        >
+          <option value="gray">Gray</option>
+          <option value="blue">Blue</option>
+          <option value="green">Green</option>
+          <option value="yellow">Yellow</option>
+          <option value="red">Red</option>
+          <option value="purple">Purple</option>
+          <option value="indigo">Indigo</option>
+        </select>
+      </div>
+    </>
+  )
+}
+
+export default PropertyPanel

+ 394 - 0
webui/src/components/PageBuilder/index.tsx

@@ -0,0 +1,394 @@
+// PageBuilder - Main page editor component
+// Provides a visual interface for building pages with components
+
+import { useState, useCallback, useEffect } 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'
+import { GroupMultiSelect } from '@/components/GroupMultiSelect'
+import PageRenderer from '@/components/PageRenderer'
+import ComponentPalette from './ComponentPalette'
+import PropertyPanel from './PropertyPanel'
+import type { Page, LayoutComponent, ComponentType, PageSettings } from '@/types'
+
+export function PageBuilder() {
+  const { pageId } = useParams<{ pageId: string }>()
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+
+  const [page, setPage] = useState<Page | null>(null)
+  const [selectedComponent, setSelectedComponent] = useState<LayoutComponent | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
+  const [isSaving, setIsSaving] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  // Settings panel state
+  const [showSettings, setShowSettings] = useState(false)
+  const [name, setName] = useState('')
+  const [slug, setSlug] = useState('')
+  const [showInSidebar, setShowInSidebar] = useState(true)
+  const [icon, setIcon] = useState('')
+  const [menuOrder, setMenuOrder] = useState(0)
+  const [sharedWithGroups, setSharedWithGroups] = useState<string[]>([])
+
+  // Fetch page
+  useEffect(() => {
+    const fetchPage = async () => {
+      if (!currentWorkspace || !pageId) {
+        setIsLoading(false)
+        return
+      }
+
+      setIsLoading(true)
+      setError(null)
+
+      try {
+        const pageData = await apiClient.get<Page>(
+          `/workspaces/${currentWorkspace.id}/pages/${pageId}`
+        )
+        setPage(pageData)
+        setName(pageData.name)
+        setSlug(pageData.slug)
+        setShowInSidebar(pageData.settings?.show_in_sidebar ?? true)
+        setIcon(pageData.settings?.icon || '')
+        setMenuOrder(pageData.settings?.menu_order ?? 0)
+        setSharedWithGroups(pageData.shared_with_groups || [])
+      } catch (err) {
+        setError(err instanceof Error ? err.message : 'Failed to load page')
+      } finally {
+        setIsLoading(false)
+      }
+    }
+
+    fetchPage()
+  }, [currentWorkspace, pageId])
+
+  // Add a new component
+  const handleAddComponent = useCallback((type: ComponentType) => {
+    if (!page) return
+
+    const newComponent: LayoutComponent = {
+      id: crypto.randomUUID(),
+      type,
+      position: {
+        x: 0,
+        y: (page.layout?.components?.length || 0),
+        width: 12,
+        height: 1,
+      },
+      config: getDefaultConfig(type),
+    }
+
+    setPage({
+      ...page,
+      layout: {
+        ...page.layout,
+        version: page.layout?.version || 1,
+        grid_columns: page.layout?.grid_columns || 12,
+        components: [...(page.layout?.components || []), newComponent],
+      },
+    })
+
+    setSelectedComponent(newComponent)
+  }, [page])
+
+  // Update a component
+  const handleUpdateComponent = useCallback((updated: LayoutComponent) => {
+    if (!page) return
+
+    setPage({
+      ...page,
+      layout: {
+        ...page.layout,
+        version: page.layout?.version || 1,
+        grid_columns: page.layout?.grid_columns || 12,
+        components: (page.layout?.components || []).map((c) =>
+          c.id === updated.id ? updated : c
+        ),
+      },
+    })
+
+    setSelectedComponent(updated)
+  }, [page])
+
+  // Delete a component
+  const handleDeleteComponent = useCallback(() => {
+    if (!page || !selectedComponent) return
+
+    setPage({
+      ...page,
+      layout: {
+        ...page.layout,
+        version: page.layout?.version || 1,
+        grid_columns: page.layout?.grid_columns || 12,
+        components: (page.layout?.components || []).filter((c) => c.id !== selectedComponent.id),
+      },
+    })
+
+    setSelectedComponent(null)
+  }, [page, selectedComponent])
+
+  // Save page
+  const handleSave = useCallback(async () => {
+    if (!page || !currentWorkspace) return
+
+    setIsSaving(true)
+    setError(null)
+
+    try {
+      const settings: PageSettings = {
+        show_in_sidebar: showInSidebar,
+        icon: icon || undefined,
+        menu_order: menuOrder,
+      }
+
+      await apiClient.patch(`/workspaces/${currentWorkspace.id}/pages/${page.id}`, {
+        name,
+        slug,
+        layout: page.layout,
+        settings,
+      })
+
+      // Update sharing
+      await apiClient.patch(`/workspaces/${currentWorkspace.id}/pages/${page.id}/share`, {
+        shared_with_groups: sharedWithGroups,
+      })
+
+      // Navigate back to pages list
+      navigate('/pages')
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save page')
+    } finally {
+      setIsSaving(false)
+    }
+  }, [page, currentWorkspace, name, slug, showInSidebar, icon, menuOrder, sharedWithGroups, navigate])
+
+  // Loading state
+  if (isLoading) {
+    return (
+      <div className="flex h-screen items-center justify-center">
+        <svg
+          className="h-10 w-10 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>
+    )
+  }
+
+  // No workspace selected
+  if (!currentWorkspace) {
+    return (
+      <div className="rounded-lg bg-yellow-50 p-6 text-center">
+        <p className="text-yellow-800">Please select a workspace first.</p>
+      </div>
+    )
+  }
+
+  // Page not found
+  if (!page) {
+    return (
+      <div className="rounded-lg bg-red-50 p-6 text-center">
+        <p className="text-red-800">{error || 'Page not found'}</p>
+        <Button variant="secondary" className="mt-4" onClick={() => navigate('/pages')}>
+          Go to Pages
+        </Button>
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-[calc(100vh-4rem)] flex-col">
+      {/* Toolbar */}
+      <div className="flex items-center justify-between border-b bg-white px-4 py-3">
+        <div className="flex items-center gap-4">
+          <button
+            onClick={() => navigate('/pages')}
+            className="flex items-center gap-1 text-gray-600 hover:text-gray-900"
+          >
+            <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>
+            Back
+          </button>
+          <div className="h-6 w-px bg-gray-200" />
+          <h1 className="font-medium text-gray-900">Edit: {page.name}</h1>
+        </div>
+
+        <div className="flex items-center gap-2">
+          <Button
+            variant="secondary"
+            onClick={() => setShowSettings(!showSettings)}
+          >
+            <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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+            </svg>
+            Settings
+          </Button>
+          <Button
+            variant="secondary"
+            onClick={() => window.open(`/p/${page.slug}`, '_blank')}
+          >
+            <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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
+            </svg>
+            Preview
+          </Button>
+          <Button onClick={handleSave} isLoading={isSaving}>
+            <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="M5 13l4 4L19 7" />
+            </svg>
+            Save
+          </Button>
+        </div>
+      </div>
+
+      {/* Error message */}
+      {error && (
+        <div className="border-b bg-red-50 px-4 py-2 text-sm text-red-700">
+          {error}
+        </div>
+      )}
+
+      {/* Main content */}
+      <div className="flex flex-1 overflow-hidden">
+        {/* Left sidebar - Component palette */}
+        <div className="w-64 flex-shrink-0 overflow-y-auto border-r bg-gray-50 p-4">
+          <h2 className="mb-4 text-sm font-semibold text-gray-700">Components</h2>
+          <ComponentPalette onAddComponent={handleAddComponent} />
+        </div>
+
+        {/* Center - Canvas */}
+        <div className="flex-1 overflow-y-auto bg-gray-100 p-6">
+          <div className="mx-auto max-w-5xl rounded-lg bg-white p-6 shadow-sm">
+            <PageRenderer
+              page={page}
+              isEditing={true}
+              onComponentClick={setSelectedComponent}
+            />
+          </div>
+        </div>
+
+        {/* Right sidebar - Property panel or Settings */}
+        <div className="w-80 flex-shrink-0 overflow-y-auto border-l bg-white">
+          {showSettings ? (
+            <div className="p-4 space-y-6">
+              <div className="flex items-center justify-between">
+                <h2 className="font-medium text-gray-900">Page Settings</h2>
+                <button
+                  onClick={() => setShowSettings(false)}
+                  className="rounded p-1 text-gray-400 hover:bg-gray-100 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="M6 18L18 6M6 6l12 12" />
+                  </svg>
+                </button>
+              </div>
+
+              <Input
+                label="Page Name"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+              />
+
+              <Input
+                label="URL Slug"
+                value={slug}
+                onChange={(e) => setSlug(e.target.value)}
+              />
+
+              <div>
+                <label className="mb-1.5 block text-sm font-medium text-gray-700">Icon</label>
+                <select
+                  value={icon}
+                  onChange={(e) => setIcon(e.target.value)}
+                  className="w-full rounded-lg border border-gray-300 px-3 py-2"
+                >
+                  <option value="">None</option>
+                  <option value="home">Home</option>
+                  <option value="chart">Chart</option>
+                  <option value="users">Users</option>
+                  <option value="folder">Folder</option>
+                  <option value="document">Document</option>
+                  <option value="calendar">Calendar</option>
+                  <option value="settings">Settings</option>
+                  <option value="star">Star</option>
+                </select>
+              </div>
+
+              <Input
+                label="Menu Order"
+                type="number"
+                value={String(menuOrder)}
+                onChange={(e) => setMenuOrder(parseInt(e.target.value) || 0)}
+              />
+
+              <div>
+                <label className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    checked={showInSidebar}
+                    onChange={(e) => setShowInSidebar(e.target.checked)}
+                    className="h-4 w-4 rounded border-gray-300"
+                  />
+                  <span className="text-sm text-gray-700">Show in sidebar</span>
+                </label>
+              </div>
+
+              <div className="border-t pt-4">
+                <h3 className="mb-2 text-sm font-medium text-gray-700">Sharing</h3>
+                <p className="mb-3 text-xs text-gray-500">
+                  Share this page with groups
+                </p>
+                <GroupMultiSelect
+                  workspaceId={currentWorkspace.id}
+                  selectedGroups={sharedWithGroups}
+                  onChange={setSharedWithGroups}
+                />
+              </div>
+            </div>
+          ) : (
+            <PropertyPanel
+              component={selectedComponent}
+              onUpdate={handleUpdateComponent}
+              onDelete={handleDeleteComponent}
+              onClose={() => setSelectedComponent(null)}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Get default config for a component type
+function getDefaultConfig(type: ComponentType): Record<string, unknown> {
+  switch (type) {
+    case 'header':
+      return { title: 'New Header', subtitle: '', showIcon: false }
+    case 'text-block':
+      return { content: 'Enter your text here...', alignment: 'left' }
+    case 'spacer':
+      return { size: 'md' }
+    case 'document-list':
+      return { viewId: '', displayMode: 'table', showCreateButton: true, pageSize: 20 }
+    case 'stat-card':
+      return { viewId: '', aggregation: 'count', label: 'Total', color: 'gray' }
+    default:
+      return {}
+  }
+}
+
+export default PageBuilder

+ 413 - 0
webui/src/components/PageRenderer/DocumentListRenderer.tsx

@@ -0,0 +1,413 @@
+// Document list component renderer for Page Builder
+// Displays documents from a view in table, card, or kanban mode
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { usePermissions } from '@/hooks/usePermissions'
+import Button from '@/components/Button'
+import type { DocumentListConfig, Document } from '@/types'
+
+interface View {
+  id: string
+  name: string
+  collection_name: string
+  schema: {
+    fields?: Array<{
+      name: string
+      label: string
+      type: string
+    }>
+  }
+}
+
+interface DocumentListRendererProps {
+  config: DocumentListConfig
+  onCreateDocument?: () => void
+  onEditDocument?: (doc: Document) => void
+  onDeleteDocument?: (doc: Document) => void
+}
+
+export function DocumentListRenderer({
+  config,
+  onCreateDocument,
+  onEditDocument,
+  onDeleteDocument,
+}: DocumentListRendererProps) {
+  const { currentWorkspace } = useWorkspace()
+  const workspaceId = currentWorkspace?.id || ''
+  const { canCreateInCollection, canWriteCollection, canDeleteInCollection } = usePermissions({
+    workspaceId,
+  })
+
+  const [view, setView] = useState<View | null>(null)
+  const [documents, setDocuments] = useState<Document[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [page, setPage] = useState(1)
+  const [hasMore, setHasMore] = useState(false)
+
+  // Fetch view details
+  const fetchView = useCallback(async () => {
+    if (!workspaceId || !config.viewId) return null
+
+    try {
+      const viewData = await apiClient.get<View>(
+        `/workspaces/${workspaceId}/views/${config.viewId}`
+      )
+      return viewData
+    } catch (err) {
+      console.error('Failed to fetch view:', err)
+      return null
+    }
+  }, [workspaceId, config.viewId])
+
+  // Fetch documents
+  const fetchDocuments = useCallback(async (viewData: View | null) => {
+    if (!workspaceId || !viewData) {
+      setDocuments([])
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const pageSize = config.pageSize || 20
+      const response = await apiClient.get<{ documents: Document[]; total: number }>(
+        `/workspaces/${workspaceId}/collections/${viewData.collection_name}/documents?limit=${pageSize}&offset=${(page - 1) * pageSize}`
+      )
+      setDocuments(response.documents || [])
+      setHasMore((response.documents || []).length >= pageSize)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch documents')
+      setDocuments([])
+    } finally {
+      setIsLoading(false)
+    }
+  }, [workspaceId, config.pageSize, page])
+
+  useEffect(() => {
+    const loadData = async () => {
+      const viewData = await fetchView()
+      setView(viewData)
+      await fetchDocuments(viewData)
+    }
+    loadData()
+  }, [fetchView, fetchDocuments])
+
+  // Get visible columns
+  const visibleColumns = config.visibleColumns || view?.schema?.fields?.map((f) => f.name) || []
+  const fields = view?.schema?.fields || []
+  const collectionName = view?.collection_name || ''
+
+  // Render loading state
+  if (isLoading && documents.length === 0) {
+    return (
+      <div className="flex items-center justify-center py-8">
+        <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>
+    )
+  }
+
+  // Render error state
+  if (error) {
+    return (
+      <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
+        {error}
+      </div>
+    )
+  }
+
+  // Render table mode
+  if (config.displayMode === 'table' || !config.displayMode) {
+    return (
+      <div className="space-y-4">
+        {/* Header with create button */}
+        <div className="flex items-center justify-between">
+          <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
+          {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
+            <Button size="sm" onClick={onCreateDocument}>
+              <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
+            </Button>
+          )}
+        </div>
+
+        {/* Table */}
+        <div className="overflow-x-auto rounded-lg border border-gray-200">
+          <table className="min-w-full divide-y divide-gray-200">
+            <thead className="bg-gray-50">
+              <tr>
+                {visibleColumns.map((colName) => {
+                  const field = fields.find((f) => f.name === colName)
+                  return (
+                    <th
+                      key={colName}
+                      className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
+                    >
+                      {field?.label || colName}
+                    </th>
+                  )
+                })}
+                <th className="px-4 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.length === 0 ? (
+                <tr>
+                  <td colSpan={visibleColumns.length + 1} className="px-4 py-8 text-center text-gray-500">
+                    No documents found
+                  </td>
+                </tr>
+              ) : (
+                documents.map((doc) => (
+                  <tr key={doc.id} className="hover:bg-gray-50">
+                    {visibleColumns.map((colName) => (
+                      <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
+                        {formatCellValue(doc.data[colName])}
+                      </td>
+                    ))}
+                    <td className="whitespace-nowrap px-4 py-3 text-right text-sm">
+                      <div className="flex justify-end gap-2">
+                        {canWriteCollection(collectionName) && (
+                          <button
+                            onClick={() => onEditDocument?.(doc)}
+                            className="text-primary-600 hover:text-primary-800"
+                          >
+                            Edit
+                          </button>
+                        )}
+                        {canDeleteInCollection(collectionName) && (
+                          <button
+                            onClick={() => onDeleteDocument?.(doc)}
+                            className="text-red-600 hover:text-red-800"
+                          >
+                            Delete
+                          </button>
+                        )}
+                      </div>
+                    </td>
+                  </tr>
+                ))
+              )}
+            </tbody>
+          </table>
+        </div>
+
+        {/* Pagination */}
+        {(page > 1 || hasMore) && (
+          <div className="flex items-center justify-between">
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => setPage((p) => Math.max(1, p - 1))}
+              disabled={page === 1}
+            >
+              Previous
+            </Button>
+            <span className="text-sm text-gray-600">Page {page}</span>
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => setPage((p) => p + 1)}
+              disabled={!hasMore}
+            >
+              Next
+            </Button>
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  // Render card mode
+  if (config.displayMode === 'cards') {
+    const titleField = config.cardTemplate?.titleField || visibleColumns[0]
+    const subtitleField = config.cardTemplate?.subtitleField
+    const badgeField = config.cardTemplate?.badgeField
+
+    return (
+      <div className="space-y-4">
+        {/* Header with create button */}
+        <div className="flex items-center justify-between">
+          <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
+          {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
+            <Button size="sm" onClick={onCreateDocument}>
+              <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
+            </Button>
+          )}
+        </div>
+
+        {/* Card grid */}
+        {documents.length === 0 ? (
+          <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
+            <p className="text-gray-500">No documents found</p>
+          </div>
+        ) : (
+          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+            {documents.map((doc) => (
+              <div
+                key={doc.id}
+                className="group 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 className="flex-1 min-w-0">
+                    <h4 className="truncate font-medium text-gray-900">
+                      {formatCellValue(doc.data[titleField])}
+                    </h4>
+                    {subtitleField && (
+                      <p className="mt-1 truncate text-sm text-gray-500">
+                        {formatCellValue(doc.data[subtitleField])}
+                      </p>
+                    )}
+                  </div>
+                  {badgeField && Boolean(doc.data[badgeField]) && (
+                    <span className="ml-2 inline-flex flex-shrink-0 items-center rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-medium text-primary-800">
+                      {formatCellValue(doc.data[badgeField])}
+                    </span>
+                  )}
+                </div>
+
+                {/* Card actions */}
+                <div className="mt-4 flex justify-end gap-2 opacity-0 transition group-hover:opacity-100">
+                  {canWriteCollection(collectionName) && (
+                    <button
+                      onClick={() => onEditDocument?.(doc)}
+                      className="rounded px-2 py-1 text-xs text-primary-600 hover:bg-primary-50"
+                    >
+                      Edit
+                    </button>
+                  )}
+                  {canDeleteInCollection(collectionName) && (
+                    <button
+                      onClick={() => onDeleteDocument?.(doc)}
+                      className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
+                    >
+                      Delete
+                    </button>
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    )
+  }
+
+  // Render kanban mode (simplified version)
+  if (config.displayMode === 'kanban') {
+    const groupField = config.kanbanGroupField || visibleColumns[0]
+    const titleField = config.cardTemplate?.titleField || visibleColumns[0]
+
+    // Group documents by the kanban field
+    const groups: Record<string, Document[]> = {}
+    documents.forEach((doc) => {
+      const groupValue = String(doc.data[groupField] || 'Unassigned')
+      if (!groups[groupValue]) {
+        groups[groupValue] = []
+      }
+      groups[groupValue].push(doc)
+    })
+
+    return (
+      <div className="space-y-4">
+        {/* Header with create button */}
+        <div className="flex items-center justify-between">
+          <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
+          {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
+            <Button size="sm" onClick={onCreateDocument}>
+              <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
+            </Button>
+          )}
+        </div>
+
+        {/* Kanban columns */}
+        <div className="flex gap-4 overflow-x-auto pb-4">
+          {Object.entries(groups).map(([groupName, groupDocs]) => (
+            <div
+              key={groupName}
+              className="flex w-72 flex-shrink-0 flex-col rounded-lg bg-gray-100"
+            >
+              <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
+                <h4 className="font-medium text-gray-900">{groupName}</h4>
+                <span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">
+                  {groupDocs.length}
+                </span>
+              </div>
+              <div className="flex-1 space-y-2 overflow-y-auto p-2">
+                {groupDocs.map((doc) => (
+                  <div
+                    key={doc.id}
+                    className="group rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition hover:shadow-md"
+                  >
+                    <p className="font-medium text-gray-900">
+                      {formatCellValue(doc.data[titleField])}
+                    </p>
+                    <div className="mt-2 flex justify-end gap-1 opacity-0 transition group-hover:opacity-100">
+                      {canWriteCollection(collectionName) && (
+                        <button
+                          onClick={() => onEditDocument?.(doc)}
+                          className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+                        >
+                          <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>
+                      )}
+                    </div>
+                  </div>
+                ))}
+              </div>
+            </div>
+          ))}
+        </div>
+      </div>
+    )
+  }
+
+  return null
+}
+
+// Helper function to format cell values for display
+function formatCellValue(value: unknown): string {
+  if (value === null || value === undefined) {
+    return '-'
+  }
+  if (typeof value === 'boolean') {
+    return value ? 'Yes' : 'No'
+  }
+  if (value instanceof Date) {
+    return value.toLocaleDateString()
+  }
+  if (typeof value === 'object') {
+    return JSON.stringify(value)
+  }
+  return String(value)
+}
+
+export default DocumentListRenderer

+ 59 - 0
webui/src/components/PageRenderer/HeaderRenderer.tsx

@@ -0,0 +1,59 @@
+// Header component renderer for Page Builder
+
+import type { HeaderConfig } from '@/types'
+
+interface HeaderRendererProps {
+  config: HeaderConfig
+}
+
+// Icon renderer for headers
+function HeaderIcon({ icon, className = 'h-8 w-8' }: { icon?: string; className?: string }) {
+  switch (icon) {
+    case 'home':
+      return (
+        <svg className={className} 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>
+      )
+    case 'chart':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+        </svg>
+      )
+    case 'users':
+      return (
+        <svg className={className} 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>
+      )
+    case 'star':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
+        </svg>
+      )
+    default:
+      return null
+  }
+}
+
+export function HeaderRenderer({ config }: HeaderRendererProps) {
+  return (
+    <div className="flex items-center gap-4">
+      {config.showIcon && config.icon && (
+        <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-primary-100 text-primary-600">
+          <HeaderIcon icon={config.icon} />
+        </div>
+      )}
+      <div>
+        <h1 className="text-2xl font-bold text-gray-900">{config.title}</h1>
+        {config.subtitle && (
+          <p className="mt-1 text-gray-600">{config.subtitle}</p>
+        )}
+      </div>
+    </div>
+  )
+}
+
+export default HeaderRenderer

+ 20 - 0
webui/src/components/PageRenderer/SpacerRenderer.tsx

@@ -0,0 +1,20 @@
+// Spacer component renderer for Page Builder
+
+import type { SpacerConfig } from '@/types'
+
+interface SpacerRendererProps {
+  config: SpacerConfig
+}
+
+export function SpacerRenderer({ config }: SpacerRendererProps) {
+  const sizeClasses = {
+    sm: 'h-4',
+    md: 'h-8',
+    lg: 'h-12',
+    xl: 'h-16',
+  }
+
+  return <div className={sizeClasses[config.size || 'md']} />
+}
+
+export default SpacerRenderer

+ 215 - 0
webui/src/components/PageRenderer/StatCardRenderer.tsx

@@ -0,0 +1,215 @@
+// Stat card component renderer for Page Builder
+// Displays aggregated statistics from a view
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import type { StatCardConfig } from '@/types'
+
+interface View {
+  id: string
+  name: string
+  collection_name: string
+}
+
+interface StatCardRendererProps {
+  config: StatCardConfig
+}
+
+// Icon renderer for stat cards
+function StatIcon({ icon, className = 'h-6 w-6' }: { icon?: string; className?: string }) {
+  switch (icon) {
+    case 'users':
+      return (
+        <svg className={className} 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>
+      )
+    case 'chart':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+        </svg>
+      )
+    case 'document':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
+      )
+    case 'currency':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+        </svg>
+      )
+    case 'check':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
+        </svg>
+      )
+    case 'trending':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
+        </svg>
+      )
+    default:
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
+        </svg>
+      )
+  }
+}
+
+export function StatCardRenderer({ config }: StatCardRendererProps) {
+  const { currentWorkspace } = useWorkspace()
+  const workspaceId = currentWorkspace?.id || ''
+
+  const [value, setValue] = useState<number | string | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+
+  // Fetch and calculate the statistic
+  const fetchStat = useCallback(async () => {
+    if (!workspaceId || !config.viewId) {
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      // First get the view to know the collection
+      const view = await apiClient.get<View>(
+        `/workspaces/${workspaceId}/views/${config.viewId}`
+      )
+
+      // Fetch documents to calculate aggregation
+      // In a production system, this would be a dedicated aggregation endpoint
+      const response = await apiClient.get<{ documents: Array<{ data: Record<string, unknown> }>; total: number }>(
+        `/workspaces/${workspaceId}/collections/${view.collection_name}/documents?limit=1000`
+      )
+
+      const documents = response.documents || []
+
+      // Calculate aggregation based on type
+      let result: number | string
+
+      switch (config.aggregation) {
+        case 'count':
+          result = response.total || documents.length
+          break
+        case 'sum':
+          if (!config.field) {
+            result = 0
+          } else {
+            result = documents.reduce((acc, doc) => {
+              const val = Number(doc.data[config.field!]) || 0
+              return acc + val
+            }, 0)
+          }
+          break
+        case 'avg':
+          if (!config.field || documents.length === 0) {
+            result = 0
+          } else {
+            const sum = documents.reduce((acc, doc) => {
+              const val = Number(doc.data[config.field!]) || 0
+              return acc + val
+            }, 0)
+            result = Math.round((sum / documents.length) * 100) / 100
+          }
+          break
+        case 'min':
+          if (!config.field || documents.length === 0) {
+            result = 0
+          } else {
+            result = Math.min(
+              ...documents.map((doc) => Number(doc.data[config.field!]) || 0)
+            )
+          }
+          break
+        case 'max':
+          if (!config.field || documents.length === 0) {
+            result = 0
+          } else {
+            result = Math.max(
+              ...documents.map((doc) => Number(doc.data[config.field!]) || 0)
+            )
+          }
+          break
+        default:
+          result = 0
+      }
+
+      setValue(result)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch statistic')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [workspaceId, config.viewId, config.aggregation, config.field])
+
+  useEffect(() => {
+    fetchStat()
+  }, [fetchStat])
+
+  // Color classes
+  const colorClasses = {
+    blue: 'bg-blue-100 text-blue-600',
+    green: 'bg-green-100 text-green-600',
+    yellow: 'bg-yellow-100 text-yellow-600',
+    red: 'bg-red-100 text-red-600',
+    purple: 'bg-purple-100 text-purple-600',
+    indigo: 'bg-indigo-100 text-indigo-600',
+    gray: 'bg-gray-100 text-gray-600',
+  }
+
+  const iconColorClass = colorClasses[config.color as keyof typeof colorClasses] || colorClasses.gray
+
+  return (
+    <div className="rounded-lg border border-gray-200 bg-white p-6">
+      <div className="flex items-center gap-4">
+        <div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg ${iconColorClass}`}>
+          <StatIcon icon={config.icon} />
+        </div>
+        <div className="min-w-0 flex-1">
+          <p className="text-sm font-medium text-gray-500">{config.label}</p>
+          {isLoading ? (
+            <div className="mt-1 h-8 w-20 animate-pulse rounded bg-gray-200" />
+          ) : error ? (
+            <p className="mt-1 text-sm text-red-600">Error</p>
+          ) : (
+            <p className="mt-1 text-2xl font-bold text-gray-900">
+              {formatStatValue(value)}
+            </p>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Helper function to format stat values
+function formatStatValue(value: number | string | null): string {
+  if (value === null || value === undefined) {
+    return '-'
+  }
+  if (typeof value === 'number') {
+    // Format large numbers with commas
+    if (value >= 1000) {
+      return value.toLocaleString()
+    }
+    // Round decimals to 2 places
+    if (!Number.isInteger(value)) {
+      return value.toFixed(2)
+    }
+  }
+  return String(value)
+}
+
+export default StatCardRenderer

+ 27 - 0
webui/src/components/PageRenderer/TextBlockRenderer.tsx

@@ -0,0 +1,27 @@
+// Text block component renderer for Page Builder
+
+import type { TextBlockConfig } from '@/types'
+
+interface TextBlockRendererProps {
+  config: TextBlockConfig
+}
+
+export function TextBlockRenderer({ config }: TextBlockRendererProps) {
+  const alignmentClasses = {
+    left: 'text-left',
+    center: 'text-center',
+    right: 'text-right',
+  }
+
+  return (
+    <div className={`prose prose-gray max-w-none ${alignmentClasses[config.alignment || 'left']}`}>
+      {/* Simple text rendering - could be extended to support markdown */}
+      <div
+        className="whitespace-pre-wrap text-gray-700"
+        dangerouslySetInnerHTML={{ __html: config.content }}
+      />
+    </div>
+  )
+}
+
+export default TextBlockRenderer

+ 245 - 0
webui/src/components/PageRenderer/index.tsx

@@ -0,0 +1,245 @@
+// Main PageRenderer component - orchestrates rendering page layouts
+// Filters components based on user permissions for underlying views/collections
+
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { usePermissions } from '@/hooks/usePermissions'
+import HeaderRenderer from './HeaderRenderer'
+import TextBlockRenderer from './TextBlockRenderer'
+import SpacerRenderer from './SpacerRenderer'
+import DocumentListRenderer from './DocumentListRenderer'
+import StatCardRenderer from './StatCardRenderer'
+import type {
+  Page,
+  LayoutComponent,
+  HeaderConfig,
+  TextBlockConfig,
+  SpacerConfig,
+  DocumentListConfig,
+  StatCardConfig,
+} from '@/types'
+
+interface View {
+  id: string
+  collection_name: string
+}
+
+interface PageRendererProps {
+  page: Page
+  isEditing?: boolean
+  onComponentClick?: (component: LayoutComponent) => void
+}
+
+// Cache for view collection lookups
+const viewCollectionCache: Record<string, string> = {}
+
+export function PageRenderer({ page, isEditing = false, onComponentClick }: PageRendererProps) {
+  const { currentWorkspace } = useWorkspace()
+  const workspaceId = currentWorkspace?.id || ''
+  const { canReadCollection } = usePermissions({ workspaceId })
+
+  const [visibleComponents, setVisibleComponents] = useState<LayoutComponent[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+
+  // Get collection name for a view (with caching)
+  const getViewCollection = useCallback(async (viewId: string): Promise<string | null> => {
+    if (!workspaceId) return null
+
+    // Check cache
+    const cacheKey = `${workspaceId}:${viewId}`
+    if (viewCollectionCache[cacheKey]) {
+      return viewCollectionCache[cacheKey]
+    }
+
+    try {
+      const view = await apiClient.get<View>(
+        `/workspaces/${workspaceId}/views/${viewId}`
+      )
+      viewCollectionCache[cacheKey] = view.collection_name
+      return view.collection_name
+    } catch {
+      return null
+    }
+  }, [workspaceId])
+
+  // Filter components based on permissions
+  const filterComponents = useCallback(async (components: LayoutComponent[]): Promise<LayoutComponent[]> => {
+    const filtered: LayoutComponent[] = []
+
+    for (const component of components) {
+      // Core components are always visible
+      if (['header', 'text-block', 'spacer'].includes(component.type)) {
+        filtered.push(component)
+        continue
+      }
+
+      // Data components need collection read access
+      if (component.type === 'document-list' || component.type === 'stat-card') {
+        const config = component.config as unknown as { viewId?: string }
+        if (config.viewId) {
+          const collectionName = await getViewCollection(config.viewId)
+          if (collectionName && canReadCollection(collectionName)) {
+            filtered.push(component)
+          }
+        }
+        continue
+      }
+
+      // Unknown component types are passed through in edit mode
+      if (isEditing) {
+        filtered.push(component)
+      }
+    }
+
+    return filtered
+  }, [getViewCollection, canReadCollection, isEditing])
+
+  // Load and filter components
+  useEffect(() => {
+    const loadComponents = async () => {
+      setIsLoading(true)
+      const components = page.layout?.components || []
+      const filtered = await filterComponents(components)
+      setVisibleComponents(filtered)
+      setIsLoading(false)
+    }
+
+    loadComponents()
+  }, [page.layout?.components, filterComponents])
+
+  // Sort components by position (y first, then x)
+  const sortedComponents = useMemo(() => {
+    return [...visibleComponents].sort((a, b) => {
+      if (a.position.y !== b.position.y) {
+        return a.position.y - b.position.y
+      }
+      return a.position.x - b.position.x
+    })
+  }, [visibleComponents])
+
+  // Render a single component
+  const renderComponent = (component: LayoutComponent) => {
+    const style = isEditing ? {
+      gridColumn: `${component.position.x + 1} / span ${component.position.width}`,
+      gridRow: `${component.position.y + 1} / span ${component.position.height}`,
+    } : undefined
+
+    const wrapperClass = isEditing
+      ? 'cursor-pointer rounded border border-dashed border-transparent hover:border-primary-300 hover:bg-primary-50/50 transition'
+      : ''
+
+    const handleClick = () => {
+      if (isEditing && onComponentClick) {
+        onComponentClick(component)
+      }
+    }
+
+    const content = (() => {
+      switch (component.type) {
+        case 'header':
+          return <HeaderRenderer config={component.config as unknown as HeaderConfig} />
+        case 'text-block':
+          return <TextBlockRenderer config={component.config as unknown as TextBlockConfig} />
+        case 'spacer':
+          return <SpacerRenderer config={component.config as unknown as SpacerConfig} />
+        case 'document-list':
+          return <DocumentListRenderer config={component.config as unknown as DocumentListConfig} />
+        case 'stat-card':
+          return <StatCardRenderer config={component.config as unknown as StatCardConfig} />
+        default:
+          return (
+            <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500">
+              Unknown component type: {component.type}
+            </div>
+          )
+      }
+    })()
+
+    return (
+      <div
+        key={component.id}
+        className={wrapperClass}
+        style={style}
+        onClick={handleClick}
+      >
+        {content}
+      </div>
+    )
+  }
+
+  // Render loading state
+  if (isLoading) {
+    return (
+      <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>
+    )
+  }
+
+  // Render empty state
+  if (sortedComponents.length === 0) {
+    return (
+      <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">
+          {isEditing ? 'Add components to build your page' : 'This page has no content'}
+        </p>
+      </div>
+    )
+  }
+
+  // Render in grid mode when editing, or stacked mode for display
+  if (isEditing) {
+    return (
+      <div
+        className="grid gap-4"
+        style={{
+          gridTemplateColumns: `repeat(${page.layout?.grid_columns || 12}, 1fr)`,
+        }}
+      >
+        {sortedComponents.map(renderComponent)}
+      </div>
+    )
+  }
+
+  // Render stacked layout for display mode
+  return (
+    <div className="space-y-6">
+      {sortedComponents.map(renderComponent)}
+    </div>
+  )
+}
+
+export default PageRenderer
+
+// Export individual renderers for direct use
+export { HeaderRenderer } from './HeaderRenderer'
+export { TextBlockRenderer } from './TextBlockRenderer'
+export { SpacerRenderer } from './SpacerRenderer'
+export { DocumentListRenderer } from './DocumentListRenderer'
+export { StatCardRenderer } from './StatCardRenderer'

+ 2 - 1
webui/src/components/PermissionEditor.tsx

@@ -103,7 +103,8 @@ export function PermissionEditor({
       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'
+      field: 'bg-yellow-100 text-yellow-800 border-yellow-200',
+      page: 'bg-orange-100 text-orange-800 border-orange-200'
     }
 
     const colorClass = parsed ? scopeColors[parsed.scope] : 'bg-gray-100 text-gray-800 border-gray-200'

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

@@ -2,6 +2,7 @@
 
 import { NavLink } from 'react-router-dom'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { useSidebarPages } from '@/hooks/useSidebarPages'
 import type { Workspace } from '@/types'
 
 interface SidebarProps {
@@ -58,6 +59,20 @@ const navigation: NavItem[] = [
       </svg>
     ),
   },
+  {
+    name: 'Pages',
+    path: '/pages',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          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>
+    ),
+  },
   {
     name: 'Users',
     path: '/users',
@@ -116,6 +131,67 @@ const navigation: NavItem[] = [
   },
 ]
 
+// Icon renderer for dynamic pages
+function PageIcon({ icon, className = 'h-5 w-5' }: { icon?: string; className?: string }) {
+  switch (icon) {
+    case 'home':
+      return (
+        <svg className={className} 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>
+      )
+    case 'chart':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+        </svg>
+      )
+    case 'users':
+      return (
+        <svg className={className} 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>
+      )
+    case 'folder':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
+        </svg>
+      )
+    case 'document':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
+      )
+    case 'calendar':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
+        </svg>
+      )
+    case 'settings':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+        </svg>
+      )
+    case 'star':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
+        </svg>
+      )
+    default:
+      return (
+        <svg className={className} 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>
+      )
+  }
+}
+
 function WorkspaceSelector() {
   const { workspaces, currentWorkspace, setCurrentWorkspace, isLoading } = useWorkspace()
 
@@ -158,6 +234,11 @@ function WorkspaceSelector() {
 }
 
 function Sidebar({ isOpen, onClose }: SidebarProps) {
+  const { currentWorkspace } = useWorkspace()
+  const { pages: sidebarPages, isLoading: pagesLoading } = useSidebarPages({
+    workspaceId: currentWorkspace?.id,
+  })
+
   return (
     <>
       {/* Mobile backdrop */}
@@ -215,6 +296,56 @@ function Sidebar({ isOpen, onClose }: SidebarProps) {
               </li>
             ))}
           </ul>
+
+          {/* Dynamic Pages Section */}
+          {currentWorkspace && (
+            <div className="mt-6">
+              <div className="mb-2 flex items-center gap-2 px-3">
+                <span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
+                  Custom Pages
+                </span>
+                {pagesLoading && (
+                  <svg
+                    className="h-3 w-3 animate-spin text-gray-400"
+                    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>
+              {!pagesLoading && sidebarPages.length === 0 ? (
+                <p className="px-3 text-xs text-gray-400">No pages to display</p>
+              ) : (
+                <ul className="space-y-1">
+                  {sidebarPages.map((page) => (
+                    <li key={page.id}>
+                      <NavLink
+                        to={`/p/${page.slug}`}
+                        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'
+                          }`
+                        }
+                      >
+                        <PageIcon icon={page.settings?.icon} />
+                        <span className="truncate">{page.name}</span>
+                      </NavLink>
+                    </li>
+                  ))}
+                </ul>
+              )}
+            </div>
+          )}
         </nav>
 
         {/* Footer */}

+ 260 - 0
webui/src/hooks/usePermissions.ts

@@ -0,0 +1,260 @@
+// Hook to fetch and check user permissions for a workspace
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useAuth } from '@/contexts/AuthContext'
+import { matchesPermission, buildPagePermission, buildCollectionPermission } from '@/types'
+
+interface Group {
+  id: string
+  name: string
+  permissions: string[]
+}
+
+interface Membership {
+  id: string
+  user_id: string
+  workspace_id: string
+  groups: string[]
+}
+
+interface UsePermissionsOptions {
+  workspaceId?: string
+}
+
+interface UsePermissionsResult {
+  permissions: string[]
+  isLoading: boolean
+  error: string | null
+  userId: string | null
+  hasPermission: (permission: string) => boolean
+  hasAnyPermission: (permissions: string[]) => boolean
+  canReadCollection: (collectionName: string) => boolean
+  canWriteCollection: (collectionName: string) => boolean
+  canCreateInCollection: (collectionName: string) => boolean
+  canDeleteInCollection: (collectionName: string) => boolean
+  canCreatePage: () => boolean
+  canViewPage: (pageOwnerId: string, sharedWithGroups: string[]) => boolean
+  canEditPage: (pageOwnerId: string) => boolean
+  canDeletePage: (pageOwnerId: string) => boolean
+  canSharePage: (pageOwnerId: string) => boolean
+  refetch: () => Promise<void>
+}
+
+export function usePermissions({
+  workspaceId,
+}: UsePermissionsOptions = {}): UsePermissionsResult {
+  const { user, isAuthenticated } = useAuth()
+  const [permissions, setPermissions] = useState<string[]>([])
+  const [userGroups, setUserGroups] = useState<string[]>([])
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const fetchPermissions = useCallback(async () => {
+    if (!workspaceId || !isAuthenticated || !user) {
+      setPermissions([])
+      setUserGroups([])
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      // First, get user's membership to know which groups they belong to
+      const membershipResponse = await apiClient.get<Membership>(
+        `/workspaces/${workspaceId}/members/${user.id}`
+      )
+      const membershipGroups = membershipResponse.groups || []
+      setUserGroups(membershipGroups)
+
+      // Fetch all groups to get permissions
+      const groupsResponse = await apiClient.get<{ groups: Group[] }>(
+        `/workspaces/${workspaceId}/groups`
+      )
+      const groups = groupsResponse.groups || []
+
+      // Collect permissions from user's groups
+      const allPermissions: string[] = []
+      for (const groupId of membershipGroups) {
+        const group = groups.find((g) => g.id === groupId)
+        if (group) {
+          allPermissions.push(...group.permissions)
+        }
+      }
+
+      // Also check for superadmin (has wildcard permission)
+      if (user.is_superadmin) {
+        allPermissions.push('*')
+      }
+
+      // Deduplicate
+      const uniquePermissions = [...new Set(allPermissions)]
+      setPermissions(uniquePermissions)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch permissions')
+      setPermissions([])
+      setUserGroups([])
+    } finally {
+      setIsLoading(false)
+    }
+  }, [workspaceId, isAuthenticated, user])
+
+  useEffect(() => {
+    fetchPermissions()
+  }, [fetchPermissions])
+
+  const hasPermission = useCallback(
+    (required: string): boolean => {
+      return permissions.some((granted) => matchesPermission(required, granted))
+    },
+    [permissions]
+  )
+
+  const hasAnyPermission = useCallback(
+    (requiredList: string[]): boolean => {
+      return requiredList.some((required) => hasPermission(required))
+    },
+    [hasPermission]
+  )
+
+  // Collection permission helpers
+  const canReadCollection = useCallback(
+    (collectionName: string): boolean => {
+      if (!workspaceId) return false
+      const readAll = buildCollectionPermission(workspaceId, collectionName, 'read_all')
+      const readOwn = buildCollectionPermission(workspaceId, collectionName, 'read_own')
+      return hasAnyPermission([readAll, readOwn])
+    },
+    [workspaceId, hasAnyPermission]
+  )
+
+  const canWriteCollection = useCallback(
+    (collectionName: string): boolean => {
+      if (!workspaceId) return false
+      const writeAll = buildCollectionPermission(workspaceId, collectionName, 'write_all')
+      const writeOwn = buildCollectionPermission(workspaceId, collectionName, 'write_own')
+      return hasAnyPermission([writeAll, writeOwn])
+    },
+    [workspaceId, hasAnyPermission]
+  )
+
+  const canCreateInCollection = useCallback(
+    (collectionName: string): boolean => {
+      if (!workspaceId) return false
+      const create = buildCollectionPermission(workspaceId, collectionName, 'create')
+      return hasPermission(create)
+    },
+    [workspaceId, hasPermission]
+  )
+
+  const canDeleteInCollection = useCallback(
+    (collectionName: string): boolean => {
+      if (!workspaceId) return false
+      const deleteAll = buildCollectionPermission(workspaceId, collectionName, 'delete_all')
+      const deleteOwn = buildCollectionPermission(workspaceId, collectionName, 'delete_own')
+      return hasAnyPermission([deleteAll, deleteOwn])
+    },
+    [workspaceId, hasAnyPermission]
+  )
+
+  // Page permission helpers
+  const canCreatePage = useCallback((): boolean => {
+    if (!workspaceId) return false
+    const create = buildPagePermission(workspaceId, 'create')
+    return hasPermission(create)
+  }, [workspaceId, hasPermission])
+
+  const canViewPage = useCallback(
+    (pageOwnerId: string, sharedWithGroups: string[]): boolean => {
+      if (!workspaceId || !user) return false
+
+      // Check read_all permission
+      const readAll = buildPagePermission(workspaceId, 'read_all')
+      if (hasPermission(readAll)) return true
+
+      // Check if user owns the page
+      if (pageOwnerId === user.id) {
+        const readOwn = buildPagePermission(workspaceId, 'read_own')
+        return hasPermission(readOwn)
+      }
+
+      // Check if page is shared with user's groups
+      return sharedWithGroups.some((groupId) => userGroups.includes(groupId))
+    },
+    [workspaceId, user, hasPermission, userGroups]
+  )
+
+  const canEditPage = useCallback(
+    (pageOwnerId: string): boolean => {
+      if (!workspaceId || !user) return false
+
+      // Check write_all permission
+      const writeAll = buildPagePermission(workspaceId, 'write_all')
+      if (hasPermission(writeAll)) return true
+
+      // Check if user owns the page and has write_own
+      if (pageOwnerId === user.id) {
+        const writeOwn = buildPagePermission(workspaceId, 'write_own')
+        return hasPermission(writeOwn)
+      }
+
+      return false
+    },
+    [workspaceId, user, hasPermission]
+  )
+
+  const canDeletePage = useCallback(
+    (pageOwnerId: string): boolean => {
+      if (!workspaceId || !user) return false
+
+      // Check delete_all permission
+      const deleteAll = buildPagePermission(workspaceId, 'delete_all')
+      if (hasPermission(deleteAll)) return true
+
+      // Check if user owns the page and has delete_own
+      if (pageOwnerId === user.id) {
+        const deleteOwn = buildPagePermission(workspaceId, 'delete_own')
+        return hasPermission(deleteOwn)
+      }
+
+      return false
+    },
+    [workspaceId, user, hasPermission]
+  )
+
+  const canSharePage = useCallback(
+    (pageOwnerId: string): boolean => {
+      if (!workspaceId || !user) return false
+
+      // Check write_all permission (can modify any page)
+      const writeAll = buildPagePermission(workspaceId, 'write_all')
+      if (hasPermission(writeAll)) return true
+
+      // Owners can always share their own pages
+      return pageOwnerId === user.id
+    },
+    [workspaceId, user, hasPermission]
+  )
+
+  return {
+    permissions,
+    isLoading,
+    error,
+    userId: user?.id ?? null,
+    hasPermission,
+    hasAnyPermission,
+    canReadCollection,
+    canWriteCollection,
+    canCreateInCollection,
+    canDeleteInCollection,
+    canCreatePage,
+    canViewPage,
+    canEditPage,
+    canDeletePage,
+    canSharePage,
+    refetch: fetchPermissions,
+  }
+}
+
+export default usePermissions

+ 72 - 0
webui/src/hooks/useSidebarPages.ts

@@ -0,0 +1,72 @@
+// Hook to fetch sidebar pages for a workspace
+
+import { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import type { PageSettings } from '@/types'
+
+interface SidebarPage {
+  id: string
+  workspace_id: string
+  name: string
+  slug: string
+  created_by: string
+  settings: PageSettings
+}
+
+interface UseSidebarPagesOptions {
+  workspaceId?: string
+}
+
+interface UseSidebarPagesResult {
+  pages: SidebarPage[]
+  isLoading: boolean
+  error: string | null
+  refetch: () => Promise<void>
+}
+
+export function useSidebarPages({
+  workspaceId,
+}: UseSidebarPagesOptions = {}): UseSidebarPagesResult {
+  const [pages, setPages] = useState<SidebarPage[]>([])
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const fetchPages = useCallback(async () => {
+    if (!workspaceId) {
+      setPages([])
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      // Backend already filters by:
+      // 1. User permissions (read_all, owns, or in shared_with_groups)
+      // 2. show_in_sidebar = true
+      const response = await apiClient.get<SidebarPage[]>(
+        `/workspaces/${workspaceId}/pages/sidebar`
+      )
+      // Pages are already sorted by menu_order from the backend
+      setPages(response || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch sidebar pages')
+      setPages([])
+    } finally {
+      setIsLoading(false)
+    }
+  }, [workspaceId])
+
+  useEffect(() => {
+    fetchPages()
+  }, [fetchPages])
+
+  return {
+    pages,
+    isLoading,
+    error,
+    refetch: fetchPages,
+  }
+}
+
+export default useSidebarPages

+ 174 - 0
webui/src/pages/PageDisplay.tsx

@@ -0,0 +1,174 @@
+// PageDisplay - Renders a page by its slug
+// Uses PageRenderer to display components with permission filtering
+
+import { useState, useEffect, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { usePermissions } from '@/hooks/usePermissions'
+import PageRenderer from '@/components/PageRenderer'
+import Button from '@/components/Button'
+import type { Page } from '@/types'
+
+function PageDisplay() {
+  const { slug } = useParams<{ slug: string }>()
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+  const { canEditPage } = usePermissions({ workspaceId: currentWorkspace?.id })
+
+  const [page, setPage] = useState<Page | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+
+  // Fetch page by slug
+  const fetchPage = useCallback(async () => {
+    if (!currentWorkspace || !slug) {
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const pageData = await apiClient.get<Page>(
+        `/workspaces/${currentWorkspace.id}/pages/slug/${slug}`
+      )
+      setPage(pageData)
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Failed to load page'
+      setError(message)
+      setPage(null)
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace, slug])
+
+  useEffect(() => {
+    fetchPage()
+  }, [fetchPage])
+
+  // Loading state
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-16">
+        <svg
+          className="h-10 w-10 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>
+    )
+  }
+
+  // No workspace selected
+  if (!currentWorkspace) {
+    return (
+      <div className="rounded-lg bg-yellow-50 p-6 text-center">
+        <p className="text-yellow-800">Please select a workspace first.</p>
+      </div>
+    )
+  }
+
+  // Error state
+  if (error) {
+    return (
+      <div className="space-y-6">
+        <div className="rounded-lg bg-red-50 px-6 py-8 text-center">
+          <svg
+            className="mx-auto h-12 w-12 text-red-400"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={1}
+              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>
+          <h2 className="mt-4 text-lg font-medium text-red-900">Page Not Found</h2>
+          <p className="mt-2 text-red-700">{error}</p>
+          <Button
+            variant="secondary"
+            className="mt-4"
+            onClick={() => navigate('/pages')}
+          >
+            Go to Pages
+          </Button>
+        </div>
+      </div>
+    )
+  }
+
+  // Page not found
+  if (!page) {
+    return (
+      <div className="space-y-6">
+        <div className="rounded-lg bg-gray-50 px-6 py-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>
+          <h2 className="mt-4 text-lg font-medium text-gray-900">Page Not Found</h2>
+          <p className="mt-2 text-gray-600">The page you're looking for doesn't exist or you don't have access to it.</p>
+          <Button
+            variant="secondary"
+            className="mt-4"
+            onClick={() => navigate('/pages')}
+          >
+            Go to Pages
+          </Button>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Page header with edit button */}
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">{page.name}</h1>
+          {page.settings?.icon && (
+            <p className="mt-1 text-gray-600">/{page.slug}</p>
+          )}
+        </div>
+        {canEditPage(page.created_by) && (
+          <Button
+            variant="secondary"
+            onClick={() => navigate(`/pages/${page.id}/edit`)}
+          >
+            <svg className="-ml-1 mr-2 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>
+            Edit Page
+          </Button>
+        )}
+      </div>
+
+      {/* Page content */}
+      <PageRenderer page={page} />
+    </div>
+  )
+}
+
+export default PageDisplay

+ 626 - 0
webui/src/pages/Pages.tsx

@@ -0,0 +1,626 @@
+// Pages management page - list and manage custom pages
+// Pages contain multiple components (views, headers, text, stats, etc.)
+
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { useAuth } from '@/contexts/AuthContext'
+import { usePermissions } from '@/hooks/usePermissions'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import { GroupMultiSelect } from '@/components/GroupMultiSelect'
+import type { Page, PageSettings } from '@/types'
+
+// Available icons for pages
+const PAGE_ICONS = [
+  { value: '', label: 'None' },
+  { value: 'home', label: 'Home' },
+  { value: 'chart', label: 'Chart' },
+  { value: 'users', label: 'Users' },
+  { value: 'folder', label: 'Folder' },
+  { value: 'document', label: 'Document' },
+  { value: 'calendar', label: 'Calendar' },
+  { value: 'settings', label: 'Settings' },
+  { value: 'star', label: 'Star' },
+] as const
+
+// Icon renderer component
+function PageIcon({ icon, className = 'h-5 w-5' }: { icon?: string; className?: string }) {
+  switch (icon) {
+    case 'home':
+      return (
+        <svg className={className} 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>
+      )
+    case 'chart':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+        </svg>
+      )
+    case 'users':
+      return (
+        <svg className={className} 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>
+      )
+    case 'folder':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
+        </svg>
+      )
+    case 'document':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
+      )
+    case 'calendar':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
+        </svg>
+      )
+    case 'settings':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+        </svg>
+      )
+    case 'star':
+      return (
+        <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
+        </svg>
+      )
+    default:
+      return (
+        <svg className={className} 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>
+      )
+  }
+}
+
+// Page form modal
+function PageModal({
+  page,
+  workspaceId,
+  onClose,
+  onSave,
+}: {
+  page: Page | null
+  workspaceId: string
+  onClose: () => void
+  onSave: () => void
+}) {
+  const isEditing = !!page
+  const { user } = useAuth()
+
+  const [name, setName] = useState(page?.name || '')
+  const [slug, setSlug] = useState(page?.slug || '')
+  const [showInSidebar, setShowInSidebar] = useState(page?.settings?.show_in_sidebar ?? true)
+  const [icon, setIcon] = useState(page?.settings?.icon || '')
+  const [menuOrder, setMenuOrder] = useState(page?.settings?.menu_order ?? 0)
+  const [sharedWithGroups, setSharedWithGroups] = useState<string[]>(page?.shared_with_groups || [])
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  // Auto-generate slug from name
+  const generateSlug = useCallback((text: string) => {
+    return text
+      .toLowerCase()
+      .replace(/[^a-z0-9]+/g, '-')
+      .replace(/^-|-$/g, '')
+  }, [])
+
+  const handleNameChange = (value: string) => {
+    setName(value)
+    if (!isEditing || !slug) {
+      setSlug(generateSlug(value))
+    }
+  }
+
+  // Check if user is owner of the page
+  const isOwner = !page || page.created_by === user?.id
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+
+    if (!name.trim()) {
+      setError('Page name is required')
+      return
+    }
+
+    if (!slug.trim()) {
+      setError('Page slug is required')
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const settings: PageSettings = {
+        show_in_sidebar: showInSidebar,
+        icon: icon || undefined,
+        menu_order: menuOrder,
+      }
+
+      if (isEditing && page) {
+        // Update page
+        await apiClient.patch(`/workspaces/${workspaceId}/pages/${page.id}`, {
+          name,
+          slug,
+          settings,
+        })
+
+        // Update sharing if owner
+        if (isOwner) {
+          await apiClient.patch(`/workspaces/${workspaceId}/pages/${page.id}/share`, {
+            shared_with_groups: sharedWithGroups,
+          })
+        }
+      } else {
+        // Create new page
+        const newPage = await apiClient.post<Page>(`/workspaces/${workspaceId}/pages`, {
+          name,
+          slug,
+          settings,
+        })
+
+        // Set sharing for new page
+        if (sharedWithGroups.length > 0) {
+          await apiClient.patch(`/workspaces/${workspaceId}/pages/${newPage.id}/share`, {
+            shared_with_groups: sharedWithGroups,
+          })
+        }
+      }
+
+      onSave()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save page')
+    } 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-2xl 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 Page' : 'Create Page'}
+          </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 space-y-6">
+            {error && (
+              <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+            )}
+
+            {/* Basic Info */}
+            <div className="grid gap-4 sm:grid-cols-2">
+              <Input
+                label="Page Name"
+                value={name}
+                onChange={(e) => handleNameChange(e.target.value)}
+                placeholder="e.g., Dashboard"
+                required
+              />
+
+              <Input
+                label="URL Slug"
+                value={slug}
+                onChange={(e) => setSlug(e.target.value)}
+                placeholder="e.g., dashboard"
+                required
+              />
+            </div>
+
+            {/* Settings */}
+            <div className="border-t pt-6">
+              <h3 className="text-lg font-medium text-gray-900 mb-4">Settings</h3>
+
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div>
+                  <label className="mb-1.5 block text-sm font-medium text-gray-700">Icon</label>
+                  <select
+                    value={icon}
+                    onChange={(e) => setIcon(e.target.value)}
+                    className="w-full rounded-lg border border-gray-300 px-3 py-2"
+                  >
+                    {PAGE_ICONS.map((i) => (
+                      <option key={i.value} value={i.value}>
+                        {i.label}
+                      </option>
+                    ))}
+                  </select>
+                </div>
+
+                <Input
+                  label="Menu Order"
+                  type="number"
+                  value={String(menuOrder)}
+                  onChange={(e) => setMenuOrder(parseInt(e.target.value) || 0)}
+                  placeholder="0"
+                />
+              </div>
+
+              <div className="mt-4">
+                <label className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    checked={showInSidebar}
+                    onChange={(e) => setShowInSidebar(e.target.checked)}
+                    className="h-4 w-4 rounded border-gray-300"
+                  />
+                  <span className="text-sm text-gray-700">Show in sidebar navigation</span>
+                </label>
+              </div>
+            </div>
+
+            {/* Sharing - only show for owners */}
+            {isOwner && (
+              <div className="border-t pt-6">
+                <h3 className="text-lg font-medium text-gray-900 mb-2">Sharing</h3>
+                <p className="text-sm text-gray-500 mb-4">
+                  Share this page with groups. Members of selected groups will be able to view this page.
+                </p>
+
+                <GroupMultiSelect
+                  workspaceId={workspaceId}
+                  selectedGroups={sharedWithGroups}
+                  onChange={setSharedWithGroups}
+                  includeSystem={true}
+                />
+              </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 Page'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
+// Main Pages component
+function Pages() {
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+  const { user } = useAuth()
+  const { canCreatePage, canEditPage, canDeletePage } = usePermissions({
+    workspaceId: currentWorkspace?.id,
+  })
+
+  const [pages, setPages] = useState<Page[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState('')
+
+  // Modals
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingPage, setEditingPage] = useState<Page | null>(null)
+  const [deletingPage, setDeletingPage] = useState<Page | null>(null)
+
+  // Fetch pages
+  const fetchPages = useCallback(async () => {
+    if (!currentWorkspace) {
+      setPages([])
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const response = await apiClient.get<{ pages: Page[] }>(
+        `/workspaces/${currentWorkspace.id}/pages`
+      )
+      setPages(response.pages || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch pages')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace])
+
+  useEffect(() => {
+    fetchPages()
+  }, [fetchPages])
+
+  // Filter pages based on search
+  const filteredPages = useMemo(() => {
+    const query = searchQuery.toLowerCase()
+    return pages.filter(
+      (p) => p.name.toLowerCase().includes(query) || p.slug.toLowerCase().includes(query)
+    )
+  }, [pages, searchQuery])
+
+  // Handle delete
+  const handleDelete = async () => {
+    if (!deletingPage || !currentWorkspace) return
+
+    try {
+      await apiClient.delete(`/workspaces/${currentWorkspace.id}/pages/${deletingPage.id}`)
+      setDeletingPage(null)
+      fetchPages()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete page')
+    }
+  }
+
+  // Handle save (create or edit)
+  const handleSave = () => {
+    setShowCreateModal(false)
+    setEditingPage(null)
+    fetchPages()
+  }
+
+  // Check if user owns a page
+  const isOwner = (page: Page) => page.created_by === user?.id
+
+  if (!currentWorkspace) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Pages</h1>
+          <p className="mt-1 text-gray-600">Create and manage custom pages</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>
+          <h1 className="text-2xl font-bold text-gray-900">Pages</h1>
+          <p className="mt-1 text-gray-600">
+            Create and manage custom pages in {currentWorkspace.name}
+          </p>
+        </div>
+        {canCreatePage() && (
+          <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 Page
+          </Button>
+        )}
+      </div>
+
+      {/* Search */}
+      <div className="max-w-md">
+        <Input
+          type="search"
+          placeholder="Search pages..."
+          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>
+      )}
+
+      {/* Pages grid */}
+      {!isLoading && !error && (
+        <div>
+          {filteredPages.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 pages match your search' : 'No pages yet'}
+              </p>
+              {!searchQuery && canCreatePage() && (
+                <Button className="mt-4" onClick={() => setShowCreateModal(true)}>
+                  Create Your First Page
+                </Button>
+              )}
+            </div>
+          ) : (
+            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+              {filteredPages.map((page) => (
+                <div
+                  key={page.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 className="flex items-start gap-3">
+                      {/* Icon */}
+                      <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-600">
+                        <PageIcon icon={page.settings?.icon} />
+                      </div>
+
+                      <div>
+                        <h3 className="font-medium text-gray-900">{page.name}</h3>
+                        <p className="mt-0.5 text-sm text-gray-500">/{page.slug}</p>
+
+                        {/* Badges */}
+                        <div className="mt-2 flex flex-wrap gap-1">
+                          {isOwner(page) && (
+                            <span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700">
+                              Owner
+                            </span>
+                          )}
+                          {!isOwner(page) && (
+                            <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700">
+                              Shared
+                            </span>
+                          )}
+                          {page.settings?.show_in_sidebar && (
+                            <span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700">
+                              Sidebar
+                            </span>
+                          )}
+                          {page.shared_with_groups?.length > 0 && (
+                            <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-700">
+                              {page.shared_with_groups.length} group{page.shared_with_groups.length !== 1 ? 's' : ''}
+                            </span>
+                          )}
+                        </div>
+                      </div>
+                    </div>
+
+                    {/* Action buttons */}
+                    <div className="flex gap-1 opacity-0 transition group-hover:opacity-100">
+                      {/* View button */}
+                      <button
+                        onClick={() => navigate(`/p/${page.slug}`)}
+                        className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+                        title="View"
+                      >
+                        <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
+                        </svg>
+                      </button>
+
+                      {/* Edit button */}
+                      {canEditPage(page.created_by) && (
+                        <>
+                          <button
+                            onClick={() => navigate(`/pages/${page.id}/edit`)}
+                            className="rounded p-1 text-gray-400 hover:bg-blue-50 hover:text-blue-600"
+                            title="Edit Layout"
+                          >
+                            <svg className="h-4 w-4" 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>
+                          </button>
+                          <button
+                            onClick={() => setEditingPage(page)}
+                            className="rounded p-1 text-gray-400 hover:bg-primary-50 hover:text-primary-600"
+                            title="Edit Settings"
+                          >
+                            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+                              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+                            </svg>
+                          </button>
+                        </>
+                      )}
+
+                      {/* Delete button */}
+                      {canDeletePage(page.created_by) && (
+                        <button
+                          onClick={() => setDeletingPage(page)}
+                          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>
+
+                  {/* Component count */}
+                  <div className="mt-3 flex items-center gap-2 text-xs text-gray-500">
+                    <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
+                    </svg>
+                    {page.layout?.components?.length || 0} component{(page.layout?.components?.length || 0) !== 1 ? 's' : ''}
+                  </div>
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Create/Edit Modal */}
+      {(showCreateModal || editingPage) && (
+        <PageModal
+          key={editingPage?.id || 'new'}
+          page={editingPage}
+          workspaceId={currentWorkspace.id}
+          onClose={() => {
+            setShowCreateModal(false)
+            setEditingPage(null)
+          }}
+          onSave={handleSave}
+        />
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deletingPage && (
+        <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 Page</h2>
+            <p className="mt-2 text-gray-600">
+              Are you sure you want to delete the page "{deletingPage.name}"? This action cannot be undone.
+            </p>
+            <div className="mt-6 flex justify-end gap-3">
+              <Button variant="secondary" onClick={() => setDeletingPage(null)}>
+                Cancel
+              </Button>
+              <Button variant="danger" onClick={handleDelete}>
+                Delete Page
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default Pages

+ 121 - 4
webui/src/types/index.ts

@@ -99,7 +99,7 @@ export interface Collection {
 }
 
 // Permission scope types
-export type PermissionScope = 'system' | 'workspace' | 'collection' | 'field'
+export type PermissionScope = 'system' | 'workspace' | 'collection' | 'field' | 'page'
 
 // Permission string format: <scope>:<resource>:<action>[:<qualifier>]
 export interface ParsedPermission {
@@ -111,17 +111,19 @@ export interface ParsedPermission {
 
 // 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 WorkspaceActions = ['admin', 'member', 'read', 'manage_members', 'manage_groups', 'manage_collections', 'manage_views', 'manage_pages'] 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
+export const PageActions = ['create', 'read_all', 'read_own', 'write_all', 'write_own', 'delete_all', 'delete_own', 'share'] as const
 
 // System resources
-export const SystemResources = ['login', 'users', 'groups', 'system_groups', 'workspaces', 'collections', 'api_keys', 'memberships', 'views'] as const
+export const SystemResources = ['login', 'users', 'groups', 'system_groups', 'workspaces', 'collections', 'api_keys', 'memberships', 'views', 'pages'] 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 PageAction = typeof PageActions[number]
 export type SystemResource = typeof SystemResources[number]
 
 // Helper functions for building permission strings
@@ -141,13 +143,17 @@ export function buildFieldPermission(workspaceId: string, collection: string, fi
   return `field:${workspaceId}:${collection}:${field}:${action}`
 }
 
+export function buildPagePermission(workspaceId: string, action: PageAction): string {
+  return `page:${workspaceId}:${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
+  if (!['system', 'workspace', 'collection', 'field', 'page'].includes(scope)) return null
 
   return {
     scope,
@@ -208,3 +214,114 @@ export interface Membership {
   created_by_name?: string
   updated_by_name?: string
 }
+
+// Page Builder Types
+
+export interface GridPosition {
+  x: number      // Column start (0-11)
+  y: number      // Row position
+  width: number  // Columns to span (1-12)
+  height: number // Row height units
+}
+
+export interface LayoutComponent {
+  id: string
+  type: ComponentType
+  position: GridPosition
+  config: Record<string, unknown>
+}
+
+export type ComponentType =
+  | 'header'
+  | 'text-block'
+  | 'spacer'
+  | 'document-list'
+  | 'stat-card'
+
+export interface PageLayout {
+  version: number
+  grid_columns: number
+  components: LayoutComponent[]
+}
+
+export interface PageSettings {
+  show_in_sidebar: boolean
+  icon?: string
+  menu_order: number
+}
+
+export interface Page {
+  id: string
+  workspace_id: string
+  name: string
+  slug: string
+  layout: PageLayout
+  settings: PageSettings
+  created_at: string
+  updated_at: string
+  created_by: string              // Owner user ID
+  shared_with_groups: string[]    // Groups that can view this page
+}
+
+export interface CreatePageRequest {
+  name: string
+  slug?: string
+  layout?: Partial<PageLayout>
+  settings?: Partial<PageSettings>
+}
+
+export interface UpdatePageRequest {
+  name?: string
+  slug?: string
+  layout?: PageLayout
+  settings?: PageSettings
+}
+
+export interface UpdatePageSharingRequest {
+  shared_with_groups: string[]
+}
+
+// Document List component configuration
+export interface DocumentListConfig {
+  viewId: string
+  displayMode: 'table' | 'cards' | 'kanban'
+  visibleColumns?: string[]
+  cardTemplate?: {
+    titleField?: string
+    subtitleField?: string
+    imageField?: string
+    badgeField?: string
+  }
+  kanbanGroupField?: string
+  showCreateButton?: boolean
+  pageSize?: number
+}
+
+// Stat Card component configuration
+export interface StatCardConfig {
+  viewId: string
+  aggregation: 'count' | 'sum' | 'avg' | 'min' | 'max'
+  field?: string
+  label: string
+  icon?: string
+  color?: string
+}
+
+// Header component configuration
+export interface HeaderConfig {
+  title: string
+  subtitle?: string
+  showIcon?: boolean
+  icon?: string
+}
+
+// Text Block component configuration
+export interface TextBlockConfig {
+  content: string
+  alignment?: 'left' | 'center' | 'right'
+}
+
+// Spacer component configuration
+export interface SpacerConfig {
+  size: 'sm' | 'md' | 'lg' | 'xl'
+}