- {/* Sidebar */}
- {isDesktop ? (
- <>
- {sidebar}
-
{
- setIsResizing(true);
- resizeStartRef.current = { x: e.clientX, width: sidebarWidth };
- }}
- />
- >
- ) : (
-
-
- {t("mobileSheetTitle")}
- {sidebar}
-
-
- )}
-
- {/* Main area */}
-
- {/* Mobile toolbar */}
- {!isDesktop && (
-
- setMobileSidebarOpen(true)}
- >
-
-
-
- {activeTab ? activeTab.connectionName : t("title")}
-
- setIsConnectionDialogOpen(true)}
- >
-
-
-
- )}
-
- {/* Tab bar */}
- {tabs.length > 0 && (
-
- {tabs.map((tab) => (
- setActiveTabId(tab.id)}
- className={cn(
- "flex items-center gap-1.5 px-3 py-2 text-xs border-r whitespace-nowrap transition-colors min-w-0 max-w-[160px]",
- activeTabId === tab.id
- ? "bg-background text-foreground border-b-2 border-b-primary"
- : "text-muted-foreground hover:text-foreground hover:bg-background/50"
- )}
- >
-
- {tab.connectionName}
- { e.stopPropagation(); closeTab(tab.id); }}
- className="ml-1 hover:text-destructive flex-shrink-0 rounded p-0.5 hover:bg-muted"
- >
- ×
-
-
- ))}
-
- connections[0] && openQueryTab(connections[0])}
- disabled={connections.length === 0}
- title={t("newTabLabel")}
- >
-
-
-
- )}
-
- {/* Content */}
-
- {activeTab ? (
-
- // Editing the query text detaches the tab from its source
- // table — grid edits must never target a table the rows
- // no longer come from.
- updateTab(activeTab.id, {
- query: q,
- table: q === activeTab.query ? activeTab.table : undefined,
- })
- }
- onResult={(result, error) => updateTab(activeTab.id, { result, error })}
- onClose={() => closeTab(activeTab.id)}
- />
- ) : (
-
- {!encryptionKey ? (
-
-
-
-
-
-
{t("vaultLockedTitle")}
-
- {t("vaultLockedDesc")}
-
-
-
- ) : connections.length === 0 ? (
-
-
-
-
-
-
- {t("heroTitle")}
-
-
- {t("heroDesc")}
-
-
-
- {(["PostgreSQL", "MySQL", "MariaDB"] as const).map((db) => (
-
- ))}
-
-
setIsConnectionDialogOpen(true)}
- className="gap-2 shadow-lg"
- >
-
- {t("heroAddConnection")}
-
-
- ) : (
-
-
-
-
-
{t("selectTableTitle")}
-
- {t("selectTableDesc")}
-
-
- )}
-
- )}
-
-
-
- {/* Connection dialog — wider than default to fit form + saved-connections panel */}
- {isDesktop ? (
-
-
-
- {t("dialogTitle")}
-
-
-
-
- ) : (
-
-
-
- {t("dialogTitle")}
-
-
-
-
-
-
- )}
-
- );
-}
diff --git a/apps/desktop-ui/src/app/app/to-do/GoogleLoginButton.tsx b/apps/desktop-ui/src/app/app/to-do/GoogleLoginButton.tsx
deleted file mode 100644
index c1ae59d3..00000000
--- a/apps/desktop-ui/src/app/app/to-do/GoogleLoginButton.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-// components/GoogleLoginButton.js
-'use client';
-import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
-import { auth } from '../../../database/firebase';
-import { Button } from '../../../components/ui/button';
-import Image from 'next/image';
-import { useRouter } from 'next/navigation';
-import { establishBackendSession } from '@/lib/backend-auth';
-
-const GoogleLoginButton = () => {
- const router = useRouter();
- const signInWithGoogle = async () => {
- const provider = new GoogleAuthProvider();
- try {
- const result = await signInWithPopup(auth, provider);
- const idToken = await result.user.getIdToken();
- await establishBackendSession(idToken, { checkRevoked: true });
- router.push('/dashboard');
- } catch (error) {
- console.error('Error during Google sign-in or API session:', error);
- }
- };
-
- return (
-
-
-
- Sign-In with Google
-
-
- );
-
-
-};
-
-export default GoogleLoginButton;
-
diff --git a/apps/desktop-ui/src/app/app/to-do/context/ProjectContext.tsx b/apps/desktop-ui/src/app/app/to-do/context/ProjectContext.tsx
index 6cce573d..011fa6e4 100644
--- a/apps/desktop-ui/src/app/app/to-do/context/ProjectContext.tsx
+++ b/apps/desktop-ui/src/app/app/to-do/context/ProjectContext.tsx
@@ -3,7 +3,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { Project, NewProject } from "@/app/app/to-do/types/Project";
import useAuth from "@/utils/useAuth";
-import { backendFetch } from "@/lib/backend-auth";
+import { apiFetch } from "@/lib/desktop/api-fetch";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
@@ -26,7 +26,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) {
const authedFetch = useCallback(
async (path: string, init?: RequestInit) => {
if (!user) throw new Error("Not authenticated");
- const res = await backendFetch(path, {
+ const res = await apiFetch(path, {
...init,
headers: {
"Content-Type": "application/json",
diff --git a/apps/desktop-ui/src/app/app/to-do/context/TaskContext.tsx b/apps/desktop-ui/src/app/app/to-do/context/TaskContext.tsx
index d72d7ac9..f8edc65a 100644
--- a/apps/desktop-ui/src/app/app/to-do/context/TaskContext.tsx
+++ b/apps/desktop-ui/src/app/app/to-do/context/TaskContext.tsx
@@ -13,7 +13,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Task, NewTask } from "@/app/app/to-do/types/Task";
import { format } from "date-fns";
import useAuth, { AuthState } from "@/utils/useAuth";
-import { backendFetch } from "@/lib/backend-auth";
+import { apiFetch } from "@/lib/desktop/api-fetch";
import { fetchAllPages } from "@/lib/fetch-all-pages";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
@@ -136,7 +136,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
const authedFetch = useCallback(
async (path: string, init?: RequestInit) => {
if (!user) throw new Error("Not authenticated");
- const res = await backendFetch(path, {
+ const res = await apiFetch(path, {
...init,
headers: {
"Content-Type": "application/json",
diff --git a/apps/desktop-ui/src/app/app/to-do/login-page.tsx b/apps/desktop-ui/src/app/app/to-do/login-page.tsx
deleted file mode 100644
index a564ad7d..00000000
--- a/apps/desktop-ui/src/app/app/to-do/login-page.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { Metadata } from "next"
-import { FileJson, ListTodo, StickyNote, FileCode, Key, Hash } from "lucide-react"
-import { LegalAgreementFooter } from "@/components/legal-agreement-footer"
-import { Card } from "@/components/ui/card"
-import { LoginForm } from "../../../components/login-form"
-
-export const metadata: Metadata = {
- title: "Login - MyDevTools",
- description: "Login to access your developer tools and workspace",
-}
-
-export default function LoginPage() {
- return (
-
-
-
-
-
-
Essential Developer Tools
-
- {[
- {
- icon: ListTodo,
- title: "To-Do App",
- description: "Organize tasks",
- },
- {
- icon: StickyNote,
- title: "Note Taking",
- description: "Capture ideas",
- },
- {
- icon: FileJson,
- title: "JSON/YAML Tools",
- description: "Convert & format",
- },
- {
- icon: FileCode,
- title: "YAML to TOML",
- description: "Easy conversion",
- },
- {
- icon: Key,
- title: "Bcrypt Generator",
- description: "Secure hashing",
- },
- {
- icon: Hash,
- title: "UUID Generator",
- description: "Unique identifiers",
- },
- ].map((tool, i) => (
-
-
-
-
-
{tool.title}
-
{tool.description}
-
-
-
- ))}
-
-
-
-
-
- Streamline your development workflow with our comprehensive suite of tools.
-
-
-
-
-
-
-
Welcome back
-
Sign in to your account to continue
-
-
-
-
-
-
- )
-}
-
diff --git a/apps/desktop-ui/src/app/dashboard/dashboard-client-layout.tsx b/apps/desktop-ui/src/app/dashboard/dashboard-client-layout.tsx
index 930d87a3..860f4c92 100644
--- a/apps/desktop-ui/src/app/dashboard/dashboard-client-layout.tsx
+++ b/apps/desktop-ui/src/app/dashboard/dashboard-client-layout.tsx
@@ -1,22 +1,15 @@
'use client'
import React from 'react';
import { ClientLayout } from '../../components/sidebar/client-layout';
-import { RequireAuth } from '@/components/require-auth';
import { MasterPasswordGate } from '@/components/master-password-gate';
import { OnboardingGate } from '@/components/onboarding-gate';
-import { PasskeyPromptGate } from '@/components/passkey-prompt-gate';
-import { isDesktop } from '@/lib/desktop/is-desktop';
export default function DashboardClientLayout({ children }: { children: React.ReactNode }) {
- // Onboarding/passkey are web-account flows; the offline desktop app has no
- // per-session auth backend for them (local API 501s on /auth/*).
- const web = !isDesktop();
return (
-
- {web && }
+ <>
+
- {web && }
{children}
-
+ >
);
}
diff --git a/apps/desktop-ui/src/app/help/page.tsx b/apps/desktop-ui/src/app/help/page.tsx
index 54406ecf..e8610061 100644
--- a/apps/desktop-ui/src/app/help/page.tsx
+++ b/apps/desktop-ui/src/app/help/page.tsx
@@ -80,9 +80,9 @@ const appDetails: Record<
dataNote:
'Requests you send go to the targets you choose over your own network connection. Use the app only with APIs you trust.',
},
- '/app/database-explorer': {
+ '/app/data-explorer': {
howItWorks: [
- 'Connect to MongoDB using a connection string, browse databases and collections, and run queries.',
+ 'Connect to PostgreSQL, MySQL, MongoDB, Redis, Firestore, or Elasticsearch, browse the tree, and run queries.',
'Saved connections are encrypted with your global master key on your device; only ciphertext is written to the local database.',
],
dataNote:
diff --git a/apps/desktop-ui/src/app/layout.tsx b/apps/desktop-ui/src/app/layout.tsx
index 18252dda..e3d7234a 100644
--- a/apps/desktop-ui/src/app/layout.tsx
+++ b/apps/desktop-ui/src/app/layout.tsx
@@ -114,11 +114,6 @@ export default async function RootLayout({
courierPrime.variable
)}>
- {/* Firebase Auth & token refresh */}
-
-
- {/* Firebase Storage */}
-
{/* Vercel Speed Insights */}
diff --git a/apps/desktop-ui/src/app/login/page.tsx b/apps/desktop-ui/src/app/login/page.tsx
deleted file mode 100644
index b8dd304f..00000000
--- a/apps/desktop-ui/src/app/login/page.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { ArrowLeft, Cloud, ShieldCheck, Zap } from "lucide-react";
-import { LegalAgreementFooter } from "@/components/legal-agreement-footer";
-import { LoginForm } from "@/components/login-form";
-import { LoginRedirectIfAuthed } from "@/components/login-redirect-if-authed";
-import { HideOnDesktop } from "@/components/desktop/hide-on-desktop";
-import { Logo } from "@/components/logo";
-import { Magnetic } from "@/components/mdt-magnetic";
-import MdtAurora from "@/components/mdt-aurora";
-
-const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://mydevtools.tech";
-
-export const metadata: Metadata = {
- title: "Login - MyDevTools",
- description: "Login to access your developer tools and workspace",
- alternates: { canonical: `${baseUrl}/login` },
-};
-
-const FEATURES = [
- {
- icon: Zap,
- title: "80+ tools, one app",
- body: "Formatters, generators, API client, SQL, crypto — all in one place.",
- },
- {
- icon: Cloud,
- title: "Completely offline",
- body: "Runs fully on your device. No network needed once you're in.",
- },
- {
- icon: ShieldCheck,
- title: "Client-side AES encryption",
- body: "Data is AES-256 encrypted and never leaves your device — unless you back it up.",
- },
-];
-
-const TOOL_PILLS = [
- "JSON",
- "JWT",
- "Regex",
- "Base64",
- "UUID",
- "API Client",
- "SQL",
- "GraphQL",
-];
-
-export default function LoginPage() {
- return (
-
-
-
- {/* deck backdrop */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Back to home
-
-
-
-
-
-
- {/* ── Showcase (desktop only) ── */}
-
- Welcome back
-
- Your entire dev toolkit,{" "}
- one app away.
-
-
- Sign in to sync your work across devices and pick up exactly where you left off.
-
-
-
- {FEATURES.map(({ icon: Icon, title, body }) => (
-
-
-
-
-
-
- ))}
-
-
-
- {TOOL_PILLS.map((pill) => (
-
- {pill}
-
- ))}
-
-
-
- {/* ── Login card ── */}
-
-
-
-
-
-
-
- Sign in to MyDevTools
-
-
- Continue with your preferred provider to open your workspace.
-
-
-
-
-
-
- No passwords. One-click OAuth — we never see your credentials.
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/desktop-ui/src/app/page.tsx b/apps/desktop-ui/src/app/page.tsx
index 4c811955..f2791d09 100644
--- a/apps/desktop-ui/src/app/page.tsx
+++ b/apps/desktop-ui/src/app/page.tsx
@@ -4,18 +4,13 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
/**
- * Desktop launch route: no marketing surface in the app bundle. Route to the
- * one-time activation screen until the app is activated, then straight to the
- * tools. Offline-first — no network in this decision.
+ * Launch route: no marketing surface in the app bundle, and nothing to unlock —
+ * the app has no accounts. Straight to the tools.
*/
export default function Page() {
const router = useRouter();
useEffect(() => {
- void (async () => {
- const { localApi } = await import("@/lib/desktop/bridge");
- const activated = (await localApi("GET", "/desktop/activation")).status === 200;
- router.replace(activated ? "/dashboard" : "/activate");
- })();
+ router.replace("/dashboard");
}, [router]);
return
;
}
diff --git a/apps/desktop-ui/src/app/settings/page.tsx b/apps/desktop-ui/src/app/settings/page.tsx
index a749b9dc..ff5b7670 100644
--- a/apps/desktop-ui/src/app/settings/page.tsx
+++ b/apps/desktop-ui/src/app/settings/page.tsx
@@ -13,7 +13,7 @@ import { cn } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { COLOR_THEME_OPTIONS, type ColorTheme, useColorTheme } from '@/hooks/use-color-theme'
-import { DesktopPlanSettings } from '@/components/desktop/desktop-plan-settings'
+import { ProfileCard } from '@/components/settings/profile-card'
import { AppVersionLabel } from '@/components/desktop/app-version-label'
import { useActiveWorkspace } from '@/store/workspace-store'
import { Briefcase } from 'lucide-react'
@@ -79,7 +79,7 @@ export default function SettingsPage() {
-
+
diff --git a/apps/desktop-ui/src/components/__tests__/migration-banner.test.ts b/apps/desktop-ui/src/components/__tests__/migration-banner.test.ts
deleted file mode 100644
index 8c09e414..00000000
--- a/apps/desktop-ui/src/components/__tests__/migration-banner.test.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-/**
- * Tests for MigrationBanner (task 25 — boot wiring + first-login migration banner).
- *
- * Environment: jest-environment-node — no DOM, no React rendering.
- * Strategy: verify module structure + polling logic contracts via state
- * simulation, matching the pattern of other component tests in this project.
- */
-
-// ── Mocks ────────────────────────────────────────────────────────────────────
-
-jest.mock("@/lib/backend-auth", () => ({
- backendFetch: jest.fn(),
-}))
-
-// ── Imports ──────────────────────────────────────────────────────────────────
-
-import * as backendAuth from "@/lib/backend-auth"
-
-// ── Helpers ──────────────────────────────────────────────────────────────────
-
-type MeResponse = {
- migration_status?: string
- migrated_at?: number | null
- migrated_fast?: boolean
-}
-
-/** Simulate one MigrationBanner poll tick and return the derived status. */
-async function simulateTick(me: MeResponse): Promise<"pending" | "done" | null> {
- ;(backendAuth.backendFetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => me,
- })
-
- const res = await (backendAuth.backendFetch as jest.Mock)("/api/backend/auth/me")
- if (!res.ok) return null
- const data = await res.json()
-
- if (data.migrated_at || data.migrated_fast === true) return "done"
- if (data.migration_status === "pending") return "pending"
- return "done"
-}
-
-// ── Tests ────────────────────────────────────────────────────────────────────
-
-describe("MigrationBanner — module exports", () => {
- it("exports a MigrationBanner named function component", () => {
- const mod = require("../migration-banner")
- expect(typeof mod.MigrationBanner).toBe("function")
- })
-})
-
-describe("MigrationBanner — polling logic contracts", () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it("resolves to 'done' immediately when migrated_at is set", async () => {
- const status = await simulateTick({ migrated_at: 1234567890, migration_status: "done" })
- expect(status).toBe("done")
- })
-
- it("resolves to 'done' when migrated_fast flag is true", async () => {
- const status = await simulateTick({ migrated_fast: true, migration_status: "done" })
- expect(status).toBe("done")
- })
-
- it("resolves to 'pending' when migration_status is 'pending' and migrated_at is absent", async () => {
- const status = await simulateTick({ migration_status: "pending" })
- expect(status).toBe("pending")
- })
-
- it("resolves to 'done' when migration_status is neither 'pending' nor 'done' (unknown value)", async () => {
- const status = await simulateTick({ migration_status: "unknown" })
- expect(status).toBe("done")
- })
-
- it("resolves to 'done' when migration_status is absent (field not returned)", async () => {
- const status = await simulateTick({})
- expect(status).toBe("done")
- })
-
- it("resolves to 'done' when migrated_at is set even if migration_status is still 'pending'", async () => {
- // migrated_at takes priority over migration_status
- const status = await simulateTick({ migrated_at: 1700000000, migration_status: "pending" })
- expect(status).toBe("done")
- })
-
- it("polls the correct endpoint", async () => {
- ;(backendAuth.backendFetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ migration_status: "done" }),
- })
- await (backendAuth.backendFetch as jest.Mock)("/api/backend/auth/me")
- expect(backendAuth.backendFetch).toHaveBeenCalledWith("/api/backend/auth/me")
- })
-
- it("returns null when the fetch response is not ok", async () => {
- ;(backendAuth.backendFetch as jest.Mock).mockResolvedValueOnce({ ok: false })
- const res = await (backendAuth.backendFetch as jest.Mock)("/api/backend/auth/me")
- const status = res.ok ? "done" : null
- expect(status).toBeNull()
- })
-})
-
-describe("MigrationBanner — banner render guard (status contract)", () => {
- it("banner must NOT render when status is null", () => {
- // Mirrors: if (status !== 'pending') return null
- const status = null
- const wouldRender = status === "pending"
- expect(wouldRender).toBe(false)
- })
-
- it("banner must NOT render when status is 'done'", () => {
- const status: string = "done"
- const wouldRender = status === "pending"
- expect(wouldRender).toBe(false)
- })
-
- it("banner MUST render when status is 'pending'", () => {
- const status: string = "pending"
- const wouldRender = status === "pending"
- expect(wouldRender).toBe(true)
- })
-})
-
-describe("MigrationBanner — source structure assertions", () => {
- it("component polls /api/backend/auth/me (not /users/me)", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../migration-banner.tsx"),
- "utf8"
- )
- expect(source).toContain("/api/backend/auth/me")
- })
-
- it("component uses a cleanup function (timer clearTimeout)", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../migration-banner.tsx"),
- "utf8"
- )
- expect(source).toContain("clearTimeout")
- })
-
- it("component uses cancelled flag to prevent state updates after unmount", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../migration-banner.tsx"),
- "utf8"
- )
- expect(source).toContain("cancelled")
- })
-
- it("component has a max elapsed time guard (60s)", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../migration-banner.tsx"),
- "utf8"
- )
- expect(source).toContain("MAX_ELAPSED_MS")
- })
-
- it("banner text says 'Setting up your workspace'", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../migration-banner.tsx"),
- "utf8"
- )
- expect(source).toContain("Setting up your workspace")
- })
-})
-
-describe("EnsureBackendSession — workspace hydration wiring", () => {
- it("ensure-backend-session.tsx imports useWorkspaceStore", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../../components/ensure-backend-session.tsx"),
- "utf8"
- )
- expect(source).toContain("useWorkspaceStore")
- })
-
- it("ensure-backend-session.tsx calls loadFromBackend after session confirms", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../../components/ensure-backend-session.tsx"),
- "utf8"
- )
- expect(source).toContain("loadFromBackend")
- })
-
- it("workspace hydration is non-blocking (.catch not re-thrown)", () => {
- const fs = require("fs")
- const path = require("path")
- const source = fs.readFileSync(
- path.join(__dirname, "../../components/ensure-backend-session.tsx"),
- "utf8"
- )
- // loadFromBackend().catch(...) pattern confirms non-blocking
- expect(source).toMatch(/loadFromBackend\(\)\.catch/)
- })
-})
diff --git a/apps/desktop-ui/src/components/api-client/__tests__/environments-context.test.ts b/apps/desktop-ui/src/components/api-client/__tests__/environments-context.test.ts
index cb5aa5cf..27b17d6a 100644
--- a/apps/desktop-ui/src/components/api-client/__tests__/environments-context.test.ts
+++ b/apps/desktop-ui/src/components/api-client/__tests__/environments-context.test.ts
@@ -1,8 +1,4 @@
-jest.mock("react-firebase-hooks/auth", () => ({
- useAuthState: () => [null, false],
-}))
-jest.mock("@/database/firebase", () => ({ auth: {} }))
-jest.mock("@/lib/backend-auth", () => ({ backendFetch: jest.fn() }))
+jest.mock("@/lib/desktop/api-fetch", () => ({ apiFetch: jest.fn() }))
jest.mock("sonner", () => ({ toast: { success: jest.fn(), error: jest.fn() } }))
/**
diff --git a/apps/desktop-ui/src/components/api-client/__tests__/history-context.test.ts b/apps/desktop-ui/src/components/api-client/__tests__/history-context.test.ts
index 63547317..ff200d2e 100644
--- a/apps/desktop-ui/src/components/api-client/__tests__/history-context.test.ts
+++ b/apps/desktop-ui/src/components/api-client/__tests__/history-context.test.ts
@@ -1,8 +1,4 @@
-jest.mock("react-firebase-hooks/auth", () => ({
- useAuthState: () => [null, false],
-}))
-jest.mock("@/database/firebase", () => ({ auth: {} }))
-jest.mock("@/lib/backend-auth", () => ({ backendFetch: jest.fn() }))
+jest.mock("@/lib/desktop/api-fetch", () => ({ apiFetch: jest.fn() }))
/**
* Tests for HistoryContext (history-context.tsx).
diff --git a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx
index 686de5f2..1e8c41b6 100644
--- a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx
+++ b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx
@@ -8,7 +8,7 @@ import { Collection, CollectionRequest } from "../types"
import { CollectionItem } from "./collection-item"
import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe, PanelRightClose } from "lucide-react"
import { buildShareUrl } from "@/lib/share-link"
-import { backendFetch } from "@/lib/backend-auth"
+import { apiFetch } from "@/lib/desktop/api-fetch"
import { toast } from "sonner"
import { downloadCollectionAsPostman } from "@/lib/export/postman"
import { downloadCollectionAsOpenApi } from "@/lib/export/openapi"
@@ -340,7 +340,7 @@ export function CollectionsSidebar({
const idx = Number(choice)
const target = idx === 0 ? null : workspaces[idx - 1]?.id ?? null
try {
- const res = await backendFetch(`/api/backend/api-client/collections/${collection.id}`, {
+ const res = await apiFetch(`/api/backend/api-client/collections/${collection.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace: target }),
@@ -403,7 +403,7 @@ export function CollectionsSidebar({
onClick={async () => {
if (typeof window === "undefined") return
try {
- const res = await backendFetch("/api/backend/api-client/public-mocks", {
+ const res = await apiFetch("/api/backend/api-client/public-mocks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
diff --git a/apps/desktop-ui/src/components/api-client/collections/use-collections.ts b/apps/desktop-ui/src/components/api-client/collections/use-collections.ts
index 8c3dc957..4a7d64ae 100644
--- a/apps/desktop-ui/src/components/api-client/collections/use-collections.ts
+++ b/apps/desktop-ui/src/components/api-client/collections/use-collections.ts
@@ -3,9 +3,8 @@
import * as React from "react"
import { Collection, CollectionFolder, CollectionRequest } from "../types"
import { toast } from "sonner"
-import { auth } from "@/database/firebase"
-import { useAuthState } from "react-firebase-hooks/auth"
-import { backendFetch } from "@/lib/backend-auth"
+import useAuth from "@/utils/useAuth"
+import { apiFetch } from "@/lib/desktop/api-fetch"
import { broadcastApiClientUpdate, useApiClientSyncListener } from "@/lib/api-client-sync"
const STORAGE_KEY = "api-client-collections"
@@ -15,7 +14,7 @@ function sortCollections(cols: Collection[]) {
}
export function useCollections() {
- const [user, loading] = useAuthState(auth)
+ const { user, loading } = useAuth()
const [collections, setCollections] = React.useState([])
const [isLoading, setIsLoading] = React.useState(true)
const migrationRanRef = React.useRef(false)
@@ -27,7 +26,7 @@ export function useCollections() {
const authedFetch = React.useCallback(
async (path: string, init?: RequestInit) => {
if (!user) throw new Error("Not authenticated")
- const res = await backendFetch(path, {
+ const res = await apiFetch(path, {
...init,
headers: {
"Content-Type": "application/json",
diff --git a/apps/desktop-ui/src/components/api-client/comments-panel.tsx b/apps/desktop-ui/src/components/api-client/comments-panel.tsx
index b8302217..ac5f5d77 100644
--- a/apps/desktop-ui/src/components/api-client/comments-panel.tsx
+++ b/apps/desktop-ui/src/components/api-client/comments-panel.tsx
@@ -6,8 +6,7 @@ import { Textarea } from "@/components/ui/textarea"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Trash2, MessageSquare } from "lucide-react"
import type { RequestComment } from "./types"
-import { useAuthState } from "react-firebase-hooks/auth"
-import { auth } from "@/database/firebase"
+import { useAppUser } from "@/hooks/use-app-user"
/** Pulled out so the lint rule for impure render-time calls doesn't flag the inline use. */
const nowMs = (): number => Date.now()
@@ -18,11 +17,11 @@ interface CommentsPanelProps {
}
export function CommentsPanel({ comments, onChange }: CommentsPanelProps) {
- const [user] = useAuthState(auth)
+ const user = useAppUser()
const [draft, setDraft] = React.useState("")
const list = comments ?? []
- const myName = user?.displayName ?? user?.email ?? "Anonymous"
+ const myName = user.name || "Anonymous"
const handlePost = () => {
const text = draft.trim()
diff --git a/apps/desktop-ui/src/components/api-client/offline-indicator.tsx b/apps/desktop-ui/src/components/api-client/offline-indicator.tsx
index cfaf7248..94a23c2b 100644
--- a/apps/desktop-ui/src/components/api-client/offline-indicator.tsx
+++ b/apps/desktop-ui/src/components/api-client/offline-indicator.tsx
@@ -4,7 +4,7 @@ import * as React from "react"
import { CloudOff, Cloud, RefreshCw } from "lucide-react"
import { cn } from "@/lib/utils"
import { drainQueue, listQueue, subscribe, type QueuedMutation } from "@/lib/offline/queue"
-import { backendFetch } from "@/lib/backend-auth"
+import { apiFetch } from "@/lib/desktop/api-fetch"
import { toast } from "sonner"
export function OfflineIndicator() {
@@ -31,7 +31,7 @@ export function OfflineIndicator() {
if (listQueue().length === 0) return
setDraining(true)
try {
- const result = await drainQueue((path, init) => backendFetch(path, init))
+ const result = await drainQueue((path, init) => apiFetch(path, init))
if (result.succeeded > 0) {
toast.success(`Replayed ${result.succeeded} pending change${result.succeeded === 1 ? "" : "s"}`)
}
diff --git a/apps/desktop-ui/src/components/api-client/use-environments.ts b/apps/desktop-ui/src/components/api-client/use-environments.ts
index 7e6d7cf9..8150a9f9 100644
--- a/apps/desktop-ui/src/components/api-client/use-environments.ts
+++ b/apps/desktop-ui/src/components/api-client/use-environments.ts
@@ -2,9 +2,8 @@
import * as React from "react"
import { toast } from "sonner"
-import { auth } from "@/database/firebase"
-import { useAuthState } from "react-firebase-hooks/auth"
-import { backendFetch } from "@/lib/backend-auth"
+import useAuth from "@/utils/useAuth"
+import { apiFetch } from "@/lib/desktop/api-fetch"
import { broadcastApiClientUpdate, useApiClientSyncListener } from "@/lib/api-client-sync"
export interface EnvironmentVariable {
@@ -29,7 +28,7 @@ function sortEnvs(envs: Environment[]) {
}
export function useEnvironments() {
- const [user, loading] = useAuthState(auth)
+ const { user, loading } = useAuth()
const [environments, setEnvironments] = React.useState([])
const [activeEnvId, setActiveEnvId] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
@@ -42,7 +41,7 @@ export function useEnvironments() {
const authedFetch = React.useCallback(
async (path: string, init?: RequestInit) => {
if (!user) throw new Error("Not authenticated")
- const res = await backendFetch(path, {
+ const res = await apiFetch(path, {
...init,
headers: {
"Content-Type": "application/json",
diff --git a/apps/desktop-ui/src/components/api-client/use-history.ts b/apps/desktop-ui/src/components/api-client/use-history.ts
index e26b00e7..209facec 100644
--- a/apps/desktop-ui/src/components/api-client/use-history.ts
+++ b/apps/desktop-ui/src/components/api-client/use-history.ts
@@ -2,9 +2,8 @@
import { useState, useCallback, useEffect, useRef } from "react"
import { HistoryRequest, CollectionRequest } from "./types"
-import { auth } from "@/database/firebase"
-import { useAuthState } from "react-firebase-hooks/auth"
-import { backendFetch } from "@/lib/backend-auth"
+import useAuth from "@/utils/useAuth"
+import { apiFetch } from "@/lib/desktop/api-fetch"
import { broadcastApiClientUpdate, useApiClientSyncListener } from "@/lib/api-client-sync"
const HISTORY_STORAGE_KEY = "api-client-history"
@@ -71,7 +70,7 @@ function persistHistoryWithFallback(items: HistoryRequest[]): HistoryRequest[] {
}
export function useHistory() {
- const [user, loading] = useAuthState(auth)
+ const { user, loading } = useAuth()
const [history, setHistory] = useState([])
const [isHistoryLoading, setIsHistoryLoading] = useState(true)
const migrationRanRef = useRef(false)
@@ -79,7 +78,7 @@ export function useHistory() {
const authedFetch = useCallback(
async (path: string, init?: RequestInit) => {
if (!user) throw new Error("Not authenticated")
- const res = await backendFetch(path, {
+ const res = await apiFetch(path, {
...init,
headers: {
"Content-Type": "application/json",
diff --git a/apps/desktop-ui/src/components/api-client/use-workspaces.ts b/apps/desktop-ui/src/components/api-client/use-workspaces.ts
index 4d377650..07978ab8 100644
--- a/apps/desktop-ui/src/components/api-client/use-workspaces.ts
+++ b/apps/desktop-ui/src/components/api-client/use-workspaces.ts
@@ -1,9 +1,8 @@
"use client"
import * as React from "react"
-import { auth } from "@/database/firebase"
-import { useAuthState } from "react-firebase-hooks/auth"
-import { backendFetch } from "@/lib/backend-auth"
+import useAuth from "@/utils/useAuth"
+import { apiFetch } from "@/lib/desktop/api-fetch"
import { toast } from "sonner"
export interface Workspace {
@@ -15,7 +14,7 @@ export interface Workspace {
const ACTIVE_KEY = "api-client-active-workspace"
export function useWorkspaces() {
- const [user, loadingUser] = useAuthState(auth)
+ const { user, loading: loadingUser } = useAuth()
const [workspaces, setWorkspaces] = React.useState([])
const [activeId, setActiveId] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
@@ -37,7 +36,7 @@ export function useWorkspaces() {
const reload = React.useCallback(async () => {
if (!user) return
try {
- const res = await backendFetch("/api/backend/api-client/workspaces")
+ const res = await apiFetch("/api/backend/api-client/workspaces")
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setWorkspaces(await res.json())
} catch (e) {
@@ -56,7 +55,7 @@ export function useWorkspaces() {
const createWorkspace = async (name: string): Promise => {
if (!user) return null
try {
- const res = await backendFetch("/api/backend/api-client/workspaces", {
+ const res = await apiFetch("/api/backend/api-client/workspaces", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
@@ -75,7 +74,7 @@ export function useWorkspaces() {
const renameWorkspace = async (id: string, name: string) => {
if (!user) return
try {
- const res = await backendFetch(`/api/backend/api-client/workspaces/${id}`, {
+ const res = await apiFetch(`/api/backend/api-client/workspaces/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
@@ -93,7 +92,7 @@ export function useWorkspaces() {
const deleteWorkspace = async (id: string) => {
if (!user) return
try {
- const res = await backendFetch(`/api/backend/api-client/workspaces/${id}`, { method: "DELETE" })
+ const res = await apiFetch(`/api/backend/api-client/workspaces/${id}`, { method: "DELETE" })
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`)
setWorkspaces((prev) => prev.filter((w) => w.id !== id))
if (activeId === id) setActiveId(null)
diff --git a/apps/desktop-ui/src/components/api-key-vault/add-api-key-dialog.tsx b/apps/desktop-ui/src/components/api-key-vault/add-api-key-dialog.tsx
index ec005965..6729921a 100644
--- a/apps/desktop-ui/src/components/api-key-vault/add-api-key-dialog.tsx
+++ b/apps/desktop-ui/src/components/api-key-vault/add-api-key-dialog.tsx
@@ -12,7 +12,6 @@ import { Plus, Eye, EyeOff, KeyRound } from "lucide-react"
import { useIsMobile } from "@/components/hooks/use-mobile"
import { useApiKeyVaultStore, type ApiKeyEntry, type ApiKeyEnv } from "@/store/api-key-vault-store"
import { encryptData } from "@/lib/encryption"
-import { auth } from "@/database/firebase"
import { createApiKeyEntry, updateApiKeyEntry } from "@/lib/api-key-vault-api"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
@@ -270,10 +269,6 @@ export function AddApiKeyDialog({ children }: { children?: React.ReactNode }) {
if (!canWrite) return null
const handleSubmit = async (data: FormState) => {
- if (!auth.currentUser) {
- toast.error("Sign in to continue")
- return
- }
if (!encryptionKey) {
toast.error(cipherKeyErrorMessage())
return
@@ -351,10 +346,6 @@ export function EditApiKeyDialog({
if (!canWrite) return null
const handleSubmit = async (data: FormState) => {
- if (!auth.currentUser) {
- toast.error("Sign in to continue")
- return
- }
if (!encryptionKey) {
toast.error(cipherKeyErrorMessage())
return
diff --git a/apps/desktop-ui/src/components/auth-logout-listener.tsx b/apps/desktop-ui/src/components/auth-logout-listener.tsx
deleted file mode 100644
index dd4343e0..00000000
--- a/apps/desktop-ui/src/components/auth-logout-listener.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { FORCE_LOGOUT_EVENT, logoutUser, type LogoutReason } from "@/lib/logout-user"
-
-export function AuthLogoutListener() {
- const router = useRouter()
-
- React.useEffect(() => {
- const handler = (evt: Event) => {
- const detail = (evt as CustomEvent<{ reason?: LogoutReason }>).detail
- const reason = detail?.reason ?? "session-expired"
- ;(async () => {
- await logoutUser(reason)
- router.replace("/login")
- })()
- }
-
- window.addEventListener(FORCE_LOGOUT_EVENT, handler)
- return () => window.removeEventListener(FORCE_LOGOUT_EVENT, handler)
- }, [router])
-
- return null
-}
-
diff --git a/apps/desktop-ui/src/components/client-shell.tsx b/apps/desktop-ui/src/components/client-shell.tsx
index 849df1bf..c55ce442 100644
--- a/apps/desktop-ui/src/components/client-shell.tsx
+++ b/apps/desktop-ui/src/components/client-shell.tsx
@@ -5,7 +5,6 @@ import { Suspense, type ReactNode } from "react"
import { UserPreferencesSync } from "@/components/user-preferences-sync"
import { PinnedToolsPreferencesSync } from "@/components/pinned-tools-preferences-sync"
import { AppUpdateNotifier } from "@/components/app-update-notifier"
-import { AuthLogoutListener } from "@/components/auth-logout-listener"
const GlobalCommandPalette = dynamic(
() => import('@/components/global-command-palette').then((m) => m.GlobalCommandPalette),
@@ -38,7 +37,6 @@ export function ClientShell({ children }: Props) {
-
diff --git a/apps/desktop-ui/src/components/dashboard/dashboard-analytics-panel.tsx b/apps/desktop-ui/src/components/dashboard/dashboard-analytics-panel.tsx
index 77527fed..acdebee6 100644
--- a/apps/desktop-ui/src/components/dashboard/dashboard-analytics-panel.tsx
+++ b/apps/desktop-ui/src/components/dashboard/dashboard-analytics-panel.tsx
@@ -155,7 +155,7 @@ export function DashboardAnalyticsPanel() {
]
const toolkitChips = [
- { label: t('nosqlConnections'), value: data.nosqlConnections, icon: Database, accent: 'from-emerald-500 to-teal-500', href: '/app/database-explorer' },
+ { label: t('nosqlConnections'), value: data.nosqlConnections, icon: Database, accent: 'from-emerald-500 to-teal-500', href: '/app/data-explorer' },
{ label: t('apiClientCollections'), value: data.apiClientCollections, icon: Boxes, accent: 'from-slate-600 to-slate-800 dark:from-slate-500 dark:to-slate-700', href: '/app/api-client' },
{ label: t('apiClientEnvironments'), value: data.apiClientEnvironments, icon: Server, accent: 'from-fuchsia-500 to-pink-500', href: '/app/api-client' },
{ label: t('apiClientHistory'), value: data.apiClientHistoryEntries, icon: History, accent: 'from-orange-500 to-red-500', href: '/app/api-client' },
diff --git a/apps/desktop-ui/src/components/dashboard/dashboard-tool-card.tsx b/apps/desktop-ui/src/components/dashboard/dashboard-tool-card.tsx
index 6f286555..ce4d59d8 100644
--- a/apps/desktop-ui/src/components/dashboard/dashboard-tool-card.tsx
+++ b/apps/desktop-ui/src/components/dashboard/dashboard-tool-card.tsx
@@ -7,7 +7,6 @@ import { Sparkles, Pin } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { requiresAuth } from '@/lib/tool-config'
import { TOOL_PATH_TO_MESSAGE_KEY } from '@/lib/tool-i18n'
import { cn } from '@/lib/utils'
import { type ToolCardProps, DEFAULT_ACCENT, formatRelativeTime } from './types'
@@ -62,16 +61,8 @@ export const ToolCard = React.memo(function ToolCard({
})()
: item.description
- const itemRequiresAuth = item.url ? requiresAuth(item.url.toString()) : false
const a = accent ?? DEFAULT_ACCENT
- const handleClick = (e: React.MouseEvent) => {
- if (itemRequiresAuth && !user) {
- e.preventDefault()
- window.location.href = '/login'
- }
- }
-
const pinned = item.url ? isPinned(item.url.toString()) : false
const cardRef = React.useRef(null)
@@ -88,7 +79,6 @@ export const ToolCard = React.memo(function ToolCard({
diff --git a/apps/desktop-ui/src/components/sql-client/__tests__/results-binary.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/results-binary.test.ts
similarity index 96%
rename from apps/desktop-ui/src/components/sql-client/__tests__/results-binary.test.ts
rename to apps/desktop-ui/src/components/data-explorer/__tests__/results-binary.test.ts
index cc746e59..a9a64260 100644
--- a/apps/desktop-ui/src/components/sql-client/__tests__/results-binary.test.ts
+++ b/apps/desktop-ui/src/components/data-explorer/__tests__/results-binary.test.ts
@@ -2,7 +2,7 @@
// parsed under this node-environment jest config. This suite never renders.
jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k }));
-import { formatBinary } from "../results-table";
+import { formatBinary } from "../sql/results-table";
describe("formatBinary", () => {
it("summarises a blob as hex plus its true length", () => {
diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/sql-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/sql-adapter.test.ts
index b11a86c7..82112a62 100644
--- a/apps/desktop-ui/src/components/data-explorer/__tests__/sql-adapter.test.ts
+++ b/apps/desktop-ui/src/components/data-explorer/__tests__/sql-adapter.test.ts
@@ -15,7 +15,7 @@ import { getAdapter, SOURCE_ORDER, SOURCES } from "../sources";
import { normalizeSqlTabState } from "../adapters/sql";
import { sqlBody } from "@/lib/sql-request";
import { blankSqlConfig } from "@/lib/data-explorer/sql-api";
-import type { SqlConnectionConfig } from "@/components/sql-client/types";
+import type { SqlConnectionConfig } from "@/components/data-explorer/sql/types";
const ENGINES = ["postgresql", "mysql", "mariadb"] as const;
diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/elasticsearch.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/elasticsearch.tsx
index acf23c4b..3dfe5c57 100644
--- a/apps/desktop-ui/src/components/data-explorer/adapters/elasticsearch.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/adapters/elasticsearch.tsx
@@ -15,7 +15,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
-import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form";
+import { CONNECTION_COLORS } from "@/components/data-explorer/connection-colors";
import { ElasticsearchSearchPane } from "@/components/data-explorer/elasticsearch/search-pane";
import {
listIndices,
diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx
index 8577f653..514beb29 100644
--- a/apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/adapters/firestore.tsx
@@ -9,7 +9,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
-import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form";
+import { CONNECTION_COLORS } from "@/components/data-explorer/connection-colors";
import { FirestoreCollectionPane } from "@/components/data-explorer/firestore/collection-pane";
import {
databasePath,
diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx
index e2fac74c..94593911 100644
--- a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx
@@ -54,7 +54,7 @@ import {
normalizeConnectionString,
type DbType,
} from "@/lib/nosql-dialects";
-import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form";
+import { CONNECTION_COLORS } from "@/components/data-explorer/connection-colors";
import {
SidebarDialogs,
type BulkDeleteState,
@@ -63,12 +63,12 @@ import {
type DropDbState,
type RenameCollectionState,
type RenameDatabaseState,
-} from "@/components/nosql-explorer/sidebar-dialogs";
-import { DocumentView } from "@/components/nosql-explorer/document-view";
-import { GridFsBrowser } from "@/components/nosql-explorer/gridfs-browser";
-import { SyncDialog } from "@/components/nosql-explorer/sync-dialog";
-import { ServerMonitor } from "@/components/nosql-explorer/server-monitor";
-import type { Collection, Database, SavedConnection } from "@/components/nosql-explorer/types";
+} from "@/components/data-explorer/mongodb/sidebar-dialogs";
+import { DocumentView } from "@/components/data-explorer/mongodb/document-view";
+import { GridFsBrowser } from "@/components/data-explorer/mongodb/gridfs-browser";
+import { SyncDialog } from "@/components/data-explorer/mongodb/sync-dialog";
+import { ServerMonitor } from "@/components/data-explorer/mongodb/server-monitor";
+import type { Collection, Database, SavedConnection } from "@/components/data-explorer/mongodb/types";
import { useMongoActions } from "@/lib/data-explorer/mongo-actions";
import type { ConnectionFormProps, PaneProps, SidebarTreeProps, SourceAdapter } from "../types";
diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx
index 0df7e5cf..2bb1fa23 100644
--- a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx
@@ -33,7 +33,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { apiFetch } from "@/lib/desktop/api-fetch";
import { sanitizeError } from "@/lib/nosql-error-sanitizer";
-import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form";
+import { CONNECTION_COLORS } from "@/components/data-explorer/connection-colors";
import { KeyBrowser } from "@/components/data-explorer/redis/key-browser";
import { ValueEditor } from "@/components/data-explorer/redis/value-editor";
import { CommandPanel } from "@/components/data-explorer/redis/command-panel";
diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/sql.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/sql.tsx
index 8fc66c1a..061a1936 100644
--- a/apps/desktop-ui/src/components/data-explorer/adapters/sql.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/adapters/sql.tsx
@@ -20,14 +20,14 @@ import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { apiFetch } from "@/lib/desktop/api-fetch";
import { sqlBody } from "@/lib/sql-request";
-import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form";
-import { DbIcon } from "@/components/sql-client/db-icon";
+import { CONNECTION_COLORS } from "@/components/data-explorer/connection-colors";
+import { DbIcon } from "@/components/data-explorer/sql/db-icon";
import type {
ColumnInfo,
SchemaInfo,
SqlConnectionConfig,
TableInfo,
-} from "@/components/sql-client/types";
+} from "@/components/data-explorer/sql/types";
import {
applyPastedHost,
blankSqlConfig,
diff --git a/apps/desktop-ui/src/components/data-explorer/connection-colors.ts b/apps/desktop-ui/src/components/data-explorer/connection-colors.ts
new file mode 100644
index 00000000..81b0172e
--- /dev/null
+++ b/apps/desktop-ui/src/components/data-explorer/connection-colors.ts
@@ -0,0 +1,5 @@
+// Swatches offered when tagging a saved connection. Shared by every adapter's
+// connection dialog so colors stay consistent across data sources.
+export const CONNECTION_COLORS = [
+ "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#a855f7", "#64748b",
+] as const;
diff --git a/apps/desktop-ui/src/components/data-explorer/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/connection-service.ts
index 15b01030..4a1f095c 100644
--- a/apps/desktop-ui/src/components/data-explorer/connection-service.ts
+++ b/apps/desktop-ui/src/components/data-explorer/connection-service.ts
@@ -1,20 +1,15 @@
import { encryptData, decryptData } from "@/lib/encryption";
-import { proxyJsonAuthed } from "@/lib/backend-auth";
+import { apiRequestRaw } from "@/lib/backend-api";
import { toast } from "sonner";
import type { ConnectionFormValues, SourceId, UnifiedConnection } from "./types";
-const BACKEND_BASE_URL: string =
- process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL ||
- "http://localhost:8000";
-
const BASE_PATH = "/api/v1/data-explorer/connections";
/** Raw store row — the decrypted `config` is never present on the wire. */
export type UnifiedConnectionRaw = Omit;
async function proxyRequest(method: string, path: string, body?: unknown): Promise {
- const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body);
+ const { status, data } = await apiRequestRaw(method, path, body);
if (status < 200 || status >= 300) {
const err = data as Record | null;
throw new Error(
diff --git a/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx
index b098ff4a..44041083 100644
--- a/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx
@@ -14,9 +14,9 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import useAuth from "@/utils/useAuth";
import { useMasterKeyStore } from "@/store/master-key-store";
-import { getConnections as getMongoConnections } from "@/components/nosql-explorer/connection-service";
+import { getConnections as getMongoConnections } from "@/components/data-explorer/mongodb/connection-service";
import { getConnections as getRedisConnections } from "@/components/data-explorer/redis/connection-service";
-import { getConnections as getSqlConnections } from "@/components/sql-client/connection-service";
+import { getConnections as getSqlConnections } from "@/components/data-explorer/sql/connection-service";
import {
dedupeAgainstExisting,
legacyMongoToUnified,
diff --git a/apps/desktop-ui/src/components/nosql-explorer/cells.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/cells.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/cells.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/cells.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/codegen-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/codegen-dialog.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/codegen-dialog.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/codegen-dialog.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/mongodb/connection-service.ts
similarity index 92%
rename from apps/desktop-ui/src/components/nosql-explorer/connection-service.ts
rename to apps/desktop-ui/src/components/data-explorer/mongodb/connection-service.ts
index 45fd8a66..2b6bfbaa 100644
--- a/apps/desktop-ui/src/components/nosql-explorer/connection-service.ts
+++ b/apps/desktop-ui/src/components/data-explorer/mongodb/connection-service.ts
@@ -1,15 +1,9 @@
-import { auth } from "@/database/firebase";
import { encryptData, decryptData } from "@/lib/encryption";
-import { proxyJsonAuthed } from "@/lib/backend-auth";
+import { apiRequestRaw } from "@/lib/backend-api";
import { toast } from "sonner";
import { SavedConnection } from "./types";
import type { DbType } from "@/lib/nosql-dialects";
-const BACKEND_BASE_URL: string =
- process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL ||
- "http://localhost:8000";
-
// ── proxy helper (with automatic token refresh on 401) ───────────────────────
const proxyRequest = async (
@@ -17,9 +11,8 @@ const proxyRequest = async (
path: string,
body?: unknown
): Promise => {
- if (!auth.currentUser) throw new Error("Not authenticated.");
- const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body);
+ const { status, data } = await apiRequestRaw(method, path, body);
if (status < 200 || status >= 300) {
const err = data as Record | null;
diff --git a/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/document-view.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/document-view.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/document-view.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/export-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/export-dialog.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/gridfs-browser.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/gridfs-browser.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/gridfs-browser.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/gridfs-browser.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/import-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/import-dialog.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/import-dialog.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/import-dialog.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/index-manager.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/index-manager.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/index-manager.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/index-manager.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/json-tree.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/json-tree.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/json-tree.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/json-tree.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/pipeline-builder.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/pipeline-builder.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/pipeline-builder.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/pipeline-builder.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/query-builder.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/query-builder.tsx
similarity index 99%
rename from apps/desktop-ui/src/components/nosql-explorer/query-builder.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/query-builder.tsx
index 4218e1b5..2dc7b291 100644
--- a/apps/desktop-ui/src/components/nosql-explorer/query-builder.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/mongodb/query-builder.tsx
@@ -8,8 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
-import { auth } from "@/database/firebase";
-import { useAuthState } from "react-firebase-hooks/auth";
+import useAuth from "@/utils/useAuth";
import {
getNosqlQueryHistory, putNosqlQueryHistory,
getNosqlSavedQueries, putNosqlSavedQueries, NosqlSavedQuery,
@@ -65,7 +64,7 @@ export function QueryBuilder({
const [savedQueries, setSavedQueries] = useState([]);
const [saveName, setSaveName] = useState("");
const [builderOpen, setBuilderOpen] = useState(false);
- const [user] = useAuthState(auth);
+ const { user } = useAuth();
const { theme } = useTheme();
const [advancedOpen, setAdvancedOpen] = useState(false);
const [advancedMode, setAdvancedMode] = useState<"json" | "stages">("json");
diff --git a/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/schema-view.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/schema-view.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/server-monitor.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/server-monitor.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/server-monitor.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/server-monitor.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/sidebar-dialogs.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/sidebar-dialogs.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/sidebar-dialogs.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/sidebar-dialogs.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/sync-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/sync-dialog.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/sync-dialog.tsx
rename to apps/desktop-ui/src/components/data-explorer/mongodb/sync-dialog.tsx
diff --git a/apps/desktop-ui/src/components/nosql-explorer/types.ts b/apps/desktop-ui/src/components/data-explorer/mongodb/types.ts
similarity index 100%
rename from apps/desktop-ui/src/components/nosql-explorer/types.ts
rename to apps/desktop-ui/src/components/data-explorer/mongodb/types.ts
diff --git a/apps/desktop-ui/src/components/data-explorer/redis/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/redis/connection-service.ts
index 44be345c..3238c393 100644
--- a/apps/desktop-ui/src/components/data-explorer/redis/connection-service.ts
+++ b/apps/desktop-ui/src/components/data-explorer/redis/connection-service.ts
@@ -1,19 +1,10 @@
-import { auth } from "@/database/firebase";
import { encryptData, decryptData } from "@/lib/encryption";
import { toast } from "sonner";
-import { proxyJsonAuthed } from "@/lib/backend-auth";
+import { apiRequestRaw } from "@/lib/backend-api";
import { RedisConnectionConfig, SavedRedisConnection } from "./types";
-const BACKEND_BASE_URL: string =
- process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL ||
- "http://localhost:8000";
-
async function proxyRequest(method: string, path: string, body?: unknown): Promise {
- const currentUser = auth.currentUser;
- if (!currentUser) throw new Error("Not authenticated.");
-
- const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body);
+ const { status, data } = await apiRequestRaw(method, path, body);
if (status < 200 || status >= 300) {
throw new Error(`Request failed (${status})`);
}
diff --git a/apps/desktop-ui/src/components/sql-client/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/sql/connection-service.ts
similarity index 86%
rename from apps/desktop-ui/src/components/sql-client/connection-service.ts
rename to apps/desktop-ui/src/components/data-explorer/sql/connection-service.ts
index f728cf24..1465359f 100644
--- a/apps/desktop-ui/src/components/sql-client/connection-service.ts
+++ b/apps/desktop-ui/src/components/data-explorer/sql/connection-service.ts
@@ -1,19 +1,10 @@
-import { auth } from "@/database/firebase";
import { encryptData, decryptData } from "@/lib/encryption";
import { toast } from "sonner";
-import { proxyJsonAuthed } from "@/lib/backend-auth";
+import { apiRequestRaw } from "@/lib/backend-api";
import { SavedSqlConnection, SqlConnectionConfig } from "./types";
-const BACKEND_BASE_URL: string =
- process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL ||
- "http://localhost:8000";
-
async function proxyRequest(method: string, path: string, body?: unknown): Promise {
- const currentUser = auth.currentUser;
- if (!currentUser) throw new Error("Not authenticated.");
-
- const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body);
+ const { status, data } = await apiRequestRaw(method, path, body);
if (status < 200 || status >= 300) {
throw new Error(`Request failed (${status})`);
}
diff --git a/apps/desktop-ui/src/components/sql-client/db-icon.tsx b/apps/desktop-ui/src/components/data-explorer/sql/db-icon.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/sql-client/db-icon.tsx
rename to apps/desktop-ui/src/components/data-explorer/sql/db-icon.tsx
diff --git a/apps/desktop-ui/src/components/data-explorer/sql/query-pane.tsx b/apps/desktop-ui/src/components/data-explorer/sql/query-pane.tsx
index 9d2a0ee4..1720a7ce 100644
--- a/apps/desktop-ui/src/components/data-explorer/sql/query-pane.tsx
+++ b/apps/desktop-ui/src/components/data-explorer/sql/query-pane.tsx
@@ -37,8 +37,8 @@ import {
putSqlSavedQueries,
type NosqlSavedQuery,
} from "@/lib/user-preferences-api";
-import { ResultsTable } from "@/components/sql-client/results-table";
-import type { QueryResult } from "@/components/sql-client/types";
+import { ResultsTable } from "./results-table";
+import type { QueryResult } from "./types";
import type { PaneProps } from "../types";
import type { SqlTabState } from "../adapters/sql";
diff --git a/apps/desktop-ui/src/components/sql-client/results-table.tsx b/apps/desktop-ui/src/components/data-explorer/sql/results-table.tsx
similarity index 100%
rename from apps/desktop-ui/src/components/sql-client/results-table.tsx
rename to apps/desktop-ui/src/components/data-explorer/sql/results-table.tsx
diff --git a/apps/desktop-ui/src/components/sql-client/types.ts b/apps/desktop-ui/src/components/data-explorer/sql/types.ts
similarity index 100%
rename from apps/desktop-ui/src/components/sql-client/types.ts
rename to apps/desktop-ui/src/components/data-explorer/sql/types.ts
diff --git a/apps/desktop-ui/src/components/desktop/desktop-init.tsx b/apps/desktop-ui/src/components/desktop/desktop-init.tsx
index 8451f37d..2f9c0389 100644
--- a/apps/desktop-ui/src/components/desktop/desktop-init.tsx
+++ b/apps/desktop-ui/src/components/desktop/desktop-init.tsx
@@ -1,48 +1,23 @@
"use client";
import { useEffect } from "react";
-import { usePathname, useRouter } from "next/navigation";
import { toast } from "sonner";
import { isDesktop } from "@/lib/desktop/is-desktop";
import { useWorkspaceStore } from "@/store/workspace-store";
/**
- * Desktop-only bootstrap: mandatory one-time activation gate, deep-link
- * sign-in listener, startup session probe, and update check.
+ * Desktop-only bootstrap: workspace hydration and the update check.
* Renders nothing; a no-op on web (the isDesktop guard compiles to false).
*/
export function DesktopInit() {
- const router = useRouter();
- const pathname = usePathname();
-
- // Activation gate: without a local activation record every route funnels to
- // /activate. Local check only — never blocks on the network.
- useEffect(() => {
- if (!isDesktop() || pathname === "/activate") return;
- void (async () => {
- const { getActivation } = await import("@/lib/desktop/activation");
- const activated = await getActivation().catch(() => null);
- if (!activated) router.replace("/activate");
- })();
- }, [router, pathname]);
-
useEffect(() => {
if (!isDesktop()) return;
- void (async () => {
- const [{ initDeepLinkListener }, { checkRemoteSession }] = await Promise.all([
- import("@/lib/desktop/cloud-signin"),
- import("@/lib/desktop/remote"),
- ]);
- await initDeepLinkListener().catch(() => {});
- await checkRemoteSession().catch(() => {});
- // Hydrate the workspace store so encrypted tools (API keys, password
- // manager, environment manager) resolve the always-present local personal
- // workspace. Without this the store stays empty offline → activeWs null →
- // cipher key null → "No active workspace." Runs after the session probe so
- // a remote session (if any) merges in.
- await useWorkspaceStore.getState().loadFromBackend().catch(() => {});
- })();
+ // Hydrate the workspace store so encrypted tools (API keys, password
+ // manager, environment manager) resolve the always-present local personal
+ // workspace. Without this the store stays empty → activeWs null → cipher
+ // key null → "No active workspace."
+ void useWorkspaceStore.getState().loadFromBackend().catch(() => {});
}, []);
// Auto-update notification: check on launch and every 6h so long-running
diff --git a/apps/desktop-ui/src/components/desktop/desktop-login.tsx b/apps/desktop-ui/src/components/desktop/desktop-login.tsx
deleted file mode 100644
index d074e63e..00000000
--- a/apps/desktop-ui/src/components/desktop/desktop-login.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { Loader2, ExternalLink, WifiOff } from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-
-/**
- * Desktop sign-in panel. OAuth popups don't work in WKWebView, so cloud
- * sign-in opens the system browser (`/login?desktop=1`), which hands a
- * Firebase custom token back through the loopback callback.
- */
-export function DesktopLogin() {
- const [busy, setBusy] = useState(false);
- const [online, setOnline] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- setOnline(navigator.onLine);
- const on = () => setOnline(true);
- const off = () => setOnline(false);
- window.addEventListener("online", on);
- window.addEventListener("offline", off);
- // The deep-link handler navigates to /dashboard on success; nothing to do here.
- return () => {
- window.removeEventListener("online", on);
- window.removeEventListener("offline", off);
- };
- }, []);
-
- const signIn = async () => {
- setBusy(true);
- setError(null);
- try {
- const { startCloudSignIn } = await import("@/lib/desktop/cloud-signin");
- // Resolves once the browser hands the token back and the session is set;
- // DesktopInit then routes to /dashboard.
- await startCloudSignIn();
- } catch (e) {
- setError(e instanceof Error ? e.message : "Sign-in failed. Please try again.");
- } finally {
- setBusy(false);
- }
- };
-
- return (
-
- {/* Stays clickable while waiting so a stalled attempt can be retried
- (each click opens a fresh browser handoff; old waits time out). */}
-
void signIn()} disabled={!online}>
- {busy ? (
- <>
-
- Waiting for your browser…
- >
- ) : (
- <>
-
- Sign in with your browser
- >
- )}
-
-
- {busy && (
-
- Finish signing in in your browser, then return here. Click again to restart.
-
- )}
-
- {!online && (
-
-
- You're offline — sign-in needs a connection.
-
- )}
-
- {error &&
{error}
}
-
-
- Your data is encrypted and stays on this Mac.
-
-
- );
-}
diff --git a/apps/desktop-ui/src/components/desktop/desktop-plan-settings.tsx b/apps/desktop-ui/src/components/desktop/desktop-plan-settings.tsx
deleted file mode 100644
index 1188f173..00000000
--- a/apps/desktop-ui/src/components/desktop/desktop-plan-settings.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { useTranslations } from 'next-intl'
-import { ExternalLink, UserRound } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { isDesktop } from '@/lib/desktop/is-desktop'
-import type { ActivationRecord } from '@/lib/desktop/activation'
-import { DesktopUpdateDialog } from './desktop-update-dialog'
-
-/** Settings card: locally-stored account summary + link to the web dashboard. */
-export function DesktopPlanSettings() {
- const t = useTranslations('AccountCard')
- const [record, setRecord] = useState(null)
-
- useEffect(() => {
- if (!isDesktop()) return
- void import('@/lib/desktop/activation').then(({ getActivation }) =>
- getActivation().then(setRecord).catch(() => {})
- )
- }, [])
-
- if (!isDesktop() || !record) return null
-
- const openAccountPage = async () => {
- const [{ openUrl }, { desktopWebBase }] = await Promise.all([
- import('@tauri-apps/plugin-opener'),
- import('@/lib/desktop/remote'),
- ])
- await openUrl(`${desktopWebBase()}/dashboard`)
- }
-
- return (
-
-
-
-
-
-
- {t('title')}
-
-
- {record.display_name || record.email}
-
-
-
- void openAccountPage()}>
-
- {t('manage')}
-
-
-
-
- )
-}
diff --git a/apps/desktop-ui/src/components/ensure-backend-session.tsx b/apps/desktop-ui/src/components/ensure-backend-session.tsx
deleted file mode 100644
index 1626174b..00000000
--- a/apps/desktop-ui/src/components/ensure-backend-session.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client"
-
-import { useEffect, useState } from "react"
-import type { User } from "firebase/auth"
-import { ensureBackendSession } from "@/lib/backend-auth"
-import { AppLoadingScreen } from "@/components/app-loading-screen"
-import { useWorkspaceStore } from "@/store/workspace-store"
-
-type Props = {
- user: User | null
- children: React.ReactNode
-}
-
-// Module-level cache: tracks which uid already has a valid backend session this page session.
-// Cleared when the user changes (logout), preventing stale entries.
-let confirmedSessionUid: string | null = null
-
-/**
- * When Firebase has a user, ensures HttpOnly JWT cookies exist (new login or expired cookies).
- * Caches the result per-uid so client-side re-navigation between routes never re-shows
- * the full-screen spinner for an already-confirmed session.
- */
-export function EnsureBackendSession({ user, children }: Props) {
- const alreadyConfirmed = !!user && user.uid === confirmedSessionUid
- const [ready, setReady] = useState(!user || alreadyConfirmed)
-
- useEffect(() => {
- if (!user) {
- confirmedSessionUid = null
- setReady(true)
- return
- }
- if (user.uid === confirmedSessionUid) {
- setReady(true)
- return
- }
- setReady(false)
- let cancelled = false
- ;(async () => {
- try {
- await ensureBackendSession(user)
- confirmedSessionUid = user.uid
- // Hydrate workspace store once per auth session, non-blocking
- useWorkspaceStore.getState().loadFromBackend().catch((e) => {
- console.warn("Workspace hydration failed:", e)
- })
- } catch (e) {
- console.error("Backend session sync failed:", e)
- } finally {
- if (!cancelled) setReady(true)
- }
- })()
- return () => {
- cancelled = true
- }
- }, [user])
-
- if (user && !ready) {
- return
- }
-
- return <>{children}>
-}
diff --git a/apps/desktop-ui/src/components/environment-manager/add-environment-set-dialog.tsx b/apps/desktop-ui/src/components/environment-manager/add-environment-set-dialog.tsx
index 1d4abbbc..13c8c35c 100644
--- a/apps/desktop-ui/src/components/environment-manager/add-environment-set-dialog.tsx
+++ b/apps/desktop-ui/src/components/environment-manager/add-environment-set-dialog.tsx
@@ -18,7 +18,6 @@ import { useActiveToolPermissions } from "@/lib/workspace-rbac"
import { Badge } from "@/components/ui/badge"
import { EnvPasteCollapsible } from "@/components/environment-manager/env-paste-collapsible"
import { encryptData } from "@/lib/encryption"
-import { auth } from "@/database/firebase"
import { createEnvSetEntry } from "@/lib/environment-manager-api"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
@@ -81,7 +80,7 @@ export function AddEnvironmentSetDialog({ children }: { children?: React.ReactNo
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- if (!encryptionKey || !auth.currentUser) return
+ if (!encryptionKey) return
const proj = project.trim()
const env = environment.trim()
if (!proj || !env) {
diff --git a/apps/desktop-ui/src/components/environment-manager/edit-environment-set-dialog.tsx b/apps/desktop-ui/src/components/environment-manager/edit-environment-set-dialog.tsx
index 29ec7755..7a4d9c8c 100644
--- a/apps/desktop-ui/src/components/environment-manager/edit-environment-set-dialog.tsx
+++ b/apps/desktop-ui/src/components/environment-manager/edit-environment-set-dialog.tsx
@@ -18,7 +18,6 @@ import { useCipherKey } from "@/lib/use-cipher-key"
import { Badge } from "@/components/ui/badge"
import { EnvPasteCollapsible } from "@/components/environment-manager/env-paste-collapsible"
import { encryptData } from "@/lib/encryption"
-import { auth } from "@/database/firebase"
import { updateEnvSetEntry } from "@/lib/environment-manager-api"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
@@ -89,7 +88,7 @@ export function EditEnvironmentSetDialog({ entry, open, onOpenChange }: EditEnvi
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- if (!encryptionKey || !auth.currentUser || !entry) return
+ if (!encryptionKey || !entry) return
const proj = project.trim()
const env = environment.trim()
if (!proj || !env) {
diff --git a/apps/desktop-ui/src/components/feedback-dialog.tsx b/apps/desktop-ui/src/components/feedback-dialog.tsx
deleted file mode 100644
index 78373ea4..00000000
--- a/apps/desktop-ui/src/components/feedback-dialog.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import { MessageSquarePlus, Loader2, CheckCircle2, Star } from "lucide-react";
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
-import { Label } from "@/components/ui/label";
-import { cn } from "@/lib/utils";
-import useAuth from "@/utils/useAuth";
-import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
-import { usePathname } from "next/navigation";
-import { backendFetch } from "@/lib/backend-auth";
-
-type FeedbackType = "bug" | "feature" | "general";
-
-const TYPES: { value: FeedbackType; label: string; emoji: string }[] = [
- { value: "bug", label: "Bug report", emoji: "🐛" },
- { value: "feature", label: "Feature request", emoji: "✨" },
- { value: "general", label: "General", emoji: "💬" },
-];
-
-async function submitFeedback(payload: {
- type: FeedbackType;
- message: string;
- rating: number | null;
- email: string | null;
- page: string;
-}) {
- const res = await backendFetch("/api/backend/feedback", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(err?.detail ?? "Failed to submit feedback");
- }
- return res.json();
-}
-
-function StarRating({
- value,
- onChange,
-}: {
- value: number | null;
- onChange: (v: number | null) => void;
-}) {
- const [hovered, setHovered] = useState(null);
- return (
-
- {[1, 2, 3, 4, 5].map((n) => {
- const filled = (hovered ?? value ?? 0) >= n;
- return (
- onChange(value === n ? null : n)}
- onMouseEnter={() => setHovered(n)}
- onMouseLeave={() => setHovered(null)}
- className="p-0.5 transition-transform hover:scale-110 active:scale-95"
- aria-label={`${n} star${n > 1 ? "s" : ""}`}
- >
-
-
- );
- })}
-
- );
-}
-
-export function FeedbackDialog({ variant }: { variant?: "sidebar" }) {
- const { user } = useAuth(false);
- const pathname = usePathname();
-
- const [open, setOpen] = useState(false);
- const [type, setType] = useState("general");
- const [message, setMessage] = useState("");
- const [rating, setRating] = useState(null);
- const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
- const [errorMsg, setErrorMsg] = useState("");
-
- function reset() {
- setType("general");
- setMessage("");
- setRating(null);
- setStatus("idle");
- setErrorMsg("");
- }
-
- function handleClose(v: boolean) {
- if (!v) {
- setOpen(false);
- if (status === "success") reset();
- } else {
- setOpen(true);
- }
- }
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- if (message.trim().length < 10) {
- setErrorMsg("Message must be at least 10 characters.");
- return;
- }
- setStatus("loading");
- setErrorMsg("");
- try {
- await submitFeedback({
- type,
- message: message.trim(),
- rating,
- email: user?.email ?? null,
- page: pathname,
- });
- setStatus("success");
- } catch (err) {
- setStatus("error");
- setErrorMsg(err instanceof Error ? err.message : "Something went wrong.");
- }
- }
-
- const dialog = (
-
-
-
-
-
- Share your feedback
-
-
-
- {status === "success" ? (
-
-
-
Thanks for the feedback!
-
We read every submission.
-
{ reset(); setOpen(false); }}
- >
- Close
-
-
- ) : (
-
- )}
-
-
- );
-
- if (variant === "sidebar") {
- return (
- <>
-
-
- setOpen(true)}
- tooltip="Feedback"
- className="text-muted-foreground hover:text-foreground"
- >
-
- Feedback
-
-
-
- {dialog}
- >
- );
- }
-
- return (
- <>
- setOpen(true)}
- className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-50 inline-flex items-center gap-2 h-10 px-4 rounded-full shadow-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 hover:scale-105 active:scale-95 transition-all duration-200"
- aria-label="Give feedback"
- >
-
- Feedback
-
- {dialog}
- >
- );
-}
diff --git a/apps/desktop-ui/src/components/global-command-palette.tsx b/apps/desktop-ui/src/components/global-command-palette.tsx
index 92985236..30293548 100644
--- a/apps/desktop-ui/src/components/global-command-palette.tsx
+++ b/apps/desktop-ui/src/components/global-command-palette.tsx
@@ -13,7 +13,6 @@ import {
CommandSeparator,
} from '@/components/ui/command'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
-import { requiresAuth } from '@/lib/tool-config'
import useAuth from '@/utils/useAuth'
import { cn } from '@/lib/utils'
import { Star } from 'lucide-react'
@@ -126,13 +125,6 @@ export function GlobalCommandPalette() {
const run = React.useCallback(
(entry: PaletteEntry) => {
- if (entry.requiresAuth && !user) {
- setOpen(false)
- setTimeout(() => {
- window.location.href = '/login'
- }, 0)
- return
- }
setRecentEntries((prev) => {
const nextUrls = [entry.url, ...prev.map((e) => e.url).filter((u) => u !== entry.url)].slice(0, 8)
const mapped = nextUrls
diff --git a/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx b/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx
index 981c9021..d027addc 100644
--- a/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx
+++ b/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx
@@ -28,8 +28,7 @@ import { ToolPageHeader } from '@/components/tools/tool-page-header'
import { ToolMobileTabs } from '@/components/tools/tool-mobile-tabs'
import { RevealItem } from '@/components/dashboard/dashboard-reveal'
import { Button } from '@/components/ui/button'
-import useAuth from '@/utils/useAuth'
-import { backendFetch } from '@/lib/backend-auth'
+import { apiFetch } from '@/lib/desktop/api-fetch'
import { repairJSON } from '@/lib/json-utils/repair'
import { sortKeysDeep } from '@/lib/json-utils/sort'
import { VanillaEditor, type VanillaEditorInstance } from './vanilla-editor'
@@ -118,15 +117,13 @@ const createPaneState = (initialName: string, mode: Mode): PaneState => ({
export function JsonFormatterLayout() {
const t = useTranslations('JsonFormatter')
- const { user } = useAuth(false)
const { copyToClipboard } = useCopyToClipboard()
const searchParams = useSearchParams()
const initialInputParam = searchParams.get('input')
- const authedFetch = useCallback(
+ const storeFetch = useCallback(
async (path: string, init?: RequestInit) => {
- if (!user) throw new Error('Not authenticated')
- const res = await backendFetch(path, {
+ const res = await apiFetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
@@ -139,7 +136,7 @@ export function JsonFormatterLayout() {
}
return res
},
- [user]
+ []
)
const [leftPane, setLeftPane] = useState(() => {
let content: Content = { json: initialJson };
@@ -287,11 +284,6 @@ export function JsonFormatterLayout() {
}
const handleSave = async (pane: PaneKey) => {
- if (!user) {
- toast.error(t('toastLoginRequired'))
- return
- }
-
const paneState = pane === 'left' ? leftPane : rightPane
try {
updatePane(pane, (prev) => ({ ...prev, isSaving: true }))
@@ -303,14 +295,14 @@ export function JsonFormatterLayout() {
}
if (paneState.documentId) {
- const res = await authedFetch(
+ const res = await storeFetch(
`/api/backend/json-formatter/documents/${paneState.documentId}`,
{ method: 'PATCH', body: JSON.stringify(body) }
)
const saved = (await res.json()) as { id: string }
updatePane(pane, (prev) => ({ ...prev, documentId: saved.id }))
} else {
- const res = await authedFetch('/api/backend/json-formatter/documents', {
+ const res = await storeFetch('/api/backend/json-formatter/documents', {
method: 'POST',
body: JSON.stringify(body),
})
@@ -331,7 +323,7 @@ export function JsonFormatterLayout() {
const allDocs = await fetchAllPages({
pageSize: DOCS_PAGE_SIZE,
fetchPage: async (skip, limit) => {
- const res = await authedFetch(
+ const res = await storeFetch(
`/api/backend/json-formatter/documents?skip=${skip}&limit=${limit}`
)
return (await res.json()) as JsonFormatterDocumentOut[]
@@ -342,10 +334,6 @@ export function JsonFormatterLayout() {
}
const openLoadDialog = async (pane: PaneKey) => {
- if (!user) {
- toast.error(t('toastLoginRequired'))
- return
- }
setLoadPane(pane)
setLoadOpen(true)
setDocsLoading(true)
@@ -362,7 +350,7 @@ export function JsonFormatterLayout() {
const loadDocumentIntoPane = async (pane: PaneKey, docId: string) => {
try {
- const res = await authedFetch(`/api/backend/json-formatter/documents/${docId}`)
+ const res = await storeFetch(`/api/backend/json-formatter/documents/${docId}`)
const doc = (await res.json()) as JsonFormatterDocumentOut
const title = doc?.title || ''
const contentText = doc?.content || ''
diff --git a/apps/desktop-ui/src/components/login-form.tsx b/apps/desktop-ui/src/components/login-form.tsx
deleted file mode 100644
index f4be2ef6..00000000
--- a/apps/desktop-ui/src/components/login-form.tsx
+++ /dev/null
@@ -1,327 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import {
- GoogleAuthProvider,
- GithubAuthProvider,
- signInWithPopup,
- fetchSignInMethodsForEmail,
- linkWithCredential,
- OAuthProvider,
-} from "firebase/auth";
-import { auth } from "../database/firebase";
-import { useEffect, useRef, useState } from "react";
-import { establishBackendSession } from "@/lib/backend-auth";
-import { handoffDesktopToken } from "@/lib/desktop-handoff";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Loader2, AlertCircle, Github, Fingerprint } from "lucide-react";
-import { signInWithPasskey, startConditionalPasskeyAuth } from "@/lib/passkey"
-import { toast } from "sonner";
-import { isDesktop } from "@/lib/desktop/is-desktop";
-import { DesktopLogin } from "@/components/desktop/desktop-login";
-
-export function LoginForm() {
- // Gate on mount so the statically-exported HTML (window undefined → web form)
- // doesn't mismatch the desktop client render.
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
- if (!mounted) return null;
-
- // Desktop (Tauri) can't use OAuth popups in WKWebView — it signs in through
- // the system browser instead. Web keeps the full provider/passkey flow.
- if (isDesktop()) {
- return ;
- }
- return ;
-}
-
-function WebLoginForm() {
- const router = useRouter();
- const [loadingProvider, setLoadingProvider] = useState<"google" | "github" | "passkey" | "">("");
- const [error, setError] = useState("");
- const conditionalStarted = useRef(false);
-
- // Conditional autofill: surfaces passkeys in the username field's autocomplete UI.
- useEffect(() => {
- if (conditionalStarted.current) return;
- conditionalStarted.current = true;
- let aborted = false;
- (async () => {
- try {
- const result = await startConditionalPasskeyAuth();
- if (!aborted && result) router.replace("/dashboard");
- } catch {
- // Conditional auth races with explicit button; ignore silently.
- }
- })();
- return () => {
- aborted = true;
- };
- }, [router]);
-
- // After a successful login, check for ?invite= in the URL and
- // auto-accept the invitation, then redirect to the invited workspace or
- // the dashboard. Always redirects — never throws or blocks navigation.
- const handleInviteToken = async (): Promise => {
- const params = new URLSearchParams(
- typeof window !== "undefined" ? window.location.search : ""
- )
- // Desktop-app sign-in handoff: mint a token and hand it back to the app
- // (loopback callback when ?cb= present, else the mydevtools:// deep link).
- if (params.get("desktop") === "1") {
- const ok = await handoffDesktopToken(window.location.search)
- toast[ok ? "success" : "error"](
- ok ? "Signed in — returning to the MyDevTools app…" : "Could not hand off sign-in to the desktop app"
- )
- return "/dashboard"
- }
-
- return "/dashboard"
- }
-
- const handlePasskey = async () => {
- setLoadingProvider("passkey");
- setError("");
- const NO_PASSKEY_MSG =
- "No passkey found for this site. Sign in with Google or GitHub first, then add a passkey from Settings → Security.";
- try {
- await signInWithPasskey();
- router.push(await handleInviteToken());
- } catch (e: any) {
- const msg = e instanceof Error ? e.message : "Passkey sign-in failed.";
- // Browser-side: NotAllowedError fires for both "user cancelled" and "no
- // credentials available". Treat both as the same guidance — harmless if
- // the user actually cancelled.
- const noCreds =
- e?.name === "NotAllowedError" ||
- /no .*(credential|passkey)|not .*registered|unknown passkey/i.test(msg);
- setError(noCreds ? NO_PASSKEY_MSG : msg);
- } finally {
- setLoadingProvider("");
- }
- };
-
- const handleLogin = async (provider: GoogleAuthProvider | GithubAuthProvider, providerName: "google" | "github") => {
- setLoadingProvider(providerName);
- setError("");
- try {
- const result = await signInWithPopup(auth, provider);
- const idToken = await result.user.getIdToken();
- try {
- await establishBackendSession(idToken, { checkRevoked: true });
- } catch (sessionErr) {
- console.error("Backend session failed:", sessionErr);
- setError("Signed in, but could not start an API session. Please try again.");
- return;
- }
- router.push(await handleInviteToken());
- } catch (error: any) {
- console.error("Error during sign-in:", error);
-
- if (error.code === 'auth/account-exists-with-different-credential') {
- try {
- const email = error.customData?.email;
- const pendingCredential = OAuthProvider.credentialFromError(error);
-
- console.log("Account linking error details:", {
- email,
- pendingCredential,
- customData: error.customData
- });
-
- if (!email || !pendingCredential) {
- throw new Error("Could not resolve account details for linking.");
- }
-
- // Get sign-in methods for this email.
- const methods = await fetchSignInMethodsForEmail(auth, email);
- console.log("Available sign-in methods:", methods);
-
- if (methods.length > 0) {
- const providerId = methods[0];
- let existingProvider: GoogleAuthProvider | GithubAuthProvider | null = null;
-
- if (providerId === GoogleAuthProvider.PROVIDER_ID) {
- existingProvider = new GoogleAuthProvider();
- } else if (providerId === GithubAuthProvider.PROVIDER_ID) {
- existingProvider = new GithubAuthProvider();
- }
-
- if (existingProvider) {
- // Clear previous error
- setError("");
-
- // Inform user
- const linkProviderName = providerId === GoogleAuthProvider.PROVIDER_ID ? "Google" : "GitHub";
- alert(`You already have an account with ${linkProviderName}. Please sign in with ${linkProviderName} to link your accounts.`);
-
- // Sign in with the existing provider
- const result = await signInWithPopup(auth, existingProvider);
-
- // Link the pending credential
- await linkWithCredential(result.user, pendingCredential);
- const idToken = await result.user.getIdToken();
- try {
- await establishBackendSession(idToken, { checkRevoked: true });
- } catch (sessionErr) {
- console.error("Backend session failed:", sessionErr);
- setError("Signed in, but could not start an API session. Please try again.");
- return;
- }
- router.push(await handleInviteToken());
- return;
- } else {
- setError(`Account exists with provider: ${providerId}, but automatic linking is not supported.`);
- }
- } else {
- setError("An account with this email already exists, but we couldn't determine the sign-in method. Please try signing in with the other provider.");
- }
- } catch (linkError: any) {
- console.error("Error linking accounts:", linkError);
- setError("Failed to link accounts. Please try signing in with the provider you originally used.");
- }
- } else {
- setError(
- error.code === "auth/popup-closed-by-user"
- ? "Sign-in was cancelled. Please try again."
- : "Failed to sign in. Please try again."
- );
- }
- } finally {
- setLoadingProvider("");
- }
- };
-
- const oauthButtonClass =
- "h-11 w-full justify-center border-border/70 bg-background/50 text-[15px] font-medium shadow-sm backdrop-blur-sm transition-all hover:border-border hover:bg-muted/60 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background dark:bg-background/30";
-
- return (
-
- {error && (
-
-
- {error}
-
- )}
-
- {/* Hidden username field — required for conditional WebAuthn autofill UI. */}
-
-
-
-
- {loadingProvider === "passkey" ? (
- <>
-
- Waiting for passkey…
- >
- ) : (
- <>
-
- Sign in with a passkey
- >
- )}
-
-
-
-
-
-
-
-
- or continue with
-
-
-
-
-
handleLogin(new GoogleAuthProvider(), "google")}
- disabled={loadingProvider !== ""}
- className={oauthButtonClass}
- variant="outline"
- >
- {loadingProvider === "google" ? (
- <>
-
- Signing in…
- >
- ) : (
- <>
-
-
-
-
-
-
- Continue with Google
- >
- )}
-
-
-
-
-
-
-
-
- or continue with
-
-
-
-
-
handleLogin(new GithubAuthProvider(), "github")}
- disabled={loadingProvider !== ""}
- className={oauthButtonClass}
- variant="outline"
- >
- {loadingProvider === "github" ? (
- <>
-
- Signing in…
- >
- ) : (
- <>
-
- Continue with GitHub
- >
- )}
-
-
-
- );
-}
diff --git a/apps/desktop-ui/src/components/login-redirect-if-authed.tsx b/apps/desktop-ui/src/components/login-redirect-if-authed.tsx
deleted file mode 100644
index 670758d5..00000000
--- a/apps/desktop-ui/src/components/login-redirect-if-authed.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import useAuth from "@/utils/useAuth";
-import { Loader2 } from "lucide-react";
-import { ensureBackendSession } from "@/lib/backend-auth";
-import { handoffDesktopToken, isDesktopHandoff } from "@/lib/desktop-handoff";
-
-export function LoginRedirectIfAuthed() {
- const { user, loading } = useAuth(false);
- const router = useRouter();
-
- // Only a signed-in (real) user redirects away from the sign-in screen.
- const isRealUser = !!user;
-
- useEffect(() => {
- if (loading || !isRealUser || !user) return;
- // Desktop sign-in handoff (?desktop=1): an already-signed-in browser must
- // mint the token and return to the app, NOT bounce to /dashboard. The
- // backend session cookie may be stale (Firebase client outlives it), so
- // refresh it before minting or the desktop-token endpoint 401s.
- if (isDesktopHandoff(window.location.search)) {
- void (async () => {
- try {
- await ensureBackendSession(user);
- } catch {
- /* fall through — handoff will surface failure to the app */
- }
- await handoffDesktopToken(window.location.search);
- })();
- return;
- }
- const next = new URLSearchParams(window.location.search).get("next");
- router.replace(next && next.startsWith("/") ? next : "/dashboard");
- }, [loading, isRealUser, user, router]);
-
- if (loading || isRealUser) {
- return (
-
-
-
- );
- }
-
- return null;
-}
diff --git a/apps/desktop-ui/src/components/mdt-dashboard.tsx b/apps/desktop-ui/src/components/mdt-dashboard.tsx
index 1e85c82e..85e3051f 100644
--- a/apps/desktop-ui/src/components/mdt-dashboard.tsx
+++ b/apps/desktop-ui/src/components/mdt-dashboard.tsx
@@ -53,7 +53,7 @@ export function MdtDashboard() {
- mydevtools.tech/app/sql-client
+ mydevtools.tech/app/data-explorer
⌘K
diff --git a/apps/desktop-ui/src/components/migration-banner.tsx b/apps/desktop-ui/src/components/migration-banner.tsx
deleted file mode 100644
index 239cd31c..00000000
--- a/apps/desktop-ui/src/components/migration-banner.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-
-import { useEffect, useState } from "react"
-import { backendFetch } from "@/lib/backend-auth"
-
-const POLL_INTERVAL_MS = 2000
-const MAX_ELAPSED_MS = 60_000
-
-export function MigrationBanner() {
- const [status, setStatus] = useState<"pending" | "done" | null>(null)
-
- useEffect(() => {
- let cancelled = false
- let timer: ReturnType
| null = null
- const startedAt = Date.now()
-
- async function tick() {
- try {
- const res = await backendFetch("/api/backend/auth/me")
- if (!res.ok) return
- const me = await res.json()
- if (cancelled) return
-
- // Already migrated — no banner needed
- if (me.migrated_at || me.migrated_fast === true) {
- setStatus("done")
- return
- }
-
- if (me.migration_status === "pending") {
- setStatus("pending")
- const elapsed = Date.now() - startedAt
- if (elapsed >= MAX_ELAPSED_MS) {
- // Give up silently after 60s; hide the banner
- setStatus("done")
- return
- }
- timer = setTimeout(tick, POLL_INTERVAL_MS)
- } else {
- setStatus("done")
- }
- } catch {
- // Network error — retry if we haven't timed out
- if (cancelled) return
- const elapsed = Date.now() - startedAt
- if (elapsed < MAX_ELAPSED_MS) {
- timer = setTimeout(tick, POLL_INTERVAL_MS)
- }
- }
- }
-
- tick()
- return () => {
- cancelled = true
- if (timer) clearTimeout(timer)
- }
- }, [])
-
- if (status !== "pending") return null
-
- return (
-
- Setting up your workspace…
-
- )
-}
diff --git a/apps/desktop-ui/src/components/mobile-desktop-hint.tsx b/apps/desktop-ui/src/components/mobile-desktop-hint.tsx
index 71d5f47c..fcb474d8 100644
--- a/apps/desktop-ui/src/components/mobile-desktop-hint.tsx
+++ b/apps/desktop-ui/src/components/mobile-desktop-hint.tsx
@@ -6,8 +6,7 @@ import { Monitor, X } from 'lucide-react'
const DESKTOP_RECOMMENDED_SLUGS = new Set([
'api-client',
- 'sql-client',
- 'database-explorer',
+ 'data-explorer',
'encryption-playground',
's3-drive',
'csv-excel-json',
diff --git a/apps/desktop-ui/src/components/mobile-nav.tsx b/apps/desktop-ui/src/components/mobile-nav.tsx
index 9829e7f9..0c30dfb0 100644
--- a/apps/desktop-ui/src/components/mobile-nav.tsx
+++ b/apps/desktop-ui/src/components/mobile-nav.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
-import { Home, LayoutGrid, LogOut, User as UserIcon, Moon, Sun, Settings, HelpCircle } from "lucide-react"
+import { Home, LayoutGrid, Lock, User as UserIcon, Moon, Sun, Settings, HelpCircle } from "lucide-react"
import { cn } from "@/lib/utils"
import { useSidebar } from "@/components/ui/sidebar"
import {
@@ -13,14 +13,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
-import useAuth from "@/utils/useAuth"
-import { signOut as firebaseSignOut } from "firebase/auth"
-import { auth } from "@/database/firebase"
+import { useAppUser } from "@/hooks/use-app-user"
import { usePasswordStore } from "@/store/password-store"
import { useEnvironmentManagerStore } from "@/store/environment-manager-store"
import { useMasterKeyStore } from "@/store/master-key-store"
import { clearMasterKey } from "@/lib/key-storage"
-import { logoutBackendSession } from "@/lib/backend-auth"
import { useThemeAnimation } from "@space-man/react-theme-animation"
import { motion } from "framer-motion"
@@ -33,15 +30,17 @@ const navItems = [
export function MobileNav() {
const pathname = usePathname()
- const router = useRouter()
const { toggleSidebar, openMobile } = useSidebar()
- const { user } = useAuth()
+ const user = useAppUser()
const { clearPasswords } = usePasswordStore()
const { clearSets } = useEnvironmentManagerStore()
- const { clearKey: clearMasterKeyStore } = useMasterKeyStore()
+ const lockVault = useMasterKeyStore((s) => s.lock)
const { theme, toggleTheme, ref } = useThemeAnimation()
const [mounted, setMounted] = useState(false)
+ const displayName = user.name?.trim() || 'You'
+ const initial = displayName[0]!.toUpperCase()
+
// Avoid hydration mismatch for theme
useEffect(() => {
setMounted(true)
@@ -55,11 +54,13 @@ export function MobileNav() {
}
const activeTab = getActiveTab()
- const handleSignOut = async () => {
+ // Manual vault lock: drop every decrypted secret we hold, in memory and in
+ // IndexedDB. The vault gate then asks for the master password again.
+ const handleLockVault = async () => {
try {
clearPasswords() // clear decrypted passwords from memory
clearSets() // clear decrypted environment sets from memory
- clearMasterKeyStore() // clear global master key in-memory state
+ lockVault() // drop the in-memory master key, keep the vault
// Clear password-manager vault key from IndexedDB
if (typeof window !== 'undefined' && window.indexedDB) {
@@ -82,12 +83,8 @@ export function MobileNav() {
// Clear global master key from IndexedDB
await clearMasterKey()
-
- await logoutBackendSession()
- await firebaseSignOut(auth);
- router.push('/login');
} catch (error) {
- console.error('Error signing out:', error);
+ console.error('Error locking vault:', error);
}
};
@@ -160,9 +157,8 @@ export function MobileNav() {
Tools
- {/* Profile / Login */}
- {user ? (
-
+ {/* Profile */}
+
-
+
- {user.displayName?.[0] || "U"}
+ {initial}
- {/* Online indicator */}
-
Profile
@@ -197,19 +191,14 @@ export function MobileNav() {
-
+
- {user.displayName?.[0] || "U"}
+ {initial}
-
-
- {user.displayName}
-
-
- {user.email}
-
-
+
+ {displayName}
+
@@ -240,26 +229,14 @@ export function MobileNav() {
void handleLockVault()}
className="text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer py-2.5"
>
-
- Log out
+
+ Lock vault
- ) : (
-
-
-
-
-
Login
-
- )}
)
}
diff --git a/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx b/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx
deleted file mode 100644
index 0668e5df..00000000
--- a/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx
+++ /dev/null
@@ -1,458 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Label } from "@/components/ui/label";
-import { IconDatabase, IconTrash, IconHistory, IconPencil, IconPlugConnected, IconCheck, IconX, IconBrandMongodb, IconBrandAws, IconBrandAzure, IconServer, IconCopy, IconLock } from "@tabler/icons-react";
-import { Switch } from "@/components/ui/switch";
-import { SavedConnection } from "./types";
-import { DB_DIALECTS, DB_TYPE_ORDER, detectDbType, normalizeConnectionString, type DbType } from "@/lib/nosql-dialects";
-import { getConnections, deleteConnection, saveConnection, updateConnectionDetails } from "./connection-service";
-import { backendFetch } from "@/lib/backend-auth";
-import useAuth from "@/utils/useAuth";
-import { useMasterKeyStore } from "@/store/master-key-store";
-import { toast } from "sonner";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { formatDistanceToNow } from "date-fns";
-import type { Locale } from "date-fns";
-import { cn } from "@/lib/utils";
-import { useTranslations, useLocale } from "next-intl";
-import { af, ar, ca, cs as csLocale, da, de, el, enUS, es, faIR, fr as frLocale, ms, nb, nl, pt, zhCN } from "date-fns/locale";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-
-interface ConnectionFormProps {
- onConnect: (connectionString: string) => Promise
;
- loading: boolean;
- error: string | null;
-}
-
-// Environment accent colors (hex kept literal so Tailwind can't purge them)
-export const CONNECTION_COLORS = [
- "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#a855f7", "#64748b",
-] as const;
-
-// Per-dialect brand icon. FerretDB has no brand glyph in @tabler — neutral DB icon.
-const DB_ICONS: Record = {
- mongodb: IconBrandMongodb,
- documentdb: IconBrandAws,
- cosmosdb: IconBrandAzure,
- ferretdb: IconDatabase,
-};
-
-export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProps) {
- const t = useTranslations("NoSqlExplorer.connection");
- const locale = useLocale();
- const DATE_LOCALE_MAP: Record = {
- fr: frLocale, es, ar, ca, zh: zhCN, cs: csLocale,
- el, de, da, af, fa: faIR, ms, nb, nl, pt,
- };
- const dateLocale = DATE_LOCALE_MAP[locale] ?? enUS;
- const { copyToClipboard } = useCopyToClipboard();
- const [connectionString, setConnectionString] = useState("");
- const [name, setName] = useState("My Connection");
- const [dbType, setDbType] = useState("mongodb");
- const [color, setColor] = useState(null);
- const [readOnly, setReadOnly] = useState(false);
- const [savedConnections, setSavedConnections] = useState([]);
- const { user } = useAuth();
- const { encryptionKey } = useMasterKeyStore();
- const [isLoadingConnections, setIsLoadingConnections] = useState(false);
- const [isTesting, setIsTesting] = useState(false);
- const [editingId, setEditingId] = useState(null);
- const [deleteConnDialog, setDeleteConnDialog] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
-
- useEffect(() => {
- if (user) {
- loadConnections();
- }
- }, [user]);
-
- const loadConnections = async () => {
- if (!user || !encryptionKey) return;
- setIsLoadingConnections(true);
- try {
- const connections = await getConnections(user.uid, encryptionKey);
- setSavedConnections(connections);
- } catch (error) {
- console.error("Failed to load connections", error);
- } finally {
- setIsLoadingConnections(false);
- }
- };
-
- const handleTestConnection = async () => {
- if (!connectionString) return;
- setIsTesting(true);
- try {
- const res = await backendFetch("/api/nosql/connect", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ connectionString: normalizeConnectionString(dbType, connectionString) }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error);
- toast.success(t("toastTestOk"));
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- toast.error(t("toastTestFail", { message }));
- } finally {
- setIsTesting(false);
- }
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!connectionString || !user || !encryptionKey) return;
-
- // Persist and connect with the dialect-corrected string (e.g. DocumentDB/Cosmos retryWrites=false).
- const finalString = normalizeConnectionString(dbType, connectionString);
-
- try {
- if (editingId) {
- await updateConnectionDetails(user.uid, editingId, { name, connectionString: finalString, color, readOnly, dbType }, encryptionKey);
- toast.success(t("toastUpdated"));
- } else {
- await saveConnection(user.uid, finalString, name, encryptionKey, { color, readOnly, dbType });
- }
- await loadConnections();
- } catch (e) {
- console.error("Failed to save connection", e);
- toast.error(t("toastConnectFail", { name }));
- return;
- }
-
- setEditingId(null);
- await onConnect(finalString);
- };
-
- const handleSelectConnection = (conn: SavedConnection) => {
- setConnectionString(conn.connectionString);
- setName(conn.name);
- setDbType(conn.dbType ?? detectDbType(conn.connectionString));
- setColor(conn.color ?? null);
- setReadOnly(conn.readOnly ?? false);
- setEditingId(null);
- };
-
- const handleEditConnection = (e: React.MouseEvent, conn: SavedConnection) => {
- e.stopPropagation();
- setEditingId(conn.id);
- setConnectionString(conn.connectionString);
- setName(conn.name);
- setDbType(conn.dbType ?? detectDbType(conn.connectionString));
- setColor(conn.color ?? null);
- setReadOnly(conn.readOnly ?? false);
- };
-
- const handleCancelEdit = () => {
- setEditingId(null);
- setConnectionString("");
- setName("My Connection");
- setDbType("mongodb");
- setColor(null);
- setReadOnly(false);
- };
-
- const handleDeleteConnection = async (e: React.MouseEvent, id: string) => {
- e.stopPropagation();
- setDeleteConnDialog({ open: true, id });
- };
-
- const confirmDeleteConnection = async () => {
- const id = deleteConnDialog.id;
- if (!user || !id) return;
-
- try {
- await deleteConnection(user.uid, id);
- toast.success(t("toastDeletedConn"));
- if (editingId === id) handleCancelEdit();
- await loadConnections();
- } catch (error) {
- toast.error(t("toastDeleteFail"));
- } finally {
- setDeleteConnDialog({ open: false, id: null });
- }
- };
-
- return (
-
-
-
-
-
- {editingId ? t("titleEdit") : t("titleConnect")}
-
-
- {editingId ? t("subtitleEdit") : t("subtitleConnect")}
-
-
-
-
-
-
-
- {t("labelName")}
- setName(e.target.value)}
- disabled={loading}
- className="bg-background/50"
- />
-
-
-
{t("labelDbType")}
-
- {DB_TYPE_ORDER.map((type) => {
- const Icon = DB_ICONS[type];
- const active = dbType === type;
- return (
- setDbType(type)}
- disabled={loading}
- className={cn(
- "flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-all",
- active
- ? "border-primary bg-primary/5 ring-1 ring-primary/20"
- : "border-border bg-background/50 hover:border-primary/30"
- )}
- >
-
- {DB_DIALECTS[type].label}
-
- );
- })}
-
-
-
-
-
{t("labelConnectionString")}
-
- setConnectionString(e.target.value)}
- disabled={loading}
- className="bg-background/50 font-mono text-sm pl-9"
- />
-
-
-
- {t("hintFormat")}
-
-
-
-
-
{t("labelColor")}
-
- setColor(null)}
- className={cn(
- "w-6 h-6 rounded-full border-2 flex items-center justify-center text-muted-foreground transition-transform",
- color === null ? "border-primary scale-110" : "border-border hover:scale-105"
- )}
- title={t("colorNone")}
- >
-
-
- {CONNECTION_COLORS.map((c) => (
- setColor(c)}
- className={cn(
- "w-6 h-6 rounded-full border-2 transition-transform",
- color === c ? "border-primary scale-110" : "border-transparent hover:scale-105"
- )}
- style={{ backgroundColor: c }}
- aria-label={c}
- />
- ))}
-
-
{t("hintColor")}
-
-
-
-
-
-
- {t("labelReadOnly")}
-
-
{t("hintReadOnly")}
-
-
-
-
- {error && (
-
-
- {error}
-
- )}
-
-
-
- {isTesting ? : }
- {t("testConnection")}
-
-
- {loading ? t("connecting") : (editingId ? t("updateConnect") : t("connect"))}
-
-
-
- {editingId && (
-
- {t("cancelEdit")}
-
- )}
-
-
-
-
-
-
-
-
-
- {t("savedConnections")}
-
-
- {savedConnections.length}
-
-
-
-
-
-
- {savedConnections.length === 0 ? (
-
- ) : (
-
- {savedConnections.map((conn) => {
- const RowIcon = DB_ICONS[conn.dbType ?? detectDbType(conn.connectionString)];
- return (
-
handleSelectConnection(conn)}
- >
-
-
-
-
- {conn.name}
- {conn.readOnly &&
}
-
-
-
- {conn.connectionString.replace(/:([^@]+)@/, ":****@")}
-
-
{
- e.stopPropagation();
- void copyToClipboard(conn.connectionString, t("toastStringCopied"));
- }}
- title={t("copyStringTitle")}
- >
-
-
-
-
-
- {t("lastUsed", {
- time: formatDistanceToNow(
- typeof conn.lastUsedAt === "number"
- ? new Date(conn.lastUsedAt)
- : conn.lastUsedAt?.toDate
- ? conn.lastUsedAt.toDate()
- : new Date(),
- { addSuffix: true, locale: dateLocale }
- ),
- })}
-
-
-
-
- handleEditConnection(e, conn)}
- title={t("editTitle")}
- >
-
-
- handleDeleteConnection(e, conn.id)}
- title={t("deleteTitle")}
- >
-
-
-
-
- );
- })}
-
- )}
-
-
-
-
-
-
setDeleteConnDialog(prev => ({ ...prev, open }))}>
-
-
- {t("confirmDelete")}
- {t("toastDeletedConn")}
-
-
- {t("cancel")}
-
- {t("menuDeleteConnection")}
-
-
-
-
-
- );
-}
diff --git a/apps/desktop-ui/src/components/nosql-explorer/explorer-sidebar.tsx b/apps/desktop-ui/src/components/nosql-explorer/explorer-sidebar.tsx
deleted file mode 100644
index 7e43e879..00000000
--- a/apps/desktop-ui/src/components/nosql-explorer/explorer-sidebar.tsx
+++ /dev/null
@@ -1,910 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Database, Collection, SavedConnection } from "./types";
-import { IconDatabase, IconFolder, IconChevronRight, IconChevronDown, IconRefresh, IconSearch, IconPlus, IconServer, IconPencil, IconCheck, IconX, IconDotsVertical, IconTrash, IconEdit, IconCopy, IconAlertCircle, IconLoader2, IconLock, IconActivity, IconFiles, IconArrowsExchange } from "@tabler/icons-react";
-import { cn } from "@/lib/utils";
-import React, { useState, useEffect, useRef } from "react";
-import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
-import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
-import useAuth from "@/utils/useAuth";
-import { useMasterKeyStore } from "@/store/master-key-store";
-import { getConnections, updateConnectionName, deleteConnection } from "./connection-service";
-import { backendFetch } from "@/lib/backend-auth";
-import { toast } from "sonner";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { useTranslations } from "next-intl";
-import { SidebarDialogs } from "./sidebar-dialogs";
-import { ServerMonitor } from "./server-monitor";
-import { GridFsBrowser } from "./gridfs-browser";
-import { SyncDialog } from "./sync-dialog";
-
-interface ExplorerSidebarProps {
- onSelectCollection: (connection: SavedConnection, dbName: string, collectionName: string) => void;
- onRefresh: () => void;
- onAddConnection: () => void;
- onConnectionsLoaded?: (connections: SavedConnection[]) => void;
- width?: number;
-}
-
-interface ConnectionNode {
- connection: SavedConnection;
- isExpanded: boolean;
- databases: Database[];
- isLoading: boolean;
- error: string | null;
- expandedDbs: Set; // Set of expanded db names
- dbCollections: Record; // Map of dbName -> collections
-}
-
-export function ExplorerSidebar({
- onSelectCollection,
- onRefresh,
- onAddConnection,
- onConnectionsLoaded,
- width = 256,
-}: ExplorerSidebarProps) {
- const t = useTranslations("NoSqlExplorer.sidebar");
- const { user } = useAuth();
- const { encryptionKey } = useMasterKeyStore();
- const { copyToClipboard } = useCopyToClipboard();
- const [connections, setConnections] = useState([]);
- const [searchQuery, setSearchQuery] = useState("");
- const [loading, setLoading] = useState(true);
- const [editingConnectionId, setEditingConnectionId] = useState(null);
- const [monitorConn, setMonitorConn] = useState(null);
- const [gridfsTarget, setGridfsTarget] = useState<{ connectionString: string; dbName: string; name: string; readOnly?: boolean } | null>(null);
- const [syncSource, setSyncSource] = useState<{ connectionString: string; dbName: string; collectionName: string; name: string } | null>(null);
- const [editName, setEditName] = useState("");
-
- // Dialog states
- const [renameCollectionDialog, setRenameCollectionDialog] = useState<{ open: boolean, connection: SavedConnection | null, dbName: string, collectionName: string, newName: string }>({ open: false, connection: null, dbName: "", collectionName: "", newName: "" });
- const [renameDatabaseDialog, setRenameDatabaseDialog] = useState<{ open: boolean, connection: SavedConnection | null, dbName: string, newName: string }>({ open: false, connection: null, dbName: "", newName: "" });
- const [deleteConnDialog, setDeleteConnDialog] = useState<{ open: boolean; index: number | null }>({
- open: false, index: null,
- });
- const [dropDbDialog, setDropDbDialog] = useState<{ open: boolean; connIndex: number | null; dbName: string }>({
- open: false, connIndex: null, dbName: "",
- });
- const [dropCollDialog, setDropCollDialog] = useState<{ open: boolean; connIndex: number | null; dbName: string; collectionName: string }>({
- open: false, connIndex: null, dbName: "", collectionName: "",
- });
-
- // Multiselect state
- const [selectedCollections, setSelectedCollections] = React.useState>(new Set());
- const [bulkDeleteDialog, setBulkDeleteDialog] = useState<{ open: boolean }>({ open: false });
- const [isBulkDeleting, setIsBulkDeleting] = useState(false);
-
- const toggleCollectionSelection = (connectionId: string, dbName: string, collectionName: string) => {
- const key = `${connectionId}|${dbName}|${collectionName}`;
- setSelectedCollections(prev => {
- const next = new Set(prev);
- if (next.has(key)) {
- next.delete(key);
- } else {
- next.add(key);
- }
- return next;
- });
- };
-
- const clearSelection = () => {
- setSelectedCollections(new Set());
- };
-
- useEffect(() => {
- if (user && encryptionKey) {
- loadConnections();
- }
- }, [user, encryptionKey]);
-
- const loadConnections = async () => {
- if (!user || !encryptionKey) return;
- setLoading(true);
- try {
- const saved = await getConnections(user.uid, encryptionKey);
-
- // Restore expanded state from localStorage
- const expandedConnIds = JSON.parse(localStorage.getItem("nosql_expanded_connections") || "[]");
- const expandedDbsMap = JSON.parse(localStorage.getItem("nosql_expanded_dbs") || "{}");
-
- const newConnections = saved.map(conn => {
- const isExpanded = expandedConnIds.includes(conn.id);
- const expandedDbs = new Set(expandedDbsMap[conn.id!] || []);
-
- return {
- connection: conn,
- isExpanded,
- databases: [],
- isLoading: false,
- error: null,
- expandedDbs,
- dbCollections: {}
- };
- });
-
- setConnections(newConnections);
- onConnectionsLoaded?.(saved);
-
- // Trigger refresh for expanded connections to load databases
- newConnections.forEach((node, index) => {
- if (node.isExpanded) {
- refreshDatabases(index, node.connection);
- }
- });
-
- } catch (error) {
- toast.error(t("toastLoadFail"));
- } finally {
- setLoading(false);
- }
- };
-
- const toggleConnection = async (index: number) => {
- if (editingConnectionId) return; // Prevent toggle while editing
-
- const node = connections[index];
- const newExpandedState = !node.isExpanded;
-
- // Update state
- setConnections(prev => prev.map((c, i) => i === index ? { ...c, isExpanded: newExpandedState, isLoading: newExpandedState, error: null } : c));
-
- // Update localStorage
- const expandedConnIds = JSON.parse(localStorage.getItem("nosql_expanded_connections") || "[]");
- if (newExpandedState) {
- if (node.connection.id && !expandedConnIds.includes(node.connection.id)) {
- expandedConnIds.push(node.connection.id);
- }
- } else {
- const idx = expandedConnIds.indexOf(node.connection.id);
- if (idx !== -1) expandedConnIds.splice(idx, 1);
- }
- try { localStorage.setItem("nosql_expanded_connections", JSON.stringify(expandedConnIds)); } catch (e) { console.warn("nosql sidebar: localStorage write failed", e); }
-
- if (newExpandedState) {
- await refreshDatabases(index);
- }
- };
-
- const refreshDatabases = async (index: number, connectionOverride?: SavedConnection) => {
- const connection = connectionOverride || connections[index]?.connection;
- if (!connection) return;
-
- setConnections(prev => prev.map((c, i) => i === index ? { ...c, isLoading: true, error: null } : c));
- try {
- const res = await backendFetch("/api/nosql/connect", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ connectionString: connection.connectionString }),
- });
- const data = await res.json();
-
- if (!res.ok) throw new Error(data.error);
-
- setConnections(prev => prev.map((c, i) => i === index ? {
- ...c,
- isLoading: false,
- databases: data.databases,
- } : c));
- } catch (error: any) {
- setConnections(prev => prev.map((c, i) => i === index ? {
- ...c,
- isLoading: false,
- error: error.message
- } : c));
- toast.error(t("toastConnectFail", { name: connection.name }));
- }
- };
-
- const toggleDatabase = async (connIndex: number, dbName: string) => {
- const node = connections[connIndex];
- const isDbExpanded = node.expandedDbs.has(dbName);
- const newExpanded = new Set(node.expandedDbs);
-
- if (isDbExpanded) {
- newExpanded.delete(dbName);
- } else {
- newExpanded.add(dbName);
- }
-
- // Update state
- setConnections(prev => prev.map((c, i) => i === connIndex ? { ...c, expandedDbs: newExpanded } : c));
-
- // Update localStorage
- if (node.connection.id) {
- try {
- const expandedDbsMap = JSON.parse(localStorage.getItem("nosql_expanded_dbs") || "{}");
- expandedDbsMap[node.connection.id] = Array.from(newExpanded);
- localStorage.setItem("nosql_expanded_dbs", JSON.stringify(expandedDbsMap));
- } catch (e) {
- console.warn("nosql sidebar: localStorage write failed", e);
- }
- }
-
- if (!isDbExpanded) {
- // Fetch collections if not already fetched
- if (!node.dbCollections[dbName]) {
- await refreshCollections(connIndex, dbName);
- }
- }
- };
-
- const refreshCollections = async (connIndex: number, dbName: string) => {
- const node = connections[connIndex];
- try {
- const res = await backendFetch("/api/nosql/collections", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- connectionString: node.connection.connectionString,
- dbName,
- }),
- });
- const data = await res.json();
-
- if (!res.ok) throw new Error(data.error);
-
- setConnections(prev => prev.map((c, i) => i === connIndex ? {
- ...c,
- dbCollections: { ...c.dbCollections, [dbName]: data.collections }
- } : c));
- } catch (error) {
- toast.error(t("toastCollectionsFail", { name: dbName }));
- }
- };
-
- const startEditing = (e: React.MouseEvent, conn: SavedConnection) => {
- e.stopPropagation();
- setEditingConnectionId(conn.id || null);
- setEditName(conn.name);
- };
-
- const cancelEditing = (e?: React.MouseEvent) => {
- e?.stopPropagation();
- setEditingConnectionId(null);
- setEditName("");
- };
-
- const saveEditing = async (e: React.MouseEvent, conn: SavedConnection) => {
- e.stopPropagation();
- if (!user || !conn.id) return;
-
- try {
- await updateConnectionName(user.uid, conn.id, editName);
- setConnections(prev => prev.map(c => c.connection.id === conn.id ? { ...c, connection: { ...c.connection, name: editName } } : c));
- toast.success(t("toastRenamed"));
- setEditingConnectionId(null);
- } catch (error) {
- toast.error(t("toastRenameFail"));
- }
- };
-
- const handleDeleteConnection = (index: number) => {
- setDeleteConnDialog({ open: true, index });
- };
-
- const confirmDeleteConnection = async () => {
- const index = deleteConnDialog.index;
- if (index === null) return;
- const node = connections[index];
- if (!user || !node.connection.id) return;
- try {
- await deleteConnection(user.uid, node.connection.id);
- setConnections(prev => prev.filter((_, i) => i !== index));
- toast.success(t("toastDeleted"));
- } catch (error) {
- toast.error(t("toastDeleteConnFail"));
- } finally {
- setDeleteConnDialog({ open: false, index: null });
- }
- };
-
- const handleDropDatabase = (connIndex: number, dbName: string) => {
- setDropDbDialog({ open: true, connIndex, dbName });
- };
-
- const confirmDropDatabase = async () => {
- const { connIndex, dbName } = dropDbDialog;
- if (connIndex === null) return;
- const node = connections[connIndex];
- try {
- const res = await backendFetch("/api/nosql/database/drop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ connectionString: node.connection.connectionString, dbName }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error);
- toast.success(t("toastDbDropped", { name: dbName }));
- refreshDatabases(connIndex);
- } catch (error: any) {
- toast.error(error.message);
- } finally {
- setDropDbDialog({ open: false, connIndex: null, dbName: "" });
- }
- };
-
- const handleDropCollection = (connIndex: number, dbName: string, collectionName: string) => {
- setDropCollDialog({ open: true, connIndex, dbName, collectionName });
- };
-
- const confirmDropCollection = async () => {
- const { connIndex, dbName, collectionName } = dropCollDialog;
- if (connIndex === null) return;
- const node = connections[connIndex];
- try {
- const res = await backendFetch("/api/nosql/collection/drop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ connectionString: node.connection.connectionString, dbName, collectionName }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error);
- toast.success(t("toastCollectionDropped", { name: collectionName }));
- refreshCollections(connIndex, dbName);
- } catch (error: any) {
- toast.error(error.message);
- } finally {
- setDropCollDialog({ open: false, connIndex: null, dbName: "", collectionName: "" });
- }
- };
-
- const confirmBulkDelete = async () => {
- setIsBulkDeleting(true);
- const toDelete = Array.from(selectedCollections);
- const errors: string[] = [];
-
- for (const key of toDelete) {
- const [connectionId, dbName, collectionName] = key.split("|");
- const connIndex = connections.findIndex(c => c.connection.id === connectionId);
- if (connIndex === -1) continue;
- const node = connections[connIndex];
- try {
- const res = await backendFetch("/api/nosql/collection/drop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ connectionString: node.connection.connectionString, dbName, collectionName }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error);
- // Refresh collections for this db
- refreshCollections(connIndex, dbName);
- } catch (error: any) {
- errors.push(`${collectionName}: ${error.message}`);
- }
- }
-
- setIsBulkDeleting(false);
- setBulkDeleteDialog({ open: false });
- clearSelection();
-
- if (errors.length > 0) {
- toast.error(t("bulkDeleteFailed", { count: errors.length }));
- } else {
- toast.success(t("bulkDeleted", { count: toDelete.length }));
- }
- };
-
- const handleRenameCollection = async () => {
- const { connection, dbName, collectionName, newName } = renameCollectionDialog;
- if (!connection || !newName) return;
-
- try {
- const res = await backendFetch("/api/nosql/collection/rename", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- connectionString: connection.connectionString,
- dbName,
- collectionName,
- newCollectionName: newName
- }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error);
-
- toast.success(t("toastCollectionRenamed", { name: newName }));
- setRenameCollectionDialog({ ...renameCollectionDialog, open: false });
-
- // Find connection index to refresh
- const connIndex = connections.findIndex(c => c.connection.id === connection.id);
- if (connIndex !== -1) {
- refreshCollections(connIndex, dbName);
- }
- } catch (error: any) {
- toast.error(error.message);
- }
- };
-
- const handleRenameDatabase = async () => {
- const { connection, dbName, newName } = renameDatabaseDialog;
- if (!connection || !newName) return;
-
- try {
- const res = await backendFetch("/api/nosql/database/rename", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- connectionString: connection.connectionString,
- oldDbName: dbName,
- newDbName: newName
- }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error);
-
- toast.success(t("toastDatabaseRenamed", { name: newName }));
- setRenameDatabaseDialog({ ...renameDatabaseDialog, open: false });
-
- // Find connection index to refresh
- const connIndex = connections.findIndex(c => c.connection.id === connection.id);
- if (connIndex !== -1) {
- refreshDatabases(connIndex);
- }
- } catch (error: any) {
- toast.error(error.message);
- }
- };
-
- const matchesSearch = (text: string) => text.toLowerCase().includes(searchQuery.toLowerCase());
-
- const filteredConnections = connections.filter(node => {
- // Drop malformed nodes — any entry missing `connection.id` would crash
- // the renderer (which reads `node.connection.id` as the React key).
- if (!node?.connection || typeof node.connection.id !== "string") return false;
- if (!searchQuery) return true;
- if (matchesSearch(node.connection.name)) return true;
-
- const hasMatchingDb = node.databases.some(db => {
- if (matchesSearch(db.name)) return true;
- const collections = node.dbCollections[db.name] || [];
- return collections.some(col => matchesSearch(col.name));
- });
-
- return hasMatchingDb;
- });
-
- const nosqlScrollRef = useRef(null);
- const { displayCount: nosqlDisplayCount, sentinelRef: nosqlSentinelRef, hasMore: nosqlHasMore } = useInfiniteScroll({
- totalCount: filteredConnections.length,
- resetKey: searchQuery,
- pageSize: 20,
- scrollContainerRef: nosqlScrollRef,
- });
- const visibleConnections = filteredConnections.slice(0, nosqlDisplayCount);
-
- return (
-
-
-
-
{t("explorer")}
-
-
-
-
-
-
- {t("add")}
-
-
- {t("tooltipAdd")}
-
-
-
-
-
-
-
- {t("tooltipRefresh")}
-
-
-
-
-
-
- setSearchQuery(e.target.value)}
- className="h-9 pl-7 text-xs"
- />
-
-
-
-
- {loading ? (
-
{t("loadingConnections")}
- ) : filteredConnections.length === 0 ? (
-
{t("noConnectionsFound")}
- ) : (
- visibleConnections.map((node) => {
- // `visibleConnections` is filtered+sliced, so its map index does NOT
- // match the real `connections` array that every handler indexes into.
- // Derive the real index from the stable connection id.
- const index = connections.findIndex(c => c.connection.id === node.connection.id);
- return (
-
-
- {editingConnectionId === node.connection.id ? (
-
- setEditName(e.target.value)}
- className="h-8 text-xs"
- autoFocus
- onClick={(e) => e.stopPropagation()}
- onKeyDown={(e) => {
- if (e.key === "Enter") saveEditing(e as any, node.connection);
- if (e.key === "Escape") cancelEditing();
- }}
- />
- saveEditing(e, node.connection)}>
-
-
-
-
-
-
- ) : (
-
-
-
- toggleConnection(index)}
- >
- {node.isExpanded ? (
-
- ) : (
-
- )}
-
-
- {/* Status dot */}
- 0 ? "bg-green-500" :
- "bg-muted-foreground/40"
- )} />
-
-
- {node.connection.name}
- {node.connection.readOnly && }
-
-
-
-
-
- e.stopPropagation()}
- >
-
-
-
- e.stopPropagation()}>
- startEditing(e as any, node.connection)}>
- {t("menuRename")}
-
- {
- e.stopPropagation();
- void copyToClipboard(node.connection.connectionString, t("toastSidebarStringCopied"));
- }}>
- {t("menuCopyConnectionString")}
-
- {
- e.stopPropagation();
- setMonitorConn(node.connection);
- }}>
- {t("menuMonitorServer")}
-
-
- {
- e.stopPropagation();
- handleDeleteConnection(index);
- }}>
- {t("menuDeleteConnection")}
-
-
-
-
-
-
-
- {node.connection.connectionString.replace(/:([^@]+)@/, ":****@")}
- {node.error && {node.error}
}
-
-
-
- )}
-
-
- {node.isExpanded && (
-
- {node.isLoading ? (
-
-
- {t("connecting")}
-
- ) : node.error ? (
-
-
-
-
-
- {t("connectionFailed")}
-
-
-
- {node.error}
-
-
-
- ) : node.databases.length === 0 ? (
-
{t("noDatabases")}
- ) : (
- node.databases
- .filter(db => {
- if (!searchQuery) return true;
- if (matchesSearch(node.connection.name)) return true;
- if (matchesSearch(db.name)) return true;
- const collections = node.dbCollections[db.name] || [];
- return collections.some(col => matchesSearch(col.name));
- })
- .map((db) => (
-
-
- toggleDatabase(index, db.name)}
- >
- {node.expandedDbs.has(db.name) ? (
-
- ) : (
-
- )}
-
- {db.name}
-
-
-
-
-
-
-
-
-
- refreshCollections(index, db.name)}>
- {t("refresh")}
-
- {
- void copyToClipboard(db.name, t("toastDbNameCopied"));
- }}>
- {t("copyDbName")}
-
- setGridfsTarget({
- connectionString: node.connection.connectionString,
- dbName: db.name,
- name: `${node.connection.name} / ${db.name}`,
- readOnly: node.connection.readOnly,
- })}>
- {t("browseGridfs")}
-
- {!node.connection.readOnly && (
- <>
- setRenameDatabaseDialog({
- open: true,
- connection: node.connection,
- dbName: db.name,
- newName: db.name
- })}>
- {t("menuRename")}
-
-
- handleDropDatabase(index, db.name)}>
- {t("dropDatabase")}
-
- >
- )}
-
-
-
-
- {node.expandedDbs.has(db.name) && (
-
- {!node.dbCollections[db.name] ? (
-
{t("loading")}
- ) : node.dbCollections[db.name].length === 0 ? (
-
{t("noCollections")}
- ) : (
- node.dbCollections[db.name]
- .filter(col => {
- if (!searchQuery) return true;
- if (matchesSearch(node.connection.name)) return true;
- if (matchesSearch(db.name)) return true;
- return matchesSearch(col.name);
- })
- .sort((a, b) => a.name.localeCompare(b.name))
- .map((col) => {
- const selKey = `${node.connection.id}|${db.name}|${col.name}`;
- const isSelected = selectedCollections.has(selKey);
- return (
-
- {!node.connection.readOnly && (
-
- toggleCollectionSelection(node.connection.id || "", db.name, col.name)}
- onClick={(e) => e.stopPropagation()}
- className="ml-1 mr-1 h-3 w-3 shrink-0 cursor-pointer accent-primary"
- aria-label={`Select ${col.name}`}
- />
-
- )}
-
onSelectCollection(node.connection, db.name, col.name)}
- >
-
- {col.name}
- {col.documentCount !== undefined && col.documentCount !== null && (
-
- {col.documentCount >= 1000000
- ? `${(col.documentCount / 1000000).toFixed(1)}M`
- : col.documentCount >= 1000
- ? `${(col.documentCount / 1000).toFixed(1)}K`
- : col.documentCount}
-
- )}
-
- {!node.connection.readOnly && (
-
-
-
-
-
-
-
- setRenameCollectionDialog({
- open: true,
- connection: node.connection,
- dbName: db.name,
- collectionName: col.name,
- newName: col.name
- })}>
- {t("menuRename")}
-
- setSyncSource({
- connectionString: node.connection.connectionString,
- dbName: db.name,
- collectionName: col.name,
- name: `${node.connection.name} / ${db.name} / ${col.name}`,
- })}>
- {t("menuSyncCollection")}
-
-
- handleDropCollection(index, db.name, col.name)}>
- {t("dropCollection")}
-
-
-
- )}
-
- );
- })
- )}
-
- )}
-
- ))
- )}
-
- )}
-
- );
- })
- )}
- {nosqlHasMore && (
-
-
-
- )}
-
-
-
- {selectedCollections.size > 0 && (
-
- setBulkDeleteDialog({ open: true })}
- >
-
- {t("bulkDeleteButton", { count: selectedCollections.size })}
-
-
- )}
-
-
- {monitorConn && (
-
setMonitorConn(null)}
- />
- )}
- {gridfsTarget && (
- setGridfsTarget(null)}
- />
- )}
- {syncSource && (
- c.connection)}
- open={!!syncSource}
- onClose={() => setSyncSource(null)}
- />
- )}
-
- );
-}
diff --git a/apps/desktop-ui/src/components/nosql-explorer/tab-bar.tsx b/apps/desktop-ui/src/components/nosql-explorer/tab-bar.tsx
deleted file mode 100644
index 14eae1ff..00000000
--- a/apps/desktop-ui/src/components/nosql-explorer/tab-bar.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-"use client";
-
-import { useTranslations } from "next-intl";
-import { Button } from "@/components/ui/button";
-import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
-import { cn } from "@/lib/utils";
-import { IconX, IconFolder, IconLock } from "@tabler/icons-react";
-import { ExplorerTab } from "./types";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-
-interface TabBarProps {
- tabs: ExplorerTab[];
- activeTabId: string | null;
- onTabChange: (tabId: string) => void;
- onTabClose: (tabId: string) => void;
- onCloseAll?: () => void;
-}
-
-export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onCloseAll }: TabBarProps) {
- const t = useTranslations("NoSqlExplorer.tabs");
- const hasActiveQuery = (tab: ExplorerTab) => {
- try {
- const q = tab.query?.trim();
- if (!q || q === "{}") return false;
- const parsed = JSON.parse(q);
- return Object.keys(parsed).length > 0;
- } catch {
- return false;
- }
- };
-
- if (tabs.length === 0) {
- return
;
- }
-
- // Defensive: filter out malformed entries — a single undefined/missing-id tab
- // would crash the whole component (`tab.id` read on undefined inside .map).
- const safeTabs = tabs.filter((t): t is ExplorerTab => !!t && typeof t.id === "string");
-
- return (
-
-
-
- {safeTabs.map((tab) => {
- const isActive = activeTabId === tab.id;
- const isFiltered = hasActiveQuery(tab);
- return (
-
-
-
- onTabChange(tab.id)}
- style={tab.connectionColor ? { boxShadow: `inset 0 2px 0 0 ${tab.connectionColor}` } : undefined}
- >
-
- {tab.readOnly && }
-
- {tab.collectionName}
-
- {isFiltered && (
-
- )}
- {
- e.stopPropagation();
- onTabClose(tab.id);
- }}
- >
-
-
-
-
-
- {tab.collectionName}
- {tab.connectionName} › {tab.dbName}
- {isFiltered && {t("filterActiveTooltip")}
}
-
-
-
- );
- })}
-
-
-
- {tabs.length > 0 && onCloseAll && (
-
-
- {t("closeAll")}
-
-
- )}
-
- );
-}
diff --git a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx
index fad66922..1435538d 100644
--- a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx
+++ b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx
@@ -268,9 +268,9 @@ NoteItem.displayName = "NoteItem";
export default function NotesSidebar() {
const t = useTranslations("Notes.sidebar");
- const { notes, noteById, isLoading } = useNotesData();
+ const { notes, noteById, isLoading, searchIndexReady } = useNotesData();
const { activeNoteId, setSidebarOpen } = useNotesUI();
- const { createNote, deleteNote, moveNote } = useNotesActions();
+ const { createNote, deleteNote, moveNote, warmSearchIndex } = useNotesActions();
const [noteToDelete, setNoteToDelete] = useState(null);
const [noteToMove, setNoteToMove] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
@@ -280,6 +280,13 @@ export default function NotesSidebar() {
const [sortDir, setSortDir] = useState("desc");
const searchInputRef = useRef(null);
+ // Bodies aren't in memory after the metadata/body split — decrypt them once,
+ // on the first search of the session. Until then, matching is title/tag only.
+ useEffect(() => {
+ if (!debouncedQuery.trim() || searchIndexReady) return;
+ void warmSearchIndex().catch(() => { /* locked vault: titles still match */ });
+ }, [debouncedQuery, searchIndexReady, warmSearchIndex]);
+
const childrenMap = useMemo(() => buildChildrenMap(notes), [notes]);
const sortedNotes = useMemo(() => {
@@ -305,7 +312,9 @@ export default function NotesSidebar() {
const out: { note: Note; snippet?: string; parent?: Note }[] = [];
for (const n of sortedNotes) {
const titleHit = (n.title || "").toLowerCase().includes(q);
- const text = getCachedPlainText(n);
+ // Body text comes from the plain-text cache, which only holds every
+ // note once warmSearchIndex has run.
+ const text = searchIndexReady ? getCachedPlainText(n) : "";
const bodyHit = text.toLowerCase().includes(q);
if (!titleHit && !bodyHit) continue;
out.push({
@@ -315,7 +324,8 @@ export default function NotesSidebar() {
});
}
return out;
- }, [sortedNotes, debouncedQuery, noteById]);
+ // searchIndexReady: recompute once the warm lands so body matches appear.
+ }, [sortedNotes, debouncedQuery, noteById, searchIndexReady]);
const notesScrollRef = useRef(null);
const listLength = searchResults ? searchResults.length : rootNotes.length;
@@ -485,7 +495,9 @@ export default function NotesSidebar() {
{t("loading")}
) : searchResults !== null ? (
searchResults.length === 0 ? (
- {t("noNotesFound")}
+
+ {searchIndexReady ? t("noNotesFound") : t("loading")}
+
) : (
searchResults.slice(0, displayCount).map(({ note, snippet, parent }) => (
=> {
- if (!user) throw new Error(tCtx("authRequiredError"));
- // Desktop: note images live in Firebase Storage (cloud). Desktop data
- // stays on the machine, so image upload is web-only.
- if (isDesktop()) {
- throw new Error(tCtx("cloudSyncImageError"));
+ if (file.size > MAX_INLINE_IMAGE_BYTES) {
+ throw new Error(tCtx("imageTooLargeError"));
}
- const timestamp = Date.now();
- const safeName = sanitizeFileName(file.name);
- const storageRef = ref(storage, `notes/${user.uid}/${activeNoteId}/${timestamp}_${safeName}`);
- await uploadBytes(storageRef, file);
- return getDownloadURL(storageRef);
+ const buffer = await file.arrayBuffer();
+ let binary = "";
+ const bytes = new Uint8Array(buffer);
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
+ const type = file.type || "image/png";
+ return `data:${type};base64,${btoa(binary)}`;
};
const initialMarkdown = useMemo(() => {
@@ -158,8 +162,8 @@ export default function NotionEditor() {
return pendingTemplateContent.markdown;
}
// Normalizes both new (string) and legacy (tree) content to markdown.
- return noteContentToMarkdown(activeNote?.content);
- }, [activeNoteId, editorKey, activeNote?.content, pendingTemplateContent]);
+ return noteContentToMarkdown(activeContent);
+ }, [activeNoteId, editorKey, activeContent, pendingTemplateContent]);
const activePath = useMemo(() => {
if (!activeNote) return [];
@@ -199,14 +203,14 @@ export default function NotionEditor() {
// Defer word-count compute: the heavy extract runs at most every 500ms
// instead of every keystroke.
const { wordCount, readTime } = useMemo(() => {
- const src = wordCountSource ?? activeNote?.content;
+ const src = wordCountSource ?? activeContent;
const text = extractPlainText(src);
const wc = countWords(text);
return { wordCount: wc, readTime: readingTimeMinutes(wc) };
- }, [wordCountSource, activeNote?.content]);
+ }, [wordCountSource, activeContent]);
const handleExportMarkdown = useCallback(() => {
- const src = latestMarkdownRef.current ?? activeNote?.content;
+ const src = latestMarkdownRef.current ?? activeContent;
const md = contentToMarkdown(title || "Untitled", src);
const blob = new Blob([md], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
@@ -215,10 +219,10 @@ export default function NotionEditor() {
a.download = `${(title || "note").replace(/\s+/g, "-")}.md`;
a.click();
URL.revokeObjectURL(url);
- }, [activeNote?.content, title]);
+ }, [activeContent, title]);
const handleExportHtml = useCallback(() => {
- const src = latestMarkdownRef.current ?? activeNote?.content;
+ const src = latestMarkdownRef.current ?? activeContent;
const body = noteContentToMarkdown(src);
const bodyHtml = marked.parse(body, { async: false }) as string;
const html = `\n\n\n \n${title || "Note"} \n\n\n${title || "Untitled"} \n${bodyHtml}\n`;
@@ -229,7 +233,7 @@ export default function NotionEditor() {
a.download = `${(title || "note").replace(/\s+/g, "-")}.html`;
a.click();
URL.revokeObjectURL(url);
- }, [activeNote?.content, title]);
+ }, [activeContent, title]);
const handleApplyTemplate = useCallback((tpl: NoteTemplate) => {
if (!activeNoteId) return;
@@ -503,9 +507,3 @@ export default function NotionEditor() {
);
}
-function sanitizeFileName(name: string): string {
- return name
- .replace(/[^a-zA-Z0-9._-]/g, "_")
- .replace(/_{2,}/g, "_")
- .slice(0, 200);
-}
diff --git a/apps/desktop-ui/src/components/notes/__tests__/notes-helpers.test.ts b/apps/desktop-ui/src/components/notes/__tests__/notes-helpers.test.ts
new file mode 100644
index 00000000..ace8032a
--- /dev/null
+++ b/apps/desktop-ui/src/components/notes/__tests__/notes-helpers.test.ts
@@ -0,0 +1,67 @@
+import {
+ getCachedPlainText,
+ setCachedPlainText,
+ deleteCachedPlainText,
+ clearPlainTextCache,
+ buildChildrenMap,
+} from "../notes-helpers";
+import type { Note } from "@/app/app/notes/types/Note";
+
+const note = (over: Partial & { id: string }): Note => ({
+ title: "",
+ content: null,
+ parentId: null,
+ createdAt: "2026-08-12T00:00:00.000Z",
+ updatedAt: "2026-08-12T00:00:00.000Z",
+ userId: "desktop-local",
+ ...over,
+});
+
+beforeEach(() => clearPlainTextCache());
+
+describe("getCachedPlainText", () => {
+ it("returns empty string for a metadata-only note before the index warms", () => {
+ expect(getCachedPlainText(note({ id: "a" }))).toBe("");
+ });
+
+ it("computes and caches from an in-hand body", () => {
+ const n = note({ id: "b", content: "# Title\n\nhello body" });
+ expect(getCachedPlainText(n)).toContain("hello body");
+ // Cached by id: a later metadata-only copy of the same note still resolves.
+ expect(getCachedPlainText(note({ id: "b" }))).toContain("hello body");
+ });
+
+ it("serves warmed text for a note whose body was never in state", () => {
+ setCachedPlainText("c", "warmed content");
+ expect(getCachedPlainText(note({ id: "c" }))).toBe("warmed content");
+ });
+
+ it("reflects an edit written after the warm", () => {
+ setCachedPlainText("d", "old text");
+ setCachedPlainText("d", "new text");
+ expect(getCachedPlainText(note({ id: "d" }))).toBe("new text");
+ });
+
+ it("forgets a deleted note", () => {
+ setCachedPlainText("e", "secret");
+ deleteCachedPlainText("e");
+ expect(getCachedPlainText(note({ id: "e" }))).toBe("");
+ });
+
+ it("drops everything on clear (vault lock)", () => {
+ setCachedPlainText("f", "secret");
+ clearPlainTextCache();
+ expect(getCachedPlainText(note({ id: "f" }))).toBe("");
+ });
+});
+
+describe("buildChildrenMap", () => {
+ it("groups notes by parentId with roots under null", () => {
+ const map = buildChildrenMap([
+ note({ id: "root" }),
+ note({ id: "kid", parentId: "root" }),
+ ]);
+ expect(map.get(null)?.map((n) => n.id)).toEqual(["root"]);
+ expect(map.get("root")?.map((n) => n.id)).toEqual(["kid"]);
+ });
+});
diff --git a/apps/desktop-ui/src/components/notes/notes-helpers.ts b/apps/desktop-ui/src/components/notes/notes-helpers.ts
index 6b47594f..bdf84a42 100644
--- a/apps/desktop-ui/src/components/notes/notes-helpers.ts
+++ b/apps/desktop-ui/src/components/notes/notes-helpers.ts
@@ -4,22 +4,46 @@ import { extractPlainText } from "@/app/app/notes/utils/noteContentUtils";
export type SortKey = "createdAt" | "updatedAt" | "title";
export type SortDir = "asc" | "desc";
-// Module-level incremental cache for plain text. Keyed by `id|updatedAt` so
-// only modified notes recompute when state updates. Bounded to prevent unbounded
-// growth across long sessions where notes are repeatedly edited.
+// Module-level plain-text cache for sidebar search, keyed by note id. Notes hold
+// metadata only (bodies live in NotesContentContext), so entries are written
+// explicitly by the search-index warm and by every content save. Bounded to
+// prevent unbounded growth across long sessions.
const PLAIN_TEXT_CACHE_MAX = 500;
const plainTextCache = new Map();
-export function getCachedPlainText(note: Note): string {
- const key = `${note.id}|${note.updatedAt}`;
- let v = plainTextCache.get(key);
- if (v === undefined) {
- if (plainTextCache.size >= PLAIN_TEXT_CACHE_MAX) {
- const first = plainTextCache.keys().next().value;
- if (first !== undefined) plainTextCache.delete(first);
- }
- v = extractPlainText(note.content);
- plainTextCache.set(key, v);
+
+function cacheSet(id: string, text: string): void {
+ if (!plainTextCache.has(id) && plainTextCache.size >= PLAIN_TEXT_CACHE_MAX) {
+ const first = plainTextCache.keys().next().value;
+ if (first !== undefined) plainTextCache.delete(first);
}
+ plainTextCache.set(id, text);
+}
+
+/** Cache a note's body as searchable plain text. */
+export function setCachedPlainText(id: string, content: unknown): void {
+ cacheSet(id, extractPlainText(content));
+}
+
+/** Forget one note's text (deleted note — plaintext shouldn't linger in memory). */
+export function deleteCachedPlainText(id: string): void {
+ plainTextCache.delete(id);
+}
+
+/** Drop all plaintext (vault lock — decrypted bodies must not survive it). */
+export function clearPlainTextCache(): void {
+ plainTextCache.clear();
+}
+
+/**
+ * Plain text for search. Returns "" for a metadata-only note whose body hasn't
+ * been warmed yet — such notes stay title/tag-searchable until the index warms.
+ */
+export function getCachedPlainText(note: Note): string {
+ const hit = plainTextCache.get(note.id);
+ if (hit !== undefined) return hit;
+ if (note.content == null) return "";
+ const v = extractPlainText(note.content);
+ cacheSet(note.id, v);
return v;
}
diff --git a/apps/desktop-ui/src/components/onboarding-gate.tsx b/apps/desktop-ui/src/components/onboarding-gate.tsx
index dbaf6af8..888a4660 100644
--- a/apps/desktop-ui/src/components/onboarding-gate.tsx
+++ b/apps/desktop-ui/src/components/onboarding-gate.tsx
@@ -1,37 +1,29 @@
"use client"
import { useEffect, useState } from "react"
-import useAuth from "@/utils/useAuth"
-import { getMe } from "@/lib/onboarding-api"
+import { getUserPreferences } from "@/lib/user-preferences-api"
import { OnboardingModal } from "@/components/onboarding-modal"
type State = "loading" | "show" | "done"
+/** First-run walkthrough. Whether it has run is local preference state. */
export function OnboardingGate() {
- const { user, loading: authLoading } = useAuth(false)
const [state, setState] = useState("loading")
useEffect(() => {
- if (authLoading || !user) return
let cancelled = false
-
- const check = async () => {
- try {
- const profile = await getMe()
- if (!cancelled) {
- setState(profile.onboarding_completed ? "done" : "show")
- }
- } catch {
- // If we can't fetch, don't block the user — skip onboarding
+ void getUserPreferences()
+ .then((prefs) => {
+ if (!cancelled) setState(prefs.onboardingCompleted ? "done" : "show")
+ })
+ .catch(() => {
+ // Store unavailable — never block the app on the walkthrough.
if (!cancelled) setState("done")
- }
- }
-
- void check()
+ })
return () => {
cancelled = true
}
- }, [user, authLoading])
+ }, [])
if (state !== "show") return null
diff --git a/apps/desktop-ui/src/components/onboarding-modal.tsx b/apps/desktop-ui/src/components/onboarding-modal.tsx
index 8bc44e41..4ad08f59 100644
--- a/apps/desktop-ui/src/components/onboarding-modal.tsx
+++ b/apps/desktop-ui/src/components/onboarding-modal.tsx
@@ -5,15 +5,12 @@ import { motion, AnimatePresence } from "framer-motion"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
-import { completeOnboarding, savePersona } from "@/lib/onboarding-api"
import { usePinnedToolsStore } from "@/store/pinned-tools-store"
import { useWorkspaceStore } from "@/store/workspace-store"
import { patchUserPreferences } from "@/lib/user-preferences-api"
import { ONBOARDING_ROLES, type OnboardingRole } from "@/lib/onboarding-roles"
import { Input } from "@/components/ui/input"
import useAuth from "@/utils/useAuth"
-import { updateProfile } from "firebase/auth"
-import { auth } from "@/database/firebase"
import {
IconRocket,
IconShield,
@@ -735,14 +732,10 @@ export function OnboardingModal({ onComplete }: OnboardingModalProps) {
prefsPatch.pinnedToolsByWorkspace = { [activeWorkspaceId]: tools }
}
const trimmedName = name.trim()
- await Promise.all([
- completeOnboarding(),
- patchUserPreferences(prefsPatch),
- role ? savePersona(role.id).catch(() => {}) : Promise.resolve(),
- trimmedName && auth.currentUser && trimmedName !== auth.currentUser.displayName
- ? updateProfile(auth.currentUser, { displayName: trimmedName }).catch(() => {})
- : Promise.resolve(),
- ])
+ if (trimmedName) prefsPatch.displayName = trimmedName
+ if (role) prefsPatch.persona = role.id
+ prefsPatch.onboardingCompleted = true
+ await patchUserPreferences(prefsPatch)
} catch {
// non-blocking — user can still proceed
}
diff --git a/apps/desktop-ui/src/components/palette-entries.ts b/apps/desktop-ui/src/components/palette-entries.ts
index 8693caf9..8b36273c 100644
--- a/apps/desktop-ui/src/components/palette-entries.ts
+++ b/apps/desktop-ui/src/components/palette-entries.ts
@@ -10,7 +10,6 @@ export type PaletteEntry = {
category: string
searchValue: string
Icon: React.ElementType
- requiresAuth: boolean
}
export const CATEGORY_ORDER = ['Site', 'Productivity', 'Security', 'Formatters', 'Converters', 'Generators', 'Network & API', 'Database', 'PDF', 'Media & Design'] as const
@@ -23,7 +22,6 @@ const STATIC_ENTRIES: Omit[] = [
description: 'Landing page and all tools',
category: 'Site',
Icon: Home,
- requiresAuth: false,
},
{
title: 'Dashboard',
@@ -31,7 +29,6 @@ const STATIC_ENTRIES: Omit[] = [
description: 'Your tools dashboard',
category: 'Site',
Icon: LayoutDashboard,
- requiresAuth: false,
},
{
title: 'Settings',
@@ -39,7 +36,6 @@ const STATIC_ENTRIES: Omit[] = [
description: 'Account, theme, and tool visibility',
category: 'Site',
Icon: Settings,
- requiresAuth: false,
},
{
title: 'Help',
@@ -47,7 +43,6 @@ const STATIC_ENTRIES: Omit[] = [
description: 'Documentation and support',
category: 'Site',
Icon: HelpCircle,
- requiresAuth: false,
},
{
title: 'Log in',
@@ -55,7 +50,6 @@ const STATIC_ENTRIES: Omit[] = [
description: 'Sign in to your account',
category: 'Site',
Icon: LogIn,
- requiresAuth: false,
},
]
@@ -119,7 +113,6 @@ export function getToolEntries(): PaletteEntry[] {
keywords: tool.keywords,
}),
Icon,
- requiresAuth: tool.requiresAuth,
}
})
return cachedToolEntries
diff --git a/apps/desktop-ui/src/components/passkey-prompt-gate.tsx b/apps/desktop-ui/src/components/passkey-prompt-gate.tsx
deleted file mode 100644
index 395d6f08..00000000
--- a/apps/desktop-ui/src/components/passkey-prompt-gate.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-'use client'
-
-import { useEffect, useRef, useState } from 'react'
-import { Fingerprint, Loader2 } from 'lucide-react'
-import { useTranslations } from 'next-intl'
-import { toast } from 'sonner'
-import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
-
-import useAuth from '@/utils/useAuth'
-import { Button } from '@/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { listPasskeys, registerPasskey } from '@/lib/passkey'
-
-const DISMISS_KEY = 'mdt:passkey_prompt_dismissed'
-
-export function PasskeyPromptGate() {
- const t = useTranslations('SettingsPage.passkeys.prompt')
- const { user, loading: authLoading } = useAuth(false)
- const [open, setOpen] = useState(false)
- const [registering, setRegistering] = useState(false)
- const mountedRef = useRef(true)
- useEffect(() => () => { mountedRef.current = false }, [])
-
- useEffect(() => {
- if (authLoading || !user) return
- if (typeof window === 'undefined') return
- if (window.localStorage.getItem(DISMISS_KEY) === '1') return
- if (!browserSupportsWebAuthn()) return
-
- let cancelled = false
- ;(async () => {
- try {
- const passkeys = await listPasskeys()
- if (!cancelled && passkeys.length === 0) setOpen(true)
- } catch {
- /* fail closed: don't nag if we can't even check */
- }
- })()
- return () => {
- cancelled = true
- }
- }, [user, authLoading])
-
- const dismiss = (persist: boolean) => {
- if (persist && typeof window !== 'undefined') {
- window.localStorage.setItem(DISMISS_KEY, '1')
- }
- setOpen(false)
- }
-
- const handleSetup = async () => {
- setRegistering(true)
- try {
- await registerPasskey()
- if (!mountedRef.current) return
- toast.success(t('successToast'))
- dismiss(true)
- } catch (e) {
- const msg = e instanceof Error ? e.message : t('errorToast')
- // NotAllowedError = user cancelled in the OS prompt — keep prompt open
- if (!/NotAllowedError|cancel/i.test(msg)) {
- toast.error(msg)
- }
- } finally {
- if (mountedRef.current) setRegistering(false)
- }
- }
-
- return (
- !registering && !o && dismiss(true)}>
-
-
-
-
- {t('title')}
-
- {t('description')}
-
-
- dismiss(true)} disabled={registering}>
- {t('skip')}
-
-
- {registering ? (
- <>
-
- {t('setup')}
- >
- ) : (
- t('setup')
- )}
-
-
-
-
- )
-}
diff --git a/apps/desktop-ui/src/components/password-manager/add-password-dialog.tsx b/apps/desktop-ui/src/components/password-manager/add-password-dialog.tsx
index 45cf0f3d..162b446a 100644
--- a/apps/desktop-ui/src/components/password-manager/add-password-dialog.tsx
+++ b/apps/desktop-ui/src/components/password-manager/add-password-dialog.tsx
@@ -18,7 +18,6 @@ import { Badge } from "@/components/ui/badge"
import { encryptData } from "@/lib/encryption"
import { validateTotpSecret, calculatePasswordStrength, getStrengthColor, getStrengthLabelKey } from "@/lib/password-utils"
import { usePasswordStrengthReady } from "@/lib/use-password-strength"
-import { auth } from "@/database/firebase"
import { createPasswordEntry } from "@/lib/password-manager-api"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
@@ -67,7 +66,7 @@ export function AddPasswordDialog({ children }: { children?: React.ReactNode })
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- if (!cipherKey || !auth.currentUser) return
+ if (!cipherKey) return
if (formData.totpSecret) {
const totpError = validateTotpSecret(formData.totpSecret)
diff --git a/apps/desktop-ui/src/components/password-manager/edit-password-dialog.tsx b/apps/desktop-ui/src/components/password-manager/edit-password-dialog.tsx
index f1649e5f..db7d9e26 100644
--- a/apps/desktop-ui/src/components/password-manager/edit-password-dialog.tsx
+++ b/apps/desktop-ui/src/components/password-manager/edit-password-dialog.tsx
@@ -17,7 +17,6 @@ import { useCipherKey } from "./encryption-context"
import { encryptData } from "@/lib/encryption"
import { validateTotpSecret, calculatePasswordStrength, getStrengthColor, getStrengthLabelKey } from "@/lib/password-utils"
import { usePasswordStrengthReady } from "@/lib/use-password-strength"
-import { auth } from "@/database/firebase"
import { updatePasswordEntry } from "@/lib/password-manager-api"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
@@ -81,7 +80,7 @@ export function EditPasswordDialog({ entry, open, onOpenChange }: EditPasswordDi
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- if (!cipherKey || !auth.currentUser) return
+ if (!cipherKey) return
if (formData.totpSecret) {
const totpError = validateTotpSecret(formData.totpSecret)
diff --git a/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx b/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx
index 1d9033af..3d3ddb4f 100644
--- a/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx
+++ b/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx
@@ -10,7 +10,6 @@ import { Upload, Download, FileJson, AlertTriangle, ShieldCheck } from "lucide-r
import { usePasswordStore, PasswordEntry } from "@/store/password-store"
import { useMasterKeyStore } from "@/store/master-key-store"
import { toast } from "sonner"
-import { auth } from "@/database/firebase"
import { createPasswordEntry } from "@/lib/password-manager-api"
import { encryptData } from "@/lib/encryption"
import { reauthenticate } from "@/lib/verify-master-password"
@@ -154,7 +153,7 @@ export function ImportExportDialog({ children }: ImportExportDialogProps) {
*/
const handleImport = async () => {
if (!preview || preview.entries.length === 0) return
- if (!encryptionKey || !auth.currentUser) return
+ if (!encryptionKey) return
setLoading(true)
let imported = 0
diff --git a/apps/desktop-ui/src/components/password-manager/password-list.tsx b/apps/desktop-ui/src/components/password-manager/password-list.tsx
index 50b5eb29..46782f61 100644
--- a/apps/desktop-ui/src/components/password-manager/password-list.tsx
+++ b/apps/desktop-ui/src/components/password-manager/password-list.tsx
@@ -12,7 +12,6 @@ import { Search, Copy, Eye, EyeOff, Trash2, ExternalLink, LayoutGrid, List, Lock
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { toast } from "sonner"
-import { auth } from "@/database/firebase"
import { deletePasswordEntry } from "@/lib/password-manager-api"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
@@ -212,7 +211,7 @@ export function PasswordList() {
}
const handleDeleteConfirm = async () => {
- if (!auth.currentUser || !passwordToDelete) return
+ if (!passwordToDelete) return
try {
await deletePasswordEntry(passwordToDelete)
diff --git a/apps/desktop-ui/src/components/require-auth.tsx b/apps/desktop-ui/src/components/require-auth.tsx
deleted file mode 100644
index 2aa9001e..00000000
--- a/apps/desktop-ui/src/components/require-auth.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { useRouter } from "next/navigation";
-import { AppLoadingScreen } from "@/components/app-loading-screen";
-import useAuth from "@/utils/useAuth";
-
-/**
- * Desktop gate. Two requirements, in order:
- * 1. Activation record (local kv store, offline) — else /activate.
- * 2. A signed-in Firebase user — else /login. Firebase persists the session
- * locally (IndexedDB), so this stays satisfied offline once the user has
- * logged in; no guest access to the dashboard.
- */
-export function RequireAuth({ children }: { children: React.ReactNode }) {
- const [activated, setActivated] = useState(false);
- const { user, loading } = useAuth();
- const router = useRouter();
-
- useEffect(() => {
- let cancelled = false;
- void import("@/lib/desktop/activation").then(async ({ getActivation }) => {
- const rec = await getActivation().catch(() => null);
- if (cancelled) return;
- if (!rec) router.replace("/activate");
- else setActivated(true);
- });
- return () => {
- cancelled = true;
- };
- }, [router]);
-
- // Once activated, require a signed-in user — send guests to the login page.
- useEffect(() => {
- if (activated && !loading && !user) router.replace("/login");
- }, [activated, loading, user, router]);
-
- if (!activated || loading || !user) {
- return ;
- }
-
- return <>{children}>;
-}
diff --git a/apps/desktop-ui/src/components/settings/passkey-section.tsx b/apps/desktop-ui/src/components/settings/passkey-section.tsx
deleted file mode 100644
index ac4e49c4..00000000
--- a/apps/desktop-ui/src/components/settings/passkey-section.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-'use client'
-
-import { useCallback, useEffect, useState } from 'react'
-import { Fingerprint, KeyRound, Loader2, Trash2 } from 'lucide-react'
-import { toast } from 'sonner'
-import { useTranslations } from 'next-intl'
-
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- deletePasskey,
- listPasskeys,
- registerPasskey,
- renamePasskey,
- type PasskeySummary,
-} from '@/lib/passkey'
-
-function formatDate(ms: number | null): string {
- if (!ms) return '—'
- try {
- return new Date(ms).toLocaleString()
- } catch {
- return '—'
- }
-}
-
-export function PasskeySection() {
- const t = useTranslations('SettingsPage.passkeys')
- const [passkeys, setPasskeys] = useState(null)
- const [loading, setLoading] = useState(false)
- const [registering, setRegistering] = useState(false)
- const [addOpen, setAddOpen] = useState(false)
- const [deviceName, setDeviceName] = useState('')
- const [toDelete, setToDelete] = useState(null)
-
- const refresh = useCallback(async () => {
- setLoading(true)
- try {
- setPasskeys(await listPasskeys())
- } catch (e) {
- toast.error(e instanceof Error ? e.message : t('loadError'))
- } finally {
- setLoading(false)
- }
- }, [t])
-
- useEffect(() => {
- refresh()
- }, [refresh])
-
- const handleAdd = async () => {
- setRegistering(true)
- try {
- await registerPasskey(deviceName.trim() || undefined)
- toast.success(t('addedToast'))
- setAddOpen(false)
- setDeviceName('')
- await refresh()
- } catch (e) {
- const msg = e instanceof Error ? e.message : t('addError')
- toast.error(msg)
- } finally {
- setRegistering(false)
- }
- }
-
- const handleRename = async (p: PasskeySummary) => {
- const next = window.prompt(t('renamePrompt'), p.device_name ?? '')
- if (next === null) return
- const trimmed = next.trim()
- if (!trimmed || trimmed === p.device_name) return
- try {
- await renamePasskey(p.credential_id, trimmed)
- toast.success(t('renamedToast'))
- await refresh()
- } catch (e) {
- toast.error(e instanceof Error ? e.message : t('renameError'))
- }
- }
-
- const handleDelete = async () => {
- if (!toDelete) return
- try {
- await deletePasskey(toDelete.credential_id)
- toast.success(t('removedToast'))
- setToDelete(null)
- await refresh()
- } catch (e) {
- toast.error(e instanceof Error ? e.message : t('deleteError'))
- }
- }
-
- return (
-
-
-
-
- {t('title')}
-
- {t('description')}
-
-
-
-
- {loading
- ? t('loading')
- : passkeys && passkeys.length > 0
- ? t('count', { count: passkeys.length })
- : t('none')}
-
-
setAddOpen(true)} size="sm">
-
- {t('addButton')}
-
-
-
- {passkeys && passkeys.length > 0 ? (
-
- ) : null}
-
-
- !registering && setAddOpen(open)}>
-
-
- {t('dialog.title')}
- {t('dialog.description')}
-
-
- {t('dialog.nameLabel')}
- setDeviceName(e.target.value)}
- maxLength={80}
- disabled={registering}
- />
-
-
- setAddOpen(false)} disabled={registering}>
- {t('dialog.cancel')}
-
-
- {registering ? (
- <>
-
- {t('dialog.waiting')}
- >
- ) : (
- t('dialog.continue')
- )}
-
-
-
-
-
- !open && setToDelete(null)}>
-
-
- {t('delete.title')}
- {t('delete.description')}
-
-
- {t('delete.cancel')}
- {t('delete.confirm')}
-
-
-
-
- )
-}
diff --git a/apps/desktop-ui/src/components/settings/profile-card.tsx b/apps/desktop-ui/src/components/settings/profile-card.tsx
new file mode 100644
index 00000000..492a4238
--- /dev/null
+++ b/apps/desktop-ui/src/components/settings/profile-card.tsx
@@ -0,0 +1,104 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { UserRound } from 'lucide-react'
+import { toast } from 'sonner'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { PROFILE_UPDATED_EVENT } from '@/hooks/use-app-user'
+import { getUserPreferences, patchUserPreferences } from '@/lib/user-preferences-api'
+import { DesktopUpdateDialog } from '@/components/desktop/desktop-update-dialog'
+
+/**
+ * Local profile: the name and avatar the app shows you. Stored in local
+ * preferences — there is no account and nothing leaves the device.
+ */
+export function ProfileCard() {
+ const t = useTranslations('SettingsPage.userProfile')
+ const [name, setName] = useState('')
+ const [avatar, setAvatar] = useState('')
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ void getUserPreferences()
+ .then((prefs) => {
+ setName(prefs.displayName || '')
+ setAvatar(prefs.avatar || '')
+ })
+ .catch(() => {
+ // Store unavailable — leave the fields empty rather than blocking settings.
+ })
+ }, [])
+
+ const save = async () => {
+ setSaving(true)
+ try {
+ await patchUserPreferences({
+ displayName: name.trim() || null,
+ avatar: avatar.trim() || null,
+ })
+ window.dispatchEvent(new CustomEvent(PROFILE_UPDATED_EVENT))
+ toast.success(t('saved'))
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : t('saveError'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const displayName = name.trim() || t('anonymousUser')
+
+ return (
+
+
+
+
+
+
+ {t('title')}
+
+ {t('description')}
+
+
+
+
+ {avatar ? : null}
+
+ {displayName[0]!.toUpperCase()}
+
+
+
+
+
+ void save()} disabled={saving}>
+ {t('save')}
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop-ui/src/components/shell/top-bar.tsx b/apps/desktop-ui/src/components/shell/top-bar.tsx
index 10681aeb..9ff57452 100644
--- a/apps/desktop-ui/src/components/shell/top-bar.tsx
+++ b/apps/desktop-ui/src/components/shell/top-bar.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
-import { Settings, LogOut, User as UserIcon, HelpCircle, Moon, Grid2x2Plus } from 'lucide-react'
+import { Settings, HelpCircle, Moon, Grid2x2Plus } from 'lucide-react'
import { ModeToggle } from '@/components/modeToggle'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TopNavStrip, NavIcon } from '@/components/shell/top-nav-strip'
@@ -16,7 +16,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAppUser } from '@/hooks/use-app-user'
-import { useSignOut } from '@/hooks/use-sign-out'
import { isDesktop } from '@/lib/desktop/is-desktop'
import { cn } from '@/lib/utils'
@@ -30,7 +29,6 @@ import { cn } from '@/lib/utils'
export function TopBar() {
const router = useRouter()
const user = useAppUser()
- const signOut = useSignOut()
// macOS traffic lights are inset into this bar (titleBarStyle: Overlay), so
// pad the left only inside the Tauri window. Mounted-guarded to avoid an SSR
@@ -66,9 +64,8 @@ export function TopBar() {
}
}, [])
- const isLoggedIn = Boolean(user.name || user.email)
- const displayName = user.name?.trim() || user.email?.split('@')[0] || 'User'
- const initial = (user.name?.trim()?.[0] || user.email?.[0] || '?').toUpperCase()
+ const displayName = user.name?.trim() || 'You'
+ const initial = displayName[0]!.toUpperCase()
return (
- {isLoggedIn ? (
- <>
-
-
{displayName}
- {user.email ? (
-
{user.email}
- ) : null}
-
-
-
-
- Profile
-
-
-
-
- Settings
-
-
-
-
- Help
-
-
-
-
-
- Theme
-
-
-
-
- void signOut()}
- className={cn('cursor-pointer gap-2.5 text-destructive focus:text-destructive')}
- >
- Sign out
-
- >
- ) : (
- <>
-
-
- Theme
-
-
-
-
-
-
- Sign in
-
-
- >
- )}
+
+
+
+
+ Settings
+
+
+
+
+ Help
+
+
+
+
+
+ Theme
+
+
+
diff --git a/apps/desktop-ui/src/components/sidebar/app-sidebar.tsx b/apps/desktop-ui/src/components/sidebar/app-sidebar.tsx
index 9e3ffd01..0f6c7f3a 100644
--- a/apps/desktop-ui/src/components/sidebar/app-sidebar.tsx
+++ b/apps/desktop-ui/src/components/sidebar/app-sidebar.tsx
@@ -6,11 +6,9 @@ import { cn } from '@/lib/utils'
import {
Sidebar,
SidebarContent,
- SidebarFooter,
SidebarRail,
} from '@/components/ui/sidebar'
import { NavGroup } from './nav-group'
-import { FeedbackDialog } from '@/components/feedback-dialog'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
import { LayoutDashboard, Sparkles } from 'lucide-react'
import { usePinnedToolsForActiveWorkspace } from '@/store/pinned-tools-store'
@@ -78,9 +76,6 @@ export function AppSidebar({ ...props }: React.ComponentProps