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 8dbc6b919..ad523027f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useAtomValue } from "jotai";
+import { useAtomValue, useSetAtom } from "jotai";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -13,6 +13,7 @@ import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -23,6 +24,7 @@ export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
+ const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
// Queries
const {
@@ -259,7 +261,9 @@ export default function OnboardPage() {
You can add more configurations and customize settings anytime in{" "}
-
-
-
-
- {copied ? (
-
- ) : (
-
- )}
-
-
- {copied ? "Copied!" : "Copy link"}
-
-
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ {copied ? "Copied!" : "Copy link"}
+
+
{/* Footer: Date + Creator */}
@@ -152,33 +154,33 @@ export function PublicChatSnapshotRow({
{member && (
<>
·
-
-
-
-
- {member.avatarUrl ? (
-
- ) : (
-
-
- {getInitials(member.name)}
-
-
- )}
-
- {member.name}
-
-
-
- {member.email || member.name}
-
-
+
+
+
+
+ {member.avatarUrl ? (
+
+ ) : (
+
+
+ {getInitials(member.name)}
+
+
+ )}
+
+ {member.name}
+
+
+
+ {member.email || member.name}
+
+
>
)}
diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx
index bd22e9180..8c9a43780 100644
--- a/surfsense_web/components/settings/general-settings-manager.tsx
+++ b/surfsense_web/components/settings/general-settings-manager.tsx
@@ -160,26 +160,27 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
- {/* Action Buttons */}
-
-
-
- {t("general_reset")}
-
-
-
- {saving ? t("general_saving") : t("general_save")}
-
-
+ {/* Action Buttons */}
+
+
+
+ {t("general_reset")}
+
+
+
+ {saving ? t("general_saving") : t("general_save")}
+
+
{hasChanges && (
= 2) {
@@ -101,6 +91,7 @@ function getInitials(name: string): string {
}
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
+ const isDesktop = useMediaQuery("(min-width: 768px)");
// Image gen config atoms
const {
mutateAsync: createConfig,
@@ -281,46 +272,40 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Header */}
+ refreshConfigs()}
+ disabled={isLoading}
+ className="gap-2"
+ >
+
+ Refresh
+
+ {canCreate && (
refreshConfigs()}
- disabled={isLoading}
- className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
+ onClick={openNewDialog}
+ className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
-
- Refresh
+ Add Image Model
- {canCreate && (
-
- Add Image Model
-
- )}
+ )}
{/* Errors */}
-
- {errors.map((err) => (
-
-
-
- {err?.message}
-
-
- ))}
-
+ {errors.map((err) => (
+
+ ))}
{/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && (
-
+
@@ -328,10 +313,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
configurations. Contact a space owner to request additional permissions.
-
+
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
-
+
@@ -343,7 +328,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{!canDelete && ", but cannot delete them"}.
-
+
)}
{/* Global info */}
@@ -429,23 +414,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
) : (
-
-
- {userConfigs?.map((config) => {
- const member = config.user_id ? memberMap.get(config.user_id) : null;
+
+ {userConfigs?.map((config) => {
+ const member = config.user_id ? memberMap.get(config.user_id) : null;
- return (
-
+ return (
+
{/* Header: Name + Actions */}
@@ -464,7 +438,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{canUpdate && (
-
+
-
+
·
-
+
{member.avatarUrl ? (
@@ -554,11 +528,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
-
- );
- })}
-
-
+
+ );
+ })}
+
)}
)}
@@ -732,22 +705,20 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)}
- {/* Actions */}
-
-
{
- setIsDialogOpen(false);
- setEditingConfig(null);
- resetForm();
- }}
- >
- Cancel
-
-
+ {
+ setIsDialogOpen(false);
+ setEditingConfig(null);
+ resetForm();
+ }}
+ >
+ Cancel
+
+
{/* Header actions */}
-
refreshConfigs()}
- disabled={isLoading}
- className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
- >
-
- Refresh
+ refreshConfigs()}
+ disabled={isLoading}
+ className="gap-2"
+ >
+
+ Refresh
{isAssignmentComplete && !isLoading && !hasError && (
{/* Error Alert */}
-
- {hasError && (
-
-
-
-
- {(configsError?.message ?? "Failed to load LLM configurations") ||
- (preferencesError?.message ?? "Failed to load preferences") ||
- (globalConfigsError?.message ?? "Failed to load global configurations")}
-
-
-
- )}
-
+ {hasError && (
+
+
+
+
+ {(configsError?.message ?? "Failed to load LLM configurations") ||
+ (preferencesError?.message ?? "Failed to load preferences") ||
+ (globalConfigsError?.message ?? "Failed to load global configurations")}
+
+
+
+ )}
{/* Loading Skeleton */}
{isLoading && (
@@ -322,13 +314,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
{/* Role Assignment Cards */}
{!isLoading && !hasError && hasAnyConfigs && (
-
- {Object.entries(ROLE_DESCRIPTIONS).map(([key, role], index) => {
+
+ {Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
const IconComponent = role.icon;
const isImageRole = role.configType === "image";
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
@@ -349,12 +336,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
return (
-
+
{/* Role Header */}
@@ -542,47 +524,39 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
)}
-
+
);
})}
-
+
)}
{/* Save / Reset Bar */}
-
- {hasChanges && (
-
- You have unsaved changes
-
-
-
- Reset
-
-
-
- {isSaving ? "Saving…" : "Save Changes"}
-
-
-
- )}
-
+ {hasChanges && (
+
+
You have unsaved changes
+
+
+
+ Reset
+
+
+
+ {isSaving ? "Saving…" : "Save Changes"}
+
+
+
+ )}
);
}
diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx
index ba3445aad..343fea1d2 100644
--- a/surfsense_web/components/settings/model-config-manager.tsx
+++ b/surfsense_web/components/settings/model-config-manager.tsx
@@ -12,7 +12,6 @@ import {
Trash2,
Wand2,
} from "lucide-react";
-import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useCallback, useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
@@ -51,6 +50,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
+import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
@@ -58,21 +58,6 @@ interface ModelConfigManagerProps {
searchSpaceId: number;
}
-const container = {
- hidden: { opacity: 0 },
- show: {
- opacity: 1,
- transition: {
- staggerChildren: 0.05,
- },
- },
-};
-
-const item = {
- hidden: { opacity: 0, y: 20 },
- show: { opacity: 1, y: 0 },
-};
-
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
@@ -82,6 +67,7 @@ function getInitials(name: string): string {
}
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
+ const isDesktop = useMediaQuery("(min-width: 768px)");
// Mutations
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
createNewLLMConfigMutationAtom
@@ -194,49 +180,42 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{/* Header actions */}
+ refreshConfigs()}
+ disabled={isLoading}
+ className="gap-2"
+ >
+
+ Refresh
+
+ {canCreate && (
refreshConfigs()}
- disabled={isLoading}
- className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
+ onClick={openNewDialog}
+ className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
-
- Refresh
+ Add Configuration
- {canCreate && (
-
- Add Configuration
-
)}
{/* Fetch Error Alert */}
-
- {fetchError && (
-
-
-
-
- {fetchError?.message ?? "Failed to load configurations"}
-
-
-
- )}
-
+ {fetchError && (
+
+
+
+
+ {fetchError?.message ?? "Failed to load configurations"}
+
+
+
+ )}
{/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && (
-
+
@@ -244,10 +223,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
Contact a space owner to request additional permissions.
-
+
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
-
+
@@ -259,12 +238,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!canDelete && ", but cannot delete them"}.
-
+
)}
{/* Global Configs Info */}
{globalConfigs.length > 0 && (
-
+
@@ -275,7 +254,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
-
+
)}
{/* Loading Skeleton */}
@@ -317,7 +296,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!isLoading && (
{configs?.length === 0 ? (
-
+
@@ -343,25 +322,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)}
-
+
) : (
-
-
- {configs?.map((config) => {
- const member = config.user_id ? memberMap.get(config.user_id) : null;
+
+ {configs?.map((config) => {
+ const member = config.user_id ? memberMap.get(config.user_id) : null;
- return (
-
+ return (
+
{/* Header: Name + Actions */}
@@ -380,7 +348,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{canUpdate && (
-
+
-
+
·
-
+
{member.avatarUrl ? (
@@ -493,11 +461,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
-
+
);
})}
-
-
+
)}
)}
diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx
index 54058759f..91db93994 100644
--- a/surfsense_web/components/settings/prompt-config-manager.tsx
+++ b/surfsense_web/components/settings/prompt-config-manager.tsx
@@ -183,26 +183,27 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
- {/* Action Buttons */}
-
-
-
- Reset Changes
-
-
-
- {saving ? "Saving" : "Save Instructions"}
-
-
+ {/* Action Buttons */}
+
+
+
+ Reset Changes
+
+
+
+ {saving ? "Saving" : "Save Instructions"}
+
+
{hasChanges && (
r.id === editingRoleId) : null;
return (
-
+
{canCreate && (
setShowCreateRole(true)}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
-
Create Custom Role
@@ -516,13 +508,8 @@ function RolesContent({
)}
- {roles.map((role, index) => (
-
+ {roles.map((role) => (
+
-
+
))}
-
+
);
}
@@ -695,18 +682,11 @@ function PermissionsEditor({
return (
-
toggleCategoryExpanded(category)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- toggleCategoryExpanded(category);
- }
- }}
- >
+
toggleCategoryExpanded(category)}
+ >
{config.label}
@@ -721,9 +701,11 @@ function PermissionsEditor({
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`}
/>
-
-
+
-
+
{isExpanded && (
-
+
{perms.map((perm) => {
const action = perm.value.split(":")[1];
@@ -759,21 +735,14 @@ function PermissionsEditor({
const isSelected = selectedPermissions.includes(perm.value);
return (
-
onTogglePermission(perm.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onTogglePermission(perm.value);
- }
- }}
>
{actionLabel}
@@ -787,11 +756,11 @@ function PermissionsEditor({
onClick={(e) => e.stopPropagation()}
className="shrink-0"
/>
-
+
);
})}
-
+
)}
);
@@ -964,11 +933,11 @@ function CreateRoleDialog({
/>
-
-
- Cancel
-
-
+
+
+ Cancel
+
+
{creating ? (
<>
@@ -1122,10 +1091,10 @@ function EditRoleDialog({
/>
-
-
onOpenChange(false)}>
- Cancel
-
+
+
onOpenChange(false)}>
+ Cancel
+
{saving ? (
<>
diff --git a/surfsense_web/components/settings/search-space-settings-dialog.tsx b/surfsense_web/components/settings/search-space-settings-dialog.tsx
new file mode 100644
index 000000000..47094d0c9
--- /dev/null
+++ b/surfsense_web/components/settings/search-space-settings-dialog.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useAtom } from "jotai";
+import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
+import { useTranslations } from "next-intl";
+import type React from "react";
+import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
+import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
+import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
+import { ImageModelManager } from "@/components/settings/image-model-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";
+import { RolesManager } from "@/components/settings/roles-manager";
+import { SettingsDialog } from "@/components/settings/settings-dialog";
+
+interface SearchSpaceSettingsDialogProps {
+ searchSpaceId: number;
+}
+
+export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettingsDialogProps) {
+ const t = useTranslations("searchSpaceSettings");
+ const [state, setState] = useAtom(searchSpaceSettingsDialogAtom);
+
+ const navItems = [
+ { value: "general", label: t("nav_general"), icon: },
+ { value: "models", label: t("nav_agent_configs"), icon: },
+ { value: "roles", label: t("nav_role_assignments"), icon: },
+ {
+ value: "image-models",
+ label: t("nav_image_models"),
+ icon: ,
+ },
+ { value: "team-roles", label: t("nav_team_roles"), icon: },
+ {
+ value: "prompts",
+ label: t("nav_system_instructions"),
+ icon: ,
+ },
+ { value: "public-links", label: t("nav_public_links"), icon: },
+ ];
+
+ const content: Record = {
+ general: ,
+ models: ,
+ roles: ,
+ "image-models": ,
+ "team-roles": ,
+ prompts: ,
+ "public-links": ,
+ };
+
+ return (
+ setState((prev) => ({ ...prev, open }))}
+ title={t("title")}
+ navItems={navItems}
+ activeItem={state.initialTab}
+ onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
+ >
+ {content[state.initialTab]}
+
+ );
+}
diff --git a/surfsense_web/components/settings/settings-dialog.tsx b/surfsense_web/components/settings/settings-dialog.tsx
new file mode 100644
index 000000000..c6809ca96
--- /dev/null
+++ b/surfsense_web/components/settings/settings-dialog.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import type * as React from "react";
+import { useCallback, useRef, useState } from "react";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+interface NavItem {
+ value: string;
+ label: string;
+ icon: React.ReactNode;
+}
+
+interface SettingsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ navItems: NavItem[];
+ activeItem: string;
+ onItemChange: (value: string) => void;
+ children: React.ReactNode;
+}
+
+export function SettingsDialog({
+ open,
+ onOpenChange,
+ title,
+ navItems,
+ activeItem,
+ onItemChange,
+ children,
+}: SettingsDialogProps) {
+ const activeRef = useRef(null);
+ const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
+
+ const handleTabScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
+ const atStart = el.scrollLeft <= 2;
+ const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
+ setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
+ }, []);
+
+ const handleItemChange = (value: string) => {
+ onItemChange(value);
+ activeRef.current?.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
+ };
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/components/settings/user-settings-dialog.tsx b/surfsense_web/components/settings/user-settings-dialog.tsx
new file mode 100644
index 000000000..426903a23
--- /dev/null
+++ b/surfsense_web/components/settings/user-settings-dialog.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useAtom } from "jotai";
+import { KeyRound, User } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
+import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
+import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
+import { SettingsDialog } from "@/components/settings/settings-dialog";
+
+export function UserSettingsDialog() {
+ const t = useTranslations("userSettings");
+ const [state, setState] = useAtom(userSettingsDialogAtom);
+
+ const navItems = [
+ { value: "profile", label: t("profile_nav_label"), icon: },
+ {
+ value: "api-key",
+ label: t("api_key_nav_label"),
+ icon: ,
+ },
+ ];
+
+ return (
+ setState((prev) => ({ ...prev, open }))}
+ title={t("title")}
+ navItems={navItems}
+ activeItem={state.initialTab}
+ onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
+ >
+
+ {state.initialTab === "profile" &&
}
+ {state.initialTab === "api-key" &&
}
+
+
+ );
+}