diff --git a/Dockerfile.allinone b/Dockerfile.allinone index 2e160d3dc..64e99a14d 100644 --- a/Dockerfile.allinone +++ b/Dockerfile.allinone @@ -228,7 +228,7 @@ COPY scripts/docker/init-postgres.sh /app/init-postgres.sh RUN dos2unix /app/init-postgres.sh && chmod +x /app/init-postgres.sh # Clean up build dependencies to reduce image size -RUN apt-get purge -y build-essential postgresql-server-dev-14 git \ +RUN apt-get purge -y build-essential postgresql-server-dev-14 \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* diff --git a/surfsense_backend/Dockerfile b/surfsense_backend/Dockerfile index fa3aaeae8..9ce6467b3 100644 --- a/surfsense_backend/Dockerfile +++ b/surfsense_backend/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxext6 \ libxrender1 \ dos2unix \ + git \ && rm -rf /var/lib/apt/lists/* # Update certificates and install SSL tools diff --git a/surfsense_backend/alembic/versions/80_add_user_incentive_tasks_table.py b/surfsense_backend/alembic/versions/80_add_user_incentive_tasks_table.py new file mode 100644 index 000000000..7fcadb763 --- /dev/null +++ b/surfsense_backend/alembic/versions/80_add_user_incentive_tasks_table.py @@ -0,0 +1,97 @@ +"""Add user incentive tasks table for earning free pages + +Revision ID: 80 +Revises: 79 + +Changes: +1. Create incentive_task_type enum with GITHUB_STAR value +2. Create user_incentive_tasks table to track completed tasks +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "80" +down_revision: str | None = "79" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create incentive tasks infrastructure.""" + + # Check if enum already exists (handles partial migration recovery) + conn = op.get_bind() + result = conn.execute( + sa.text("SELECT 1 FROM pg_type WHERE typname = 'incentivetasktype'") + ) + enum_exists = result.fetchone() is not None + + # Create the enum type only if it doesn't exist + if not enum_exists: + incentive_task_type_enum = postgresql.ENUM( + "GITHUB_STAR", + name="incentivetasktype", + create_type=False, + ) + incentive_task_type_enum.create(op.get_bind(), checkfirst=True) + + # Check if table already exists (handles partial migration recovery) + result = conn.execute( + sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'user_incentive_tasks'" + ) + ) + table_exists = result.fetchone() is not None + + if not table_exists: + # Create the user_incentive_tasks table + op.create_table( + "user_incentive_tasks", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "user_id", + sa.UUID(as_uuid=True), + sa.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column( + "task_type", + postgresql.ENUM( + "GITHUB_STAR", name="incentivetasktype", create_type=False + ), + nullable=False, + index=True, + ), + sa.Column("pages_awarded", sa.Integer(), nullable=False), + sa.Column( + "completed_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.func.now(), + index=True, + ), + sa.UniqueConstraint("user_id", "task_type", name="uq_user_incentive_task"), + ) + + +def downgrade() -> None: + """Remove incentive tasks infrastructure.""" + + # Drop the table + op.drop_table("user_incentive_tasks") + + # Drop the enum type + postgresql.ENUM(name="incentivetasktype").drop(op.get_bind(), checkfirst=True) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 8b6f3c718..8a9507e1b 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -144,6 +144,43 @@ class LogStatus(str, Enum): FAILED = "FAILED" +class IncentiveTaskType(str, Enum): + """ + Enum for incentive task types that users can complete to earn free pages. + Each task can only be completed once per user. + + When adding new tasks: + 1. Add a new enum value here + 2. Add the task configuration to INCENTIVE_TASKS_CONFIG below + 3. Create an Alembic migration to add the enum value to PostgreSQL + """ + + GITHUB_STAR = "GITHUB_STAR" + # Future tasks can be added here: + # GITHUB_ISSUE = "GITHUB_ISSUE" + # SOCIAL_SHARE = "SOCIAL_SHARE" + # REFER_FRIEND = "REFER_FRIEND" + + +# Centralized configuration for incentive tasks +# This makes it easy to add new tasks without changing code in multiple places +INCENTIVE_TASKS_CONFIG = { + IncentiveTaskType.GITHUB_STAR: { + "title": "Star our GitHub repository", + "description": "Show your support by starring SurfSense on GitHub", + "pages_reward": 100, + "action_url": "https://github.com/MODSetter/SurfSense", + }, + # Future tasks can be configured here: + # IncentiveTaskType.GITHUB_ISSUE: { + # "title": "Create an issue", + # "description": "Help improve SurfSense by reporting bugs or suggesting features", + # "pages_reward": 50, + # "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose", + # }, +} + + class Permission(str, Enum): """ Granular permissions for search space resources. @@ -915,6 +952,39 @@ class Notification(BaseModel, TimestampMixin): search_space = relationship("SearchSpace", back_populates="notifications") +class UserIncentiveTask(BaseModel, TimestampMixin): + """ + Tracks completed incentive tasks for users. + Each user can only complete each task type once. + When a task is completed, the user's pages_limit is increased. + """ + + __tablename__ = "user_incentive_tasks" + __table_args__ = ( + UniqueConstraint( + "user_id", + "task_type", + name="uq_user_incentive_task", + ), + ) + + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True) + pages_awarded = Column(Integer, nullable=False) + completed_at = Column( + TIMESTAMP(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) + + user = relationship("User", back_populates="incentive_tasks") + + class SearchSpaceRole(BaseModel, TimestampMixin): """ Custom roles that can be defined per search space. @@ -1093,6 +1163,13 @@ if config.AUTH_TYPE == "GOOGLE": cascade="all, delete-orphan", ) + # Incentive tasks completed by this user + incentive_tasks = relationship( + "UserIncentiveTask", + back_populates="user", + cascade="all, delete-orphan", + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, @@ -1144,6 +1221,13 @@ else: cascade="all, delete-orphan", ) + # Incentive tasks completed by this user + incentive_tasks = relationship( + "UserIncentiveTask", + back_populates="user", + cascade="all, delete-orphan", + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 76bb5101a..753105c46 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -20,6 +20,7 @@ from .google_drive_add_connector_route import ( from .google_gmail_add_connector_route import ( router as google_gmail_add_connector_router, ) +from .incentive_tasks_routes import router as incentive_tasks_router from .jira_add_connector_route import router as jira_add_connector_router from .linear_add_connector_route import router as linear_add_connector_router from .logs_routes import router as logs_router @@ -67,3 +68,4 @@ router.include_router(circleback_webhook_router) # Circleback meeting webhooks router.include_router(surfsense_docs_router) # Surfsense documentation for citations router.include_router(notifications_router) # Notifications with Electric SQL sync router.include_router(composio_router) # Composio OAuth and toolkit management +router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages diff --git a/surfsense_backend/app/routes/incentive_tasks_routes.py b/surfsense_backend/app/routes/incentive_tasks_routes.py new file mode 100644 index 000000000..93e54c153 --- /dev/null +++ b/surfsense_backend/app/routes/incentive_tasks_routes.py @@ -0,0 +1,131 @@ +""" +Incentive Tasks API routes. +Allows users to complete tasks (like starring GitHub repo) to earn free pages. +Each task can only be completed once per user. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import ( + INCENTIVE_TASKS_CONFIG, + IncentiveTaskType, + User, + UserIncentiveTask, + get_async_session, +) +from app.schemas.incentive_tasks import ( + CompleteTaskResponse, + IncentiveTaskInfo, + IncentiveTasksResponse, + TaskAlreadyCompletedResponse, +) +from app.users import current_active_user + +router = APIRouter(prefix="/incentive-tasks", tags=["incentive-tasks"]) + + +@router.get("", response_model=IncentiveTasksResponse) +async def get_incentive_tasks( + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> IncentiveTasksResponse: + """ + Get all available incentive tasks with the user's completion status. + """ + # Get all completed tasks for this user + result = await session.execute( + select(UserIncentiveTask).where(UserIncentiveTask.user_id == user.id) + ) + completed_tasks = {task.task_type: task for task in result.scalars().all()} + + # Build task list with completion status + tasks = [] + total_pages_earned = 0 + + for task_type, config in INCENTIVE_TASKS_CONFIG.items(): + completed_task = completed_tasks.get(task_type) + is_completed = completed_task is not None + + if is_completed: + total_pages_earned += completed_task.pages_awarded + + tasks.append( + IncentiveTaskInfo( + task_type=task_type, + title=config["title"], + description=config["description"], + pages_reward=config["pages_reward"], + action_url=config["action_url"], + completed=is_completed, + completed_at=completed_task.completed_at if completed_task else None, + ) + ) + + return IncentiveTasksResponse( + tasks=tasks, + total_pages_earned=total_pages_earned, + ) + + +@router.post( + "/{task_type}/complete", + response_model=CompleteTaskResponse | TaskAlreadyCompletedResponse, +) +async def complete_task( + task_type: IncentiveTaskType, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> CompleteTaskResponse | TaskAlreadyCompletedResponse: + """ + Mark an incentive task as completed and award pages to the user. + + Each task can only be completed once. If the task was already completed, + returns the existing completion information without awarding additional pages. + """ + # Validate task type exists in config + task_config = INCENTIVE_TASKS_CONFIG.get(task_type) + if not task_config: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown task type: {task_type}", + ) + + # Check if task was already completed + existing_task = await session.execute( + select(UserIncentiveTask).where( + UserIncentiveTask.user_id == user.id, + UserIncentiveTask.task_type == task_type, + ) + ) + existing = existing_task.scalar_one_or_none() + + if existing: + return TaskAlreadyCompletedResponse( + success=False, + message="Task already completed", + completed_at=existing.completed_at, + ) + + # Create the task completion record + pages_reward = task_config["pages_reward"] + new_task = UserIncentiveTask( + user_id=user.id, + task_type=task_type, + pages_awarded=pages_reward, + ) + session.add(new_task) + + # Update user's pages_limit + user.pages_limit += pages_reward + + await session.commit() + await session.refresh(user) + + return CompleteTaskResponse( + success=True, + message=f"Task completed! You earned {pages_reward} pages.", + pages_awarded=pages_reward, + new_pages_limit=user.pages_limit, + ) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 84e95f7ca..5070a2724 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -59,6 +59,58 @@ router = APIRouter() # ============ Permissions Endpoints ============ +# Human-readable descriptions for each permission +PERMISSION_DESCRIPTIONS = { + # Documents + "documents:create": "Add new documents, files, and content to the search space", + "documents:read": "View and search documents in the search space", + "documents:update": "Edit existing documents and their metadata", + "documents:delete": "Remove documents from the search space", + # Chats + "chats:create": "Start new AI chat conversations", + "chats:read": "View chat history and conversations", + "chats:update": "Edit chat titles and settings", + "chats:delete": "Delete chat conversations", + # Comments + "comments:create": "Add comments and annotations to documents", + "comments:read": "View comments on documents", + "comments:delete": "Remove comments from documents", + # LLM Configs + "llm_configs:create": "Add new AI model configurations", + "llm_configs:read": "View AI model settings and configurations", + "llm_configs:update": "Modify AI model configurations", + "llm_configs:delete": "Remove AI model configurations", + # Podcasts + "podcasts:create": "Generate new AI podcasts from content", + "podcasts:read": "Listen to and view generated podcasts", + "podcasts:update": "Edit podcast settings and metadata", + "podcasts:delete": "Remove generated podcasts", + # Connectors + "connectors:create": "Set up new data source integrations", + "connectors:read": "View configured data sources and their status", + "connectors:update": "Modify data source configurations", + "connectors:delete": "Remove data source integrations", + # Logs + "logs:read": "View activity logs and audit trail", + "logs:delete": "Clear activity logs", + # Members + "members:invite": "Send invitations to new team members", + "members:view": "View the list of team members", + "members:remove": "Remove members from the search space", + "members:manage_roles": "Assign and change member roles", + # Roles + "roles:create": "Create new custom roles", + "roles:read": "View available roles and their permissions", + "roles:update": "Modify role permissions", + "roles:delete": "Remove custom roles", + # Settings + "settings:view": "View search space settings", + "settings:update": "Modify search space settings", + "settings:delete": "Delete the entire search space", + # Full access + "*": "Full access to all features and settings", +} + @router.get("/permissions", response_model=PermissionsListResponse) async def list_all_permissions( @@ -71,12 +123,14 @@ async def list_all_permissions( for perm in Permission: # Extract category from permission value (e.g., "documents:read" -> "documents") category = perm.value.split(":")[0] if ":" in perm.value else "general" + description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}") permissions.append( PermissionInfo( value=perm.value, name=perm.name, category=category, + description=description, ) ) diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index bc52a52b1..147f515b3 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -129,6 +129,7 @@ async def read_search_spaces( result = await session.execute( select(SearchSpace) .filter(SearchSpace.user_id == user.id) + .order_by(SearchSpace.id.asc()) .offset(skip) .limit(limit) ) @@ -138,6 +139,7 @@ async def read_search_spaces( select(SearchSpace) .join(SearchSpaceMembership) .filter(SearchSpaceMembership.user_id == user.id) + .order_by(SearchSpace.id.asc()) .offset(skip) .limit(limit) ) diff --git a/surfsense_backend/app/schemas/incentive_tasks.py b/surfsense_backend/app/schemas/incentive_tasks.py new file mode 100644 index 000000000..52c2a5182 --- /dev/null +++ b/surfsense_backend/app/schemas/incentive_tasks.py @@ -0,0 +1,61 @@ +""" +Schemas for incentive tasks API. +""" + +from datetime import datetime + +from pydantic import BaseModel + +from app.db import INCENTIVE_TASKS_CONFIG, IncentiveTaskType + + +class IncentiveTaskInfo(BaseModel): + """Information about an available incentive task.""" + + task_type: IncentiveTaskType + title: str + description: str + pages_reward: int + action_url: str + completed: bool + completed_at: datetime | None = None + + +class IncentiveTasksResponse(BaseModel): + """Response containing all available incentive tasks with completion status.""" + + tasks: list[IncentiveTaskInfo] + total_pages_earned: int + + +class CompleteTaskRequest(BaseModel): + """Request to mark a task as completed.""" + + task_type: IncentiveTaskType + + +class CompleteTaskResponse(BaseModel): + """Response after completing a task.""" + + success: bool + message: str + pages_awarded: int + new_pages_limit: int + + +class TaskAlreadyCompletedResponse(BaseModel): + """Response when task was already completed.""" + + success: bool + message: str + completed_at: datetime + + +def get_task_info(task_type: IncentiveTaskType) -> dict | None: + """Get task configuration by type.""" + return INCENTIVE_TASKS_CONFIG.get(task_type) + + +def get_all_task_types() -> list[IncentiveTaskType]: + """Get all configured task types.""" + return list(INCENTIVE_TASKS_CONFIG.keys()) diff --git a/surfsense_backend/app/schemas/rbac_schemas.py b/surfsense_backend/app/schemas/rbac_schemas.py index a51f3bc28..031eef3d2 100644 --- a/surfsense_backend/app/schemas/rbac_schemas.py +++ b/surfsense_backend/app/schemas/rbac_schemas.py @@ -167,6 +167,7 @@ class PermissionInfo(BaseModel): value: str name: str category: str + description: str class PermissionsListResponse(BaseModel): diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 8dfff4895..9a4f050a1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -87,10 +87,14 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str: context_parts.append("") context_parts.append("") context_parts.append(f" {doc.id}") - context_parts.append(f" {doc.document_type.value}") + context_parts.append( + f" {doc.document_type.value}" + ) context_parts.append(f" <![CDATA[{doc.title}]]>") context_parts.append(f" ") - context_parts.append(f" ") + context_parts.append( + f" " + ) context_parts.append("") context_parts.append("") context_parts.append("") diff --git a/surfsense_web/app/(home)/layout.tsx b/surfsense_web/app/(home)/layout.tsx index 9488ee875..f1ceffac0 100644 --- a/surfsense_web/app/(home)/layout.tsx +++ b/surfsense_web/app/(home)/layout.tsx @@ -1,14 +1,18 @@ "use client"; +import { usePathname } from "next/navigation"; import { FooterNew } from "@/components/homepage/footer-new"; import { Navbar } from "@/components/homepage/navbar"; export default function HomePageLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const isAuthPage = pathname === "/login" || pathname === "/register"; + return (
{children} - + {!isAuthPage && }
); } diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index 62d2a2a66..5b2edae71 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -8,6 +8,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; +import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { AUTH_TYPE } from "@/lib/env-config"; import { ValidationError } from "@/lib/error"; @@ -42,9 +43,6 @@ export function LocalLoginForm() { // Track login attempt trackLoginAttempt("local"); - // Show loading toast - const loadingToast = toast.loading(tCommon("loading")); - try { const data = await login({ username, @@ -62,8 +60,7 @@ export function LocalLoginForm() { // Success toast toast.success(t("login_success"), { - id: loadingToast, - description: "Redirecting to dashboard...", + description: "Redirecting to dashboard", duration: 2000, }); @@ -76,7 +73,6 @@ export function LocalLoginForm() { trackLoginFailure("local", err.message); setError({ title: err.name, message: err.message }); toast.error(err.name, { - id: loadingToast, description: err.message, duration: 6000, }); @@ -106,7 +102,6 @@ export function LocalLoginForm() { // Show error toast with conditional retry action const toastOptions: any = { - id: loadingToast, description: errorDetails.description, duration: 6000, }; @@ -244,9 +239,16 @@ export function LocalLoginForm() { diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index 7aade8427..0dc9c445f 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -1,12 +1,12 @@ "use client"; -import { Loader2 } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { Logo } from "@/components/Logo"; +import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; import { AUTH_TYPE } from "@/lib/env-config"; import { AmbientBackground } from "./AmbientBackground"; @@ -59,7 +59,11 @@ function LoginContent() { }); // Show toast with conditional retry action - const toastOptions: any = { + const toastOptions: { + description: string; + duration: number; + action?: { label: string; onClick: () => void }; + } = { description: errorDescription, duration: 6000, }; @@ -88,20 +92,12 @@ function LoginContent() { setIsLoading(false); }, [searchParams, t, tCommon]); - // Show loading state while determining auth type + // Use global loading screen for auth type determination - spinner animation won't reset + useGlobalLoadingEffect(isLoading, tCommon("loading"), "login"); + + // Show nothing while loading - the GlobalLoadingProvider handles the loading UI if (isLoading) { - return ( -
- -
- -
- - {tCommon("loading")} -
-
-
- ); + return null; } if (authType === "GOOGLE") { @@ -182,23 +178,10 @@ function LoginContent() { ); } -// Loading fallback for Suspense -const LoadingFallback = () => ( -
- -
- -
- - Loading... -
-
-
-); - export default function LoginPage() { + // Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI return ( - }> + ); diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 243ad4c60..60c3ba1be 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; +import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { AUTH_TYPE } from "@/lib/env-config"; import { AppError, ValidationError } from "@/lib/error"; @@ -60,9 +61,6 @@ export default function RegisterPage() { // Track registration attempt trackRegistrationAttempt(); - // Show loading toast - const loadingToast = toast.loading(t("creating_account")); - try { await register({ email, @@ -77,7 +75,6 @@ export default function RegisterPage() { // Success toast toast.success(t("register_success"), { - id: loadingToast, description: t("redirecting_login"), duration: 2000, }); @@ -95,7 +92,6 @@ export default function RegisterPage() { trackRegistrationFailure("Registration disabled"); setError({ title: "Registration is disabled", message: friendlyMessage }); toast.error("Registration is disabled", { - id: loadingToast, description: friendlyMessage, duration: 6000, }); @@ -109,7 +105,6 @@ export default function RegisterPage() { trackRegistrationFailure(err.message); setError({ title: err.name, message: err.message }); toast.error(err.name, { - id: loadingToast, description: err.message, duration: 6000, }); @@ -137,7 +132,6 @@ export default function RegisterPage() { // Show error toast with conditional retry action const toastOptions: any = { - id: loadingToast, description: errorDetails.description, duration: 6000, }; @@ -295,9 +289,16 @@ export default function RegisterPage() { diff --git a/surfsense_web/app/auth/callback/loading.tsx b/surfsense_web/app/auth/callback/loading.tsx new file mode 100644 index 000000000..0c94e1ee0 --- /dev/null +++ b/surfsense_web/app/auth/callback/loading.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; + +export default function AuthCallbackLoading() { + const t = useTranslations("auth"); + + // Use global loading - spinner animation won't reset when page transitions + useGlobalLoadingEffect(true, t("processing_authentication"), "default"); + + // Return null - the GlobalLoadingProvider handles the loading UI + return null; +} diff --git a/surfsense_web/app/auth/callback/page.tsx b/surfsense_web/app/auth/callback/page.tsx index da868c316..4050eefb6 100644 --- a/surfsense_web/app/auth/callback/page.tsx +++ b/surfsense_web/app/auth/callback/page.tsx @@ -1,23 +1,18 @@ +"use client"; + import { Suspense } from "react"; import TokenHandler from "@/components/TokenHandler"; export default function AuthCallbackPage() { + // Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI + // TokenHandler uses useGlobalLoadingEffect to show the loading screen return ( -
-

Authentication Callback

- -
-
- } - > - -
- + + + ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index bbafa9703..e6730d8d1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -1,7 +1,6 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { Loader2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; @@ -19,6 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LayoutDataProvider } from "@/components/layout"; import { OnboardingTour } from "@/components/onboarding-tour"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; export function DashboardClientLayout({ children, @@ -146,31 +146,22 @@ export function DashboardClientLayout({ setActiveSearchSpaceIdState(activeSeacrhSpaceId); }, [search_space_id, setActiveSearchSpaceIdState]); - if ( + // Determine if we should show loading + const shouldShowLoading = (!hasCheckedOnboarding && (loading || accessLoading || globalConfigsLoading) && !isOnboardingPage) || - isAutoConfiguring - ) { - return ( -
- - - - {isAutoConfiguring ? "Setting up AI..." : t("loading_config")} - - - {isAutoConfiguring - ? "Auto-configuring with available settings" - : t("checking_llm_prefs")} - - - - - - -
- ); + isAutoConfiguring; + + // Use global loading screen - spinner animation won't reset + useGlobalLoadingEffect( + shouldShowLoading, + isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"), + "default" + ); + + if (shouldShowLoading) { + return null; } if (error && !hasCheckedOnboarding && !isOnboardingPage) { diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 38d61a6ce..d9908f46c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDown, ChevronUp, FileX, Loader2, Plus } from "lucide-react"; +import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react"; import { motion } from "motion/react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -9,6 +9,7 @@ import { useDocumentUploadDialog } from "@/components/assistant-ui/document-uplo import { DocumentViewer } from "@/components/document-viewer"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -114,7 +115,7 @@ export function DocumentsTableShell({ {loading ? (
- +

{t("loading")}

diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 2320b3b9a..74104f450 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; -import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react"; +import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -21,6 +20,7 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; import { notesApiService } from "@/lib/apis/notes-api.service"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; @@ -78,7 +78,6 @@ function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string export default function EditorPage() { const params = useParams(); const router = useRouter(); - const queryClient = useQueryClient(); const documentId = params.documentId as string; const searchSpaceId = Number(params.search_space_id); const isNewNote = documentId === "new"; @@ -349,8 +348,8 @@ export default function EditorPage() {
- -

Loading editor...

+ +

Loading editor

@@ -437,7 +436,7 @@ export default function EditorPage() { > {saving ? ( <> - + {isNewNote ? "Creating" : "Saving"} ) : ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/more-pages/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/more-pages/page.tsx new file mode 100644 index 000000000..7bb15b78b --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/more-pages/page.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { IconCalendar, IconMailFilled } from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react"; +import { motion } from "motion/react"; +import Link from "next/link"; +import { useEffect } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types"; +import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service"; +import { + trackIncentiveContactOpened, + trackIncentivePageViewed, + trackIncentiveTaskClicked, + trackIncentiveTaskCompleted, +} from "@/lib/posthog/events"; +import { cn } from "@/lib/utils"; + +export default function MorePagesPage() { + const queryClient = useQueryClient(); + + // Track page view on mount + useEffect(() => { + trackIncentivePageViewed(); + }, []); + + // Fetch tasks from API + const { data, isLoading } = useQuery({ + queryKey: ["incentive-tasks"], + queryFn: () => incentiveTasksApiService.getTasks(), + }); + + // Mutation to complete a task + const completeMutation = useMutation({ + mutationFn: incentiveTasksApiService.completeTask, + onSuccess: (response, taskType) => { + if (response.success) { + toast.success(response.message); + // Track task completion + const task = data?.tasks.find((t) => t.task_type === taskType); + if (task) { + trackIncentiveTaskCompleted(taskType, task.pages_reward); + } + // Invalidate queries to refresh data + queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] }); + queryClient.invalidateQueries({ queryKey: ["user"] }); + } + }, + onError: () => { + toast.error("Failed to complete task. Please try again."); + }, + }); + + const handleTaskClick = (task: IncentiveTaskInfo) => { + if (!task.completed) { + trackIncentiveTaskClicked(task.task_type); + completeMutation.mutate(task.task_type); + } + }; + + const allCompleted = data?.tasks.every((t) => t.completed) ?? false; + + return ( +
+ + {/* Header */} +
+ +

Get More Pages

+

Complete tasks to earn additional pages

+
+ + {/* Tasks */} + {isLoading ? ( + + + +
+ + +
+ +
+
+ ) : ( +
+ {data?.tasks.map((task) => ( + + +
+ {task.completed ? : } +
+
+

+ {task.title} +

+

+{task.pages_reward} pages

+
+ +
+
+ ))} +
+ )} + + {/* Contact */} + +
+

+ {allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"} +

+ open && trackIncentiveContactOpened()}> + + + + + + Contact Us + Schedule a meeting or send us an email. + +
+ + + Schedule a Meeting + +
+ + or + +
+ + + eric@surfsense.com + +
+
+
+
+
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 59e7878c4..4509a44a7 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -9,6 +9,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { useParams, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; @@ -34,6 +35,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; +import { Spinner } from "@/components/ui/spinner"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesElectric } from "@/hooks/use-messages-electric"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; @@ -236,6 +238,7 @@ interface ThinkingStepData { } export default function NewChatPage() { + const t = useTranslations("dashboard"); const params = useParams(); const queryClient = useQueryClient(); const [isInitializing, setIsInitializing] = useState(true); @@ -1475,8 +1478,9 @@ export default function NewChatPage() { // Show loading state only when loading an existing thread if (isInitializing) { return ( -
-
Loading chat...
+
+ +
{t("loading_chat")}
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 25f189203..1b7fa297f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useAtomValue } from "jotai"; -import { Loader2 } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -17,6 +16,7 @@ import { import { Logo } from "@/components/Logo"; import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; export default function OnboardPage() { @@ -156,7 +156,7 @@ export default function OnboardPage() {
- +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index fb2f49317..8c8bdb2e9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -5,6 +5,7 @@ import { Bot, Brain, ChevronRight, + FileText, type LucideIcon, Menu, MessageSquare, @@ -15,6 +16,7 @@ import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; +import { GeneralSettingsManager } from "@/components/settings/general-settings-manager"; import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { PromptConfigManager } from "@/components/settings/prompt-config-manager"; @@ -30,6 +32,12 @@ interface SettingsNavItem { } const settingsNavItems: SettingsNavItem[] = [ + { + id: "general", + labelKey: "nav_general", + descriptionKey: "nav_general_desc", + icon: FileText, + }, { id: "models", labelKey: "nav_agent_configs", @@ -262,6 +270,9 @@ function SettingsContent({ ease: [0.4, 0, 0.2, 1], }} > + {activeSection === "general" && ( + + )} {activeSection === "models" && } {activeSection === "roles" && } {activeSection === "prompts" && } @@ -277,7 +288,7 @@ export default function SettingsPage() { const router = useRouter(); const params = useParams(); const searchSpaceId = Number(params.search_space_id); - const [activeSection, setActiveSection] = useState("models"); + const [activeSection, setActiveSection] = useState("general"); const [isSidebarOpen, setIsSidebarOpen] = useState(false); // Track settings section view diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index b661e9222..298871cf7 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -14,7 +14,6 @@ import { Hash, Link2, LinkIcon, - Loader2, Logs, type LucideIcon, MessageCircle, @@ -96,6 +95,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -105,7 +105,6 @@ import { TableRow, } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; import type { CreateInviteRequest, DeleteInviteRequest, @@ -122,6 +121,7 @@ import type { Role, UpdateRoleRequest, } from "@/contracts/types/roles.types"; +import type { PermissionInfo } from "@/contracts/types/permissions.types"; import { invitesApiService } from "@/lib/apis/invites-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events"; @@ -321,7 +321,7 @@ export default function TeamManagementPage() { animate={{ opacity: 1, scale: 1 }} className="flex flex-col items-center gap-4" > - +

Loading team data...

@@ -471,13 +471,6 @@ export default function TeamManagementPage() { className="w-full md:w-auto" /> )} - {activeTab === "roles" && hasPermission("roles:create") && ( - - )}
@@ -499,8 +492,10 @@ export default function TeamManagementPage() { loading={rolesLoading} onUpdateRole={handleUpdateRole} onDeleteRole={handleDeleteRole} + onCreateRole={handleCreateRole} canUpdate={hasPermission("roles:update")} canDelete={hasPermission("roles:delete")} + canCreate={hasPermission("roles:create")} /> @@ -571,7 +566,7 @@ function MembersTab({ if (loading) { return (
- +
); } @@ -767,17 +762,71 @@ function MembersTab({ // ============ Role Permissions Display ============ -const CATEGORY_CONFIG: Record = { - documents: { label: "Documents", icon: FileText, order: 1 }, - chats: { label: "Chats", icon: MessageSquare, order: 2 }, - comments: { label: "Comments", icon: MessageCircle, order: 3 }, - llm_configs: { label: "LLM Configs", icon: Bot, order: 4 }, - podcasts: { label: "Podcasts", icon: Mic, order: 5 }, - connectors: { label: "Connectors", icon: Plug, order: 6 }, - logs: { label: "Logs", icon: Logs, order: 7 }, - members: { label: "Members", icon: Users, order: 8 }, - roles: { label: "Roles", icon: Shield, order: 9 }, - settings: { label: "Settings", icon: Settings, order: 10 }, +// Unified category configuration used across all role-related components +const CATEGORY_CONFIG: Record< + string, + { label: string; icon: LucideIcon; description: string; order: number } +> = { + documents: { + label: "Documents", + icon: FileText, + description: "Manage files, notes, and content", + order: 1, + }, + chats: { + label: "AI Chats", + icon: MessageSquare, + description: "Create and manage AI conversations", + order: 2, + }, + comments: { + label: "Comments", + icon: MessageCircle, + description: "Add annotations to documents", + order: 3, + }, + llm_configs: { + label: "AI Models", + icon: Bot, + description: "Configure AI model settings", + order: 4, + }, + podcasts: { + label: "Podcasts", + icon: Mic, + description: "Generate AI podcasts from content", + order: 5, + }, + connectors: { + label: "Integrations", + icon: Plug, + description: "Connect external data sources", + order: 6, + }, + logs: { + label: "Activity Logs", + icon: Logs, + description: "View and manage audit trail", + order: 7, + }, + members: { + label: "Team Members", + icon: Users, + description: "Manage team membership", + order: 8, + }, + roles: { + label: "Roles", + icon: Shield, + description: "Configure role permissions", + order: 9, + }, + settings: { + label: "Settings", + icon: Settings, + description: "Manage search space settings", + order: 10, + }, }; const ACTION_LABELS: Record = { @@ -893,25 +942,31 @@ function RolePermissionsDisplay({ permissions }: { permissions: string[] }) { function RolesTab({ roles, - groupedPermissions: _groupedPermissions, + groupedPermissions, loading, onUpdateRole: _onUpdateRole, onDeleteRole, + onCreateRole, canUpdate, canDelete, + canCreate, }: { roles: Role[]; - groupedPermissions: Record; + groupedPermissions: Record; loading: boolean; onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise; onDeleteRole: (roleId: number) => Promise; + onCreateRole: (data: CreateRoleRequest["data"]) => Promise; canUpdate: boolean; canDelete: boolean; + canCreate: boolean; }) { + const [showCreateRole, setShowCreateRole] = useState(false); + if (loading) { return (
- +
); } @@ -921,123 +976,149 @@ function RolesTab({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} - className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" + className="space-y-6" > - {roles.map((role, index) => ( + {/* Create Role Button / Section */} + {canCreate && !showCreateRole && ( - - {role.is_system_role && ( -
- System Role -
- )} - -
-
-
- -
-
- {role.name} - {role.is_default && ( - - Default - - )} -
-
- {!role.is_system_role && ( - - - - - - {canUpdate && ( - { - // TODO: Implement edit role dialog/modal - }} - > - - Edit Role - - )} - {canDelete && ( - <> - - - - e.preventDefault()} - > - - Delete Role - - - - - Delete role? - - This will permanently delete the "{role.name}" role. Members with - this role will lose their permissions. - - - - Cancel - onDeleteRole(role.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - - - - - - )} - - - )} -
- {role.description && ( - {role.description} - )} -
- - - -
+
- ))} + )} + + {/* Create Role Form */} + {showCreateRole && ( + setShowCreateRole(false)} + /> + )} + + {/* Roles Grid */} +
+ {roles.map((role, index) => ( + + + {role.is_system_role && ( +
+ System Role +
+ )} + +
+
+
+ +
+
+ {role.name} + {role.is_default && ( + + Default + + )} +
+
+ {!role.is_system_role && ( + + + + + + {canUpdate && ( + { + // TODO: Implement edit role dialog/modal + }} + > + + Edit Role + + )} + {canDelete && ( + <> + + + + e.preventDefault()} + > + + Delete Role + + + + + Delete role? + + This will permanently delete the "{role.name}" role. Members + with this role will lose their permissions. + + + + Cancel + onDeleteRole(role.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + )} + + + )} +
+ {role.description && ( + {role.description} + )} +
+ + + +
+
+ ))} +
); } @@ -1068,7 +1149,7 @@ function InvitesTab({ if (loading) { return (
- +
); } @@ -1446,7 +1527,7 @@ function CreateInviteDialog({ - - - - Create Custom Role - - Define a new role with specific permissions for this search space. - - -
-
+ + + +
+
+
+ +
+
+ Create Custom Role + + Define permissions for a new role in this search space + +
+
+ +
+
+ + {/* Quick Start with Presets */} +
+ +
+ {Object.entries(ROLE_PRESETS).map(([key, preset]) => ( + + ))} +
+
+ + {/* Role Details */} +
setName(e.target.value)} />
-
+
+ + {/* Default Role Checkbox */} +
+ setIsDefault(checked === true)} + /> +
+

- New invites without a role will use this + New members without a specific role will be assigned this role

-
- -