diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 78aada94..216cbe71 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -80,14 +80,6 @@ jobs:
# Tauri updater signing
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- # Next.js env (desktop build)
- NEXT_PUBLIC_FASTAPI_BASE_URL: ${{ secrets.NEXT_PUBLIC_FASTAPI_BASE_URL }}
- NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
- NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
- NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
- NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
- NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
- NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
with:
projectPath: apps/desktop
# __VERSION__ is replaced by tauri-action with the version from
diff --git a/CLAUDE.md b/CLAUDE.md
index 02ebefd4..b6f5893f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,13 +1,16 @@
# MyDevTools — Claude Code instructions
-All-in-one developer toolkit (~80 tools: formatters, security, generators, notes, bookmarks, tasks, API client…). Full product doc: `docs/MYDEVTOOLS.md`. Web-redesign handoff: `HANDOFF.md`.
+All-in-one developer toolkit (~80 tools: formatters, security, generators, notes, bookmarks, tasks, API client…). Full product doc: `docs/MYDEVTOOLS.md`.
## Product model (current direction — do not regress)
-- **The desktop Tauri app is the product.** Fully offline after a mandatory one-time browser activation (loopback OAuth + `mydevtools://` deep link; activation stored in SQLCipher `kv` key `activation`). No per-session login, no periodic recheck.
-- **Free & open source (AGPL-3.0).** No plans, no pricing, no paywalls — never reintroduce them. "Free & Open Source" page; `/account/plan` redirects to `/dashboard` (shipped desktop builds hard-open it).
-- **Web (`apps/web`) = marketing/auth only**: landing, SEO pages (download CTAs), login handoff, `/dashboard` (passkeys). Never reintroduce web tool pages or cloud sync.
+- **The desktop Tauri app is the product.** Fully offline, no server. **No accounts, no sign-in, no activation** — the app opens straight to the dashboard. Never reintroduce auth, Firebase, or a backend.
+- **Free & open source (AGPL-3.0).** No plans, no pricing, no paywalls — never reintroduce them. "Free & Open Source" page; `/account/plan` redirects to `/download` (shipped desktop builds hard-open the URL).
+- **Web (`apps/web`) = marketing/SEO only**: landing, SEO pages (download CTAs). No login, no dashboard, no API routes except `/api/app-version` (updater). Never reintroduce web tool pages or cloud sync.
+- Identity is local: fixed `uid` `"local"` from `utils/useAuth`, editable display name/avatar in local preferences (`useAppUser`).
+- All app data goes through `lib/backend-api.ts` / `lib/desktop/api-fetch.ts` → Tauri `local_api` → SQLCipher. The `/api/v1/...` paths are the local Rust router's contract, never HTTP.
- Vault/masterkey creation is offline-only (desktop Rust mirror).
+- Usage analytics = `lib/telemetry.ts` only: opt-in, anonymous (rotating session id, app version, locale), two events (`app_started`, `tool_opened {tool}`). Never send tool input, paths, or any stable id. Vercel Analytics / Clarity are marketing-site-only — never re-add them to `apps/desktop-ui`.
## Monorepo (pnpm)
@@ -15,14 +18,14 @@ All-in-one developer toolkit (~80 tools: formatters, security, generators, notes
|---|---|
| `apps/desktop-ui` | Next.js UI the Tauri app builds from — **all tool work happens here** (clone-then-prune of apps/web) |
| `apps/desktop` | Tauri v2 shell — SQLCipher keyed by macOS Keychain, native Rust DB drivers |
-| `apps/web` | Marketing/SEO/auth site (Next.js 16, React 19, Tailwind, shadcn/ui, next-intl) |
-| `apps/backend` | FastAPI — health + auth only |
+| `apps/web` | Marketing/SEO site (Next.js 16, React 19, Tailwind, shadcn/ui, next-intl) |
## Build / verify
- `pnpm exec` is broken (implicit install fails on ignored build scripts). Use repo-local bins: `./node_modules/.bin/tsc --noEmit`, `./node_modules/.bin/jest`, `./node_modules/.bin/next dev`. Node at `/Users/max/.nvm/versions/node/v24.18.0/bin`.
- Run `next typegen` after adding routes, else tsc errors on stale `.next/dev/types`.
-- Known pre-existing failing suites (ignore): react-window, pending-invitations-badge, encrypted-tool-placeholder, workspace-store.
+- Known pre-existing failing suite (ignore): react-window (`react-window` is not installed).
+- Rust: `cargo check` / `cargo test` in `apps/desktop/src-tauri`.
- Desktop-ui: `pnpm dev:tauri` (sets `NEXT_PUBLIC_TAURI=1`), `pnpm build:tauri`.
## Adding a tool
diff --git a/README.md b/README.md
index daeb3abc..51d261f0 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,12 @@
MyDevTools.tech
- Your all-in-one developer toolkit — fast, private, and beautifully crafted.
+ 80+ developer tools in one desktop app.
+ Completely offline. No account required. Free for everyone.
- This service exposes health and authentication helpers. All tool data lives
- on the user's device — the desktop app stores it locally, so there are no CRUD endpoints here.
-
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/clarity-analytics.tsx b/apps/desktop-ui/src/components/clarity-analytics.tsx
deleted file mode 100644
index f223230e..00000000
--- a/apps/desktop-ui/src/components/clarity-analytics.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-"use client"
-
-import { useEffect } from "react"
-
-// Microsoft Clarity heatmaps + session replays. No-op unless
-// NEXT_PUBLIC_CLARITY_PROJECT_ID is set (so it stays off in local dev),
-// matching how Vercel Analytics only reports in production.
-// Data lives in clarity.microsoft.com — nothing touches our backend.
-let initialized = false // ponytail: module guard so StrictMode/remounts can't double-inject the script
-
-export function ClarityAnalytics() {
- useEffect(() => {
- const projectId = process.env.NEXT_PUBLIC_CLARITY_PROJECT_ID
- if (!projectId || initialized) return
- initialized = true
- import("@microsoft/clarity").then((m) => m.default.init(projectId))
- }, [])
-
- return null
-}
diff --git a/apps/desktop-ui/src/components/client-shell.tsx b/apps/desktop-ui/src/components/client-shell.tsx
index 849df1bf..b6ee4fa6 100644
--- a/apps/desktop-ui/src/components/client-shell.tsx
+++ b/apps/desktop-ui/src/components/client-shell.tsx
@@ -1,26 +1,23 @@
"use client"
import dynamic from "next/dynamic"
-import { Suspense, type ReactNode } from "react"
+import { Suspense, useEffect, 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"
+import { initTelemetry } from "@/lib/telemetry"
const GlobalCommandPalette = dynamic(
() => import('@/components/global-command-palette').then((m) => m.GlobalCommandPalette),
{ ssr: false }
)
-const ClientAnalytics = dynamic(
- () => import('@vercel/analytics/next').then((m) => m.Analytics),
- { ssr: false }
-)
-const ClientSpeedInsights = dynamic(
- () => import('@vercel/speed-insights/next').then((m) => m.SpeedInsights),
- { ssr: false }
-)
-const ClientClarity = dynamic(
- () => import('@/components/clarity-analytics').then((m) => m.ClarityAnalytics),
+// Vercel Analytics / Speed Insights / Microsoft Clarity used to live here,
+// inherited from the marketing site. All three are wrong for this app: the
+// Vercel ones post to a host that does not exist in Tauri, and Clarity pulls a
+// remote script that session-records an offline, local-first app. Usage now
+// goes through lib/telemetry.ts — opt-in, anonymous, two events.
+const ClientTelemetryConsent = dynamic(
+ () => import('@/components/telemetry-consent-card').then((m) => m.TelemetryConsentCard),
{ ssr: false }
)
const ClientToaster = dynamic(
@@ -33,24 +30,21 @@ type Props = {
}
export function ClientShell({ children }: Props) {
+ useEffect(() => {
+ void initTelemetry()
+ }, [])
+
return (
<>
-
{children}
-
-
-
-
-
-
-
+
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/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/mongodb/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/mongodb/connection-service.ts
index 45fd8a66..2b6bfbaa 100644
--- a/apps/desktop-ui/src/components/data-explorer/mongodb/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/data-explorer/mongodb/query-builder.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/query-builder.tsx
index 4218e1b5..2dc7b291 100644
--- a/apps/desktop-ui/src/components/data-explorer/mongodb/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/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/data-explorer/sql/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/sql/connection-service.ts
index f728cf24..1465359f 100644
--- a/apps/desktop-ui/src/components/data-explorer/sql/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/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). */}
-
-
- {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}
-
-
-
-
-
-
-
- )
-}
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 (
-
-
-
-
-
- )
-}
diff --git a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx
index 0c30f030..77ab1af5 100644
--- a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx
+++ b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx
@@ -3,10 +3,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
-import { useAuthState } from "react-firebase-hooks/auth";
+import useAuth from "@/utils/useAuth";
import { useTranslations } from "next-intl";
import { useDebouncedCallback } from "use-debounce";
-import { auth } from "@/database/firebase";
import {
createCodeSnippetApi,
deleteCodeSnippetApi,
@@ -97,7 +96,7 @@ const FORMAT_SUPPORTED_LANGS = new Set([
export function SnippetManagerTool() {
const t = useTranslations("SnippetManager");
- const [user, authLoading] = useAuthState(auth);
+ const { user, loading: authLoading } = useAuth();
const userRef = useRef(user);
userRef.current = user;
diff --git a/apps/desktop-ui/src/components/telemetry-consent-card.tsx b/apps/desktop-ui/src/components/telemetry-consent-card.tsx
new file mode 100644
index 00000000..820de810
--- /dev/null
+++ b/apps/desktop-ui/src/components/telemetry-consent-card.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { BarChart3 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { getConsent, setConsent, initTelemetry } from '@/lib/telemetry'
+import { getUserPreferences } from '@/lib/user-preferences-api'
+
+/**
+ * Asks once, on a run after the first-run walkthrough is done, and never again
+ * — the answer is remembered either way, and Settings owns it from then on.
+ */
+export function TelemetryConsentCard() {
+ const t = useTranslations('Telemetry')
+ const [visible, setVisible] = useState(false)
+
+ useEffect(() => {
+ if (getConsent() !== 'unset') return
+
+ let cancelled = false
+ void getUserPreferences()
+ .then((prefs) => {
+ // Waiting on onboarding keeps this from stacking under the walkthrough.
+ if (!cancelled && prefs.onboardingCompleted) setVisible(true)
+ })
+ .catch(() => {
+ // Store unavailable — asking later is better than asking over an error.
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (!visible) return null
+
+ const answer = (granted: boolean) => {
+ setConsent(granted ? 'granted' : 'denied')
+ setVisible(false)
+ if (granted) void initTelemetry()
+ }
+
+ return (
+
+
+
+
+
+
+
+
{t('consent.title')}
+
{t('consent.body')}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop-ui/src/database/firebase.js b/apps/desktop-ui/src/database/firebase.js
deleted file mode 100644
index 704419f2..00000000
--- a/apps/desktop-ui/src/database/firebase.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { initializeApp } from "firebase/app";
-import { getAuth } from 'firebase/auth';
-import { getStorage } from 'firebase/storage';
-
-const firebaseConfig = {
- apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
- authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
- projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
- storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
- messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
- appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
-};
-
-const app = initializeApp(firebaseConfig);
-const storage = getStorage(app);
-
-export { storage };
-export const auth = getAuth(app);
diff --git a/apps/desktop-ui/src/hooks/use-app-user.ts b/apps/desktop-ui/src/hooks/use-app-user.ts
index b85954ab..cfc902eb 100644
--- a/apps/desktop-ui/src/hooks/use-app-user.ts
+++ b/apps/desktop-ui/src/hooks/use-app-user.ts
@@ -1,28 +1,44 @@
'use client'
import { useEffect, useState } from 'react'
-import { onAuthStateChanged } from 'firebase/auth'
-import { auth } from '@/database/firebase'
+import { getUserPreferences } from '@/lib/user-preferences-api'
export interface AppUser {
name: string
- email: string
avatar: string
}
-/** Live Firebase profile (empty strings when signed out). */
+/** Dispatched by the profile form so open surfaces pick up the new name/avatar. */
+export const PROFILE_UPDATED_EVENT = 'mydevtools:profile-updated'
+
+const EMPTY: AppUser = { name: '', avatar: '' }
+
+/**
+ * Local profile (display name + avatar), stored in user preferences. There is
+ * no account behind it — it only decides what the app calls you.
+ */
export function useAppUser(): AppUser {
- const [user, setUser] = useState({ name: '', email: '', avatar: '' })
+ const [user, setUser] = useState(EMPTY)
useEffect(() => {
- const unsubscribe = onAuthStateChanged(auth, (u) => {
- setUser(
- u
- ? { name: u.displayName || '', email: u.email || '', avatar: u.photoURL || '' }
- : { name: '', email: '', avatar: '' },
- )
- })
- return () => unsubscribe()
+ let cancelled = false
+ const load = async () => {
+ try {
+ const prefs = await getUserPreferences()
+ if (!cancelled) {
+ setUser({ name: prefs.displayName || '', avatar: prefs.avatar || '' })
+ }
+ } catch {
+ // Store unavailable (vault locked, first launch) — fall back to defaults.
+ }
+ }
+ void load()
+ const onUpdate = () => void load()
+ window.addEventListener(PROFILE_UPDATED_EVENT, onUpdate)
+ return () => {
+ cancelled = true
+ window.removeEventListener(PROFILE_UPDATED_EVENT, onUpdate)
+ }
}, [])
return user
diff --git a/apps/desktop-ui/src/hooks/use-sign-out.ts b/apps/desktop-ui/src/hooks/use-sign-out.ts
deleted file mode 100644
index df8108ad..00000000
--- a/apps/desktop-ui/src/hooks/use-sign-out.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-'use client'
-
-import { useRouter } from 'next/navigation'
-import { signOut as firebaseSignOut } from 'firebase/auth'
-import { auth } from '@/database/firebase'
-import { usePasswordStore } from '@/store/password-store'
-import { useEnvironmentManagerStore } from '@/store/environment-manager-store'
-import { useMasterKeyStore } from '@/store/master-key-store'
-import { useUserKeypairStore } from '@/store/user-keypair-store'
-import { useWorkspaceStore } from '@/store/workspace-store'
-import { clearMasterKey } from '@/lib/key-storage'
-import { logoutBackendSession } from '@/lib/backend-auth'
-
-/**
- * Full sign-out: wipes every in-memory secret + the encrypted vault key from
- * IndexedDB, ends the backend session, signs out of Firebase, and routes to
- * /login. Single source of truth used by the top-bar profile menu.
- */
-export function useSignOut(): () => Promise {
- const router = useRouter()
- const { clearPasswords } = usePasswordStore()
- const { clearSets } = useEnvironmentManagerStore()
- const { clearKey: clearMasterKeyStore } = useMasterKeyStore()
- const { clear: clearUserKeypair } = useUserKeypairStore()
- const clearWorkspaceStore = useWorkspaceStore((s) => s.clear)
-
- return async function signOut() {
- try {
- clearPasswords() // decrypted passwords in memory
- clearSets() // decrypted environment sets in memory
- clearMasterKeyStore() // in-memory master key
- clearUserKeypair() // workspace keypair in memory
- clearWorkspaceStore() // workspace selection
-
- // Clear password-manager vault key from IndexedDB
- if (typeof window !== 'undefined' && window.indexedDB) {
- await new Promise((resolve) => {
- const req = window.indexedDB.open('PasswordManagerDB', 1)
- req.onsuccess = (e: Event) => {
- const db = (e.target as IDBOpenDBRequest).result
- if (db.objectStoreNames.contains('keys')) {
- const tx = db.transaction('keys', 'readwrite')
- tx.objectStore('keys').delete('vaultKey')
- tx.oncomplete = () => resolve()
- tx.onerror = () => resolve()
- } else {
- resolve()
- }
- }
- req.onerror = () => resolve()
- })
- }
-
- await clearMasterKey() // global master key in IndexedDB
- await logoutBackendSession()
- await firebaseSignOut(auth)
- router.push('/login')
- } catch (error) {
- console.error('Error signing out:', error)
- }
- }
-}
diff --git a/apps/desktop-ui/src/hooks/use-tool-usage.ts b/apps/desktop-ui/src/hooks/use-tool-usage.ts
index 283031d3..0bd5214b 100644
--- a/apps/desktop-ui/src/hooks/use-tool-usage.ts
+++ b/apps/desktop-ui/src/hooks/use-tool-usage.ts
@@ -3,6 +3,7 @@
import { useCallback } from 'react';
import useAuth from '@/utils/useAuth';
import { trackToolUsageApi } from '@/lib/user-preferences-api';
+import { track } from '@/lib/telemetry';
import {
appendEvent,
deriveRecents,
@@ -57,6 +58,10 @@ export function useToolUsage() {
console.error('Error updating tool stats:', error);
});
}
+
+ // Anonymous, opt-in, and a no-op unless the user turned it on. The tool
+ // slug is the only thing sent — never the URL, which can carry state.
+ track('tool_opened', { tool: toolId });
}, [user?.uid]);
const getRecentlyUsedTools = useCallback(
diff --git a/apps/desktop-ui/src/lib/__tests__/auth-inflight.test.ts b/apps/desktop-ui/src/lib/__tests__/auth-inflight.test.ts
deleted file mode 100644
index e62943f9..00000000
--- a/apps/desktop-ui/src/lib/__tests__/auth-inflight.test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { dedupe, clearInflight } from "@/lib/auth-inflight"
-
-describe("dedupe", () => {
- beforeEach(() => clearInflight())
-
- it("returns the same promise for concurrent callers with the same key", async () => {
- let calls = 0
- const fn = () =>
- new Promise((resolve) =>
- setTimeout(() => {
- calls += 1
- resolve("ok")
- }, 20)
- )
-
- const [a, b, c] = await Promise.all([
- dedupe("k", fn),
- dedupe("k", fn),
- dedupe("k", fn),
- ])
-
- expect(a).toBe("ok")
- expect(b).toBe("ok")
- expect(c).toBe("ok")
- expect(calls).toBe(1)
- })
-
- it("runs distinct keys independently", async () => {
- let calls = 0
- const fn = () =>
- new Promise((resolve) => {
- calls += 1
- resolve(calls)
- })
-
- const [a, b] = await Promise.all([dedupe("x", fn), dedupe("y", fn)])
- expect(a).not.toBe(b)
- expect(calls).toBe(2)
- })
-
- it("clears the entry after settling (next call re-runs fn)", async () => {
- let calls = 0
- const fn = async () => {
- calls += 1
- return calls
- }
-
- await dedupe("k", fn)
- await dedupe("k", fn)
- expect(calls).toBe(2)
- })
-
- it("clears the entry even when the inner fn rejects", async () => {
- let calls = 0
- const fn = async () => {
- calls += 1
- throw new Error("boom")
- }
-
- await expect(dedupe("k", fn)).rejects.toThrow("boom")
- await expect(dedupe("k", fn)).rejects.toThrow("boom")
- expect(calls).toBe(2)
- })
-})
diff --git a/apps/desktop-ui/src/lib/__tests__/backend-auth.test.ts b/apps/desktop-ui/src/lib/__tests__/backend-auth.test.ts
deleted file mode 100644
index 16a58340..00000000
--- a/apps/desktop-ui/src/lib/__tests__/backend-auth.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { clearInflight } from "@/lib/auth-inflight"
-
-// Mock firebase auth module that backend-auth.ts imports.
-jest.mock("@/database/firebase", () => ({
- auth: { currentUser: { uid: "u1", getIdToken: jest.fn(async () => "id-token") } },
-}))
-
-import { proxyJsonAuthed } from "@/lib/backend-auth"
-
-type MockResponseInit = {
- status?: number
- body?: unknown
- headers?: Record
-}
-
-function mockResponse({ status = 200, body = {}, headers = {} }: MockResponseInit): Response {
- return new Response(JSON.stringify(body), {
- status,
- headers: { "content-type": "application/json", ...headers },
- })
-}
-
-describe("proxyJsonAuthed", () => {
- let fetchMock: jest.Mock
-
- beforeEach(() => {
- clearInflight()
- fetchMock = jest.fn(async (url: string) => {
- if (typeof url === "string" && url.includes("/api/proxy")) {
- return mockResponse({
- status: 200,
- body: {
- status: 200,
- statusText: "OK",
- headers: {},
- body: JSON.stringify({ ok: true }),
- time: 1,
- size: 1,
- },
- })
- }
- return mockResponse({ status: 200, body: { ok: true } })
- })
- global.fetch = fetchMock as unknown as typeof fetch
- })
-
- it("does not call /auth/session/check on warm path", async () => {
- await proxyJsonAuthed("http://b", "GET", "/x")
- const urls = fetchMock.mock.calls.map((c) => String(c[0]))
- expect(urls.some((u) => u.includes("/auth/session/check"))).toBe(false)
- })
-
- it("parallel 5 calls trigger zero /auth/session/check requests", async () => {
- await Promise.all(
- Array.from({ length: 5 }, () => proxyJsonAuthed("http://b", "GET", "/x"))
- )
- const checkCount = fetchMock.mock.calls.filter((c) =>
- String(c[0]).includes("/auth/session/check")
- ).length
- expect(checkCount).toBe(0)
- })
-
- it("on 401, calls /auth/refresh once even when invoked 3× concurrently", async () => {
- let proxyCalls = 0
- fetchMock.mockImplementation(async (url: string) => {
- const u = String(url)
- if (u.includes("/api/proxy")) {
- proxyCalls += 1
- const status = proxyCalls <= 3 ? 401 : 200
- return mockResponse({
- status: 200,
- body: {
- status,
- statusText: status === 200 ? "OK" : "Unauthorized",
- headers: {},
- body: JSON.stringify({ ok: status === 200 }),
- time: 1,
- size: 1,
- },
- })
- }
- if (u.endsWith("/api/backend/auth/refresh")) {
- return mockResponse({ status: 200, body: { ok: true } })
- }
- return mockResponse({ status: 200, body: {} })
- })
-
- await Promise.all([
- proxyJsonAuthed("http://b", "GET", "/x"),
- proxyJsonAuthed("http://b", "GET", "/x"),
- proxyJsonAuthed("http://b", "GET", "/x"),
- ])
-
- const refreshCount = fetchMock.mock.calls.filter((c) =>
- String(c[0]).includes("/api/backend/auth/refresh")
- ).length
- expect(refreshCount).toBe(1)
- })
-})
-
-describe("establishBackendSession defaults", () => {
- let fetchMock: jest.Mock
-
- beforeEach(() => {
- clearInflight()
- fetchMock = jest.fn(async () => mockResponse({ status: 200, body: { ok: true } }))
- global.fetch = fetchMock as unknown as typeof fetch
- })
-
- it("defaults check_revoked to false", async () => {
- const { establishBackendSession } = await import("@/lib/backend-auth")
- await establishBackendSession("id-token")
- const sessionCall = fetchMock.mock.calls.find((c) =>
- String(c[0]).includes("/api/backend/auth/session")
- )
- expect(sessionCall).toBeDefined()
- const body = JSON.parse(String(sessionCall![1].body))
- expect(body.check_revoked).toBe(false)
- })
-
- it("respects explicit checkRevoked: true", async () => {
- const { establishBackendSession } = await import("@/lib/backend-auth")
- await establishBackendSession("id-token", { checkRevoked: true })
- const sessionCall = fetchMock.mock.calls.find((c) =>
- String(c[0]).includes("/api/backend/auth/session")
- )
- const body = JSON.parse(String(sessionCall![1].body))
- expect(body.check_revoked).toBe(true)
- })
-})
diff --git a/apps/desktop-ui/src/lib/__tests__/telemetry.test.ts b/apps/desktop-ui/src/lib/__tests__/telemetry.test.ts
new file mode 100644
index 00000000..3572d749
--- /dev/null
+++ b/apps/desktop-ui/src/lib/__tests__/telemetry.test.ts
@@ -0,0 +1,100 @@
+/**
+ * The only thing worth testing here is the consent gate — a bug that leaks
+ * events from a user who never opted in is the one failure mode that matters.
+ */
+
+const store = new Map()
+
+function loadTelemetry(appKey: string) {
+ jest.resetModules()
+ process.env.NEXT_PUBLIC_APTABASE_KEY = appKey
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ return require("../telemetry") as typeof import("../telemetry")
+}
+
+beforeEach(() => {
+ store.clear()
+ const localStorage = {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => void store.set(k, v),
+ }
+ // jest runs in the node environment; give the module the browser bits it uses.
+ ;(globalThis as unknown as { window: unknown }).window = {
+ localStorage,
+ dispatchEvent: () => true,
+ }
+ ;(globalThis as unknown as { Event: unknown }).Event = class {
+ constructor(public type: string) {}
+ }
+ globalThis.fetch = jest.fn(() => Promise.resolve(new Response(null, { status: 200 })))
+})
+
+afterEach(() => {
+ delete (globalThis as unknown as { window?: unknown }).window
+})
+
+describe("ingestUrl", () => {
+ it("routes by the region embedded in the app key", () => {
+ const { ingestUrl } = loadTelemetry("")
+ expect(ingestUrl("A-EU-1234567890")).toBe("https://eu.aptabase.com/api/v0/event")
+ expect(ingestUrl("A-US-1234567890")).toBe("https://us.aptabase.com/api/v0/event")
+ expect(ingestUrl("A-SH-1234567890", "https://box.local")).toBe("https://box.local/api/v0/event")
+ })
+
+ it("returns null for keys it cannot place, so track stays a no-op", () => {
+ const { ingestUrl } = loadTelemetry("")
+ expect(ingestUrl("")).toBeNull()
+ expect(ingestUrl("nonsense")).toBeNull()
+ expect(ingestUrl("A-XX-1234567890")).toBeNull()
+ expect(ingestUrl("A-SH-1234567890")).toBeNull() // self-hosted key, no host configured
+ })
+})
+
+describe("track", () => {
+ it("sends nothing until the user opts in", () => {
+ const { track } = loadTelemetry("A-EU-1234567890")
+ track("tool_opened", { tool: "json-formatter" })
+ expect(globalThis.fetch).not.toHaveBeenCalled()
+ })
+
+ it("sends nothing after the user opts out", () => {
+ const { track, setConsent } = loadTelemetry("A-EU-1234567890")
+ setConsent("denied")
+ track("tool_opened", { tool: "json-formatter" })
+ expect(globalThis.fetch).not.toHaveBeenCalled()
+ })
+
+ it("sends nothing when the build ships without an app key", () => {
+ const { track, setConsent } = loadTelemetry("")
+ setConsent("granted")
+ track("tool_opened", { tool: "json-formatter" })
+ expect(globalThis.fetch).not.toHaveBeenCalled()
+ })
+
+ it("posts an anonymous event once consent is granted", () => {
+ const { track, setConsent } = loadTelemetry("A-EU-1234567890")
+ setConsent("granted")
+ track("tool_opened", { tool: "json-formatter" })
+
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1)
+ const [url, init] = (globalThis.fetch as jest.Mock).mock.calls[0]
+ expect(url).toBe("https://eu.aptabase.com/api/v0/event")
+ expect(init.headers["App-Key"]).toBe("A-EU-1234567890")
+ expect(init.credentials).toBe("omit")
+
+ const body = JSON.parse(init.body)
+ expect(body.eventName).toBe("tool_opened")
+ expect(body.props).toEqual({ tool: "json-formatter" })
+ expect(typeof body.sessionId).toBe("string")
+ // Nothing that identifies the install or the person using it.
+ expect(JSON.stringify(body)).not.toMatch(/userId|deviceId|email/i)
+ })
+
+ it("never rejects when the network is down", async () => {
+ const { track, setConsent } = loadTelemetry("A-EU-1234567890")
+ setConsent("granted")
+ globalThis.fetch = jest.fn(() => Promise.reject(new Error("offline")))
+ expect(() => track("app_started")).not.toThrow()
+ await Promise.resolve()
+ })
+})
diff --git a/apps/desktop-ui/src/lib/__tests__/user-keypair-api.test.ts b/apps/desktop-ui/src/lib/__tests__/user-keypair-api.test.ts
index 9375f9cb..db455142 100644
--- a/apps/desktop-ui/src/lib/__tests__/user-keypair-api.test.ts
+++ b/apps/desktop-ui/src/lib/__tests__/user-keypair-api.test.ts
@@ -1,36 +1,37 @@
/**
- * Tests for user-keypair-api.ts (Task 23 — Workspaces E2EE).
+ * Tests for user-keypair-api.ts (Workspaces E2EE).
*
* Environment: jest-environment-node — no DOM.
- * Strategy: mock backendFetch, assert URL + method + body shape.
+ * Strategy: mock the Rust bridge, not the fetch helper, so the assertions cover
+ * the whole path the wrapped private key travels: `/api/backend/...` →
+ * `normalizeBackendPath` → `localApi("/api/v1/...")`. Getting that mapping
+ * wrong is what would leave an existing vault undecryptable.
*/
-jest.mock("@/lib/backend-auth", () => ({
- backendFetch: jest.fn(),
+jest.mock("@/lib/desktop/is-desktop", () => ({
+ isDesktop: () => true,
}))
-import * as backendAuth from "@/lib/backend-auth"
+jest.mock("@/lib/desktop/bridge", () => {
+ const actual = jest.requireActual("@/lib/desktop/bridge")
+ return { ...actual, localApi: jest.fn() }
+})
+
+import { localApi } from "@/lib/desktop/bridge"
import { getKeypair, setKeypair } from "@/lib/user-keypair-api"
import type { KeypairBlob } from "@/lib/user-keypair-api"
-const mockFetch = backendAuth.backendFetch as jest.Mock
-
-function okJson(body: unknown): Response {
- return new Response(JSON.stringify(body), {
- status: 200,
- headers: { "content-type": "application/json" },
- })
-}
+const mockLocalApi = localApi as jest.Mock
-function okEmpty(status = 204): Response {
- return new Response(null, { status })
+function okJson(body: unknown) {
+ return { status: 200, body: JSON.stringify(body) }
}
beforeEach(() => {
jest.clearAllMocks()
})
-const BASE = "/api/backend/workspaces-api"
+const LOCAL_PATH = "/api/v1/workspaces-api/users/me/keypair"
const sampleBlob: KeypairBlob = {
publicKey: "SPKI_BASE64",
@@ -40,54 +41,51 @@ const sampleBlob: KeypairBlob = {
}
describe("getKeypair", () => {
- it("GETs /users/me/keypair and returns the blob", async () => {
- mockFetch.mockResolvedValueOnce(okJson(sampleBlob))
+ it("reads the local store at the normalized path and returns the blob", async () => {
+ mockLocalApi.mockResolvedValueOnce(okJson(sampleBlob))
const result = await getKeypair()
- const [url, init] = mockFetch.mock.calls[0]
- expect(url).toBe(`${BASE}/users/me/keypair`)
- expect(init).toBeUndefined()
+ const [method, path] = mockLocalApi.mock.calls[0]
+ expect(method).toBe("GET")
+ expect(path).toBe(LOCAL_PATH)
expect(result).toEqual(sampleBlob)
})
- it("returns null when response body is null (no keypair yet)", async () => {
- mockFetch.mockResolvedValueOnce(okJson(null))
+ it("returns null when the stored body is null (no keypair yet)", async () => {
+ mockLocalApi.mockResolvedValueOnce(okJson(null))
- const result = await getKeypair()
-
- expect(result).toBeNull()
+ expect(await getKeypair()).toBeNull()
})
- it("returns null on HTTP 404", async () => {
- mockFetch.mockResolvedValueOnce(new Response(null, { status: 404 }))
-
- const result = await getKeypair()
+ it("returns null on 404", async () => {
+ mockLocalApi.mockResolvedValueOnce({ status: 404, body: "" })
- expect(result).toBeNull()
+ expect(await getKeypair()).toBeNull()
})
- it("throws when response is not ok", async () => {
- mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }))
+ it("throws when the store reports a failure", async () => {
+ mockLocalApi.mockResolvedValueOnce({ status: 500, body: "" })
+
await expect(getKeypair()).rejects.toThrow("getKeypair failed (500)")
})
})
describe("setKeypair", () => {
- it("POSTs the blob to /users/me/keypair", async () => {
- mockFetch.mockResolvedValueOnce(okEmpty(204))
+ it("writes the blob to the local store at the normalized path", async () => {
+ mockLocalApi.mockResolvedValueOnce({ status: 204, body: "" })
await setKeypair(sampleBlob)
- const [url, init] = mockFetch.mock.calls[0]
- expect(url).toBe(`${BASE}/users/me/keypair`)
- expect(init.method).toBe("POST")
- expect(init.headers?.["Content-Type"]).toBe("application/json")
- expect(JSON.parse(init.body)).toEqual(sampleBlob)
+ const [method, path, body] = mockLocalApi.mock.calls[0]
+ expect(method).toBe("POST")
+ expect(path).toBe(LOCAL_PATH)
+ expect(JSON.parse(body)).toEqual(sampleBlob)
})
- it("throws when response is not ok", async () => {
- mockFetch.mockResolvedValueOnce(new Response(null, { status: 409 }))
+ it("throws when the store reports a failure", async () => {
+ mockLocalApi.mockResolvedValueOnce({ status: 409, body: "" })
+
await expect(setKeypair(sampleBlob)).rejects.toThrow("setKeypair failed (409)")
})
})
diff --git a/apps/desktop-ui/src/lib/auth-inflight.ts b/apps/desktop-ui/src/lib/auth-inflight.ts
deleted file mode 100644
index 45dfc278..00000000
--- a/apps/desktop-ui/src/lib/auth-inflight.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-const inflight = new Map>()
-
-export function dedupe(key: string, fn: () => Promise): Promise {
- const existing = inflight.get(key) as Promise | undefined
- if (existing) return existing
-
- const p = (async () => {
- try {
- return await fn()
- } finally {
- inflight.delete(key)
- }
- })()
-
- inflight.set(key, p as Promise)
- return p
-}
-
-/** Test-only: reset the in-flight map between cases. */
-export function clearInflight(): void {
- inflight.clear()
-}
diff --git a/apps/desktop-ui/src/lib/backend-api.ts b/apps/desktop-ui/src/lib/backend-api.ts
index 222b5990..dc0876ab 100644
--- a/apps/desktop-ui/src/lib/backend-api.ts
+++ b/apps/desktop-ui/src/lib/backend-api.ts
@@ -1,9 +1,12 @@
-import { proxyJsonAuthed } from "@/lib/backend-auth"
-
-export const BACKEND_BASE_URL: string =
- process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL ||
- "http://localhost:8000"
+/**
+ * App data API.
+ *
+ * "Backend" here means the in-process Rust SQLCipher store, not a server —
+ * there is none. `/api/v1/...` paths are the local router's contract (see
+ * `src-tauri/src/router/mod.rs`); they are routed by `apiFetch`, never sent
+ * over the network.
+ */
+import { apiFetch } from "@/lib/desktop/api-fetch"
export function extractBackendError(data: unknown): string {
if (typeof data === "string" && data.trim()) return data
@@ -19,12 +22,33 @@ export function extractBackendError(data: unknown): string {
return "Request failed"
}
+/** Local call that surfaces the status instead of throwing on failure. */
+export async function apiRequestRaw(
+ method: string,
+ path: string,
+ body?: unknown,
+): Promise<{ status: number; data: T | null }> {
+ const res = await apiFetch(path, {
+ method,
+ headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ })
+ const text = await res.text()
+ if (!text) return { status: res.status, data: null }
+ try {
+ return { status: res.status, data: JSON.parse(text) as T }
+ } catch {
+ return { status: res.status, data: text as unknown as T }
+ }
+}
+
+/** Local call that throws the store's error message on any non-2xx status. */
export async function apiRequest(
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) throw new Error(extractBackendError(data))
return data as T
}
diff --git a/apps/desktop-ui/src/lib/backend-auth.ts b/apps/desktop-ui/src/lib/backend-auth.ts
deleted file mode 100644
index 75d84ade..00000000
--- a/apps/desktop-ui/src/lib/backend-auth.ts
+++ /dev/null
@@ -1,325 +0,0 @@
-import type { User } from "firebase/auth"
-import { auth } from "@/database/firebase"
-import { dedupe } from "@/lib/auth-inflight"
-import { isDesktop } from "@/lib/desktop/is-desktop"
-import { normalizeBackendPath, toResponse } from "@/lib/desktop/bridge"
-import { desktopDataFetch } from "@/lib/desktop/router"
-import {
- checkRemoteSession,
- desktopEstablishSession,
- desktopSignOut,
-} from "@/lib/desktop/remote"
-
-/** Same-origin refresh endpoint (used by fetch helpers). */
-export const BACKEND_AUTH_REFRESH_PATH = "/api/backend/auth/refresh"
-
-/**
- * Exchange Firebase ID token for HttpOnly API cookies (call once after Firebase sign-in).
- */
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms))
-}
-
-async function readErrorMessage(res: Response): Promise {
- const contentType = res.headers.get("content-type") || ""
- if (contentType.includes("application/json")) {
- const json = await res.json().catch(() => null)
- if (json && typeof json === "object") {
- const detail = (json as { detail?: unknown; error?: unknown }).detail
- if (typeof detail === "string" && detail.trim()) return detail
- const error = (json as { detail?: unknown; error?: unknown }).error
- if (typeof error === "string" && error.trim()) return error
- }
- }
- const text = await res.text().catch(() => "")
- return text.trim()
-}
-
-export async function establishBackendSession(
- idToken: string,
- opts: {
- maxAttempts?: number
- getFreshIdToken?: () => Promise
- checkRevoked?: boolean
- } = {}
-): Promise {
- // Desktop: exchange goes through the Rust remote bridge (cookies land in
- // the persistent Rust jar, not the webview).
- if (isDesktop()) {
- await desktopEstablishSession(idToken)
- return
- }
- const maxAttempts = Math.max(1, opts.maxAttempts ?? 3)
- const checkRevoked = opts.checkRevoked ?? false
- return dedupe(`session:${checkRevoked ? "revoked" : "fast"}`, async () => {
- let token = idToken
- let lastError: Error | null = null
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
- try {
- const res = await fetch("/api/backend/auth/session", {
- method: "POST",
- credentials: "include",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id_token: token, check_revoked: checkRevoked }),
- cache: "no-store",
- })
-
- if (res.ok) return
-
- const retriable = res.status === 429 || res.status >= 500
- const msg = await readErrorMessage(res)
- lastError = new Error(msg || `Session exchange failed (${res.status})`)
-
- if (!retriable || attempt === maxAttempts) {
- throw lastError
- }
- } catch (e) {
- lastError = e instanceof Error ? e : new Error("Session exchange failed")
- if (attempt === maxAttempts) throw lastError
- }
-
- if (opts.getFreshIdToken) {
- try {
- token = await opts.getFreshIdToken()
- } catch {
- // keep the existing token for the next attempt
- }
- }
- await sleep(250 * attempt)
- }
- })
-}
-
-/**
- * If JWT cookies are missing or expired but Firebase session exists, re-run the Firebase exchange.
- */
-export async function ensureBackendSession(user: User): Promise {
- if (isDesktop()) {
- if (await checkRemoteSession()) return
- // Synthetic local users can't mint Firebase ID tokens — stay offline.
- if (user.uid === "desktop-local") return
- const idToken = await user.getIdToken()
- await desktopEstablishSession(idToken)
- return
- }
- const ok = await dedupe("session-check", async () => {
- let check = await fetch("/api/backend/auth/session/check", {
- method: "GET",
- credentials: "include",
- cache: "no-store",
- })
- if (check.ok) return true
- if (check.status >= 500) {
- await sleep(200)
- check = await fetch("/api/backend/auth/session/check", {
- method: "GET",
- credentials: "include",
- cache: "no-store",
- })
- if (check.ok) return true
- }
- return false
- })
- if (ok) return
-
- const idToken = await user.getIdToken()
- await establishBackendSession(idToken, {
- maxAttempts: 3,
- getFreshIdToken: () => user.getIdToken(true),
- checkRevoked: false,
- })
-}
-
-export async function logoutBackendSession(): Promise {
- if (isDesktop()) {
- await desktopSignOut()
- return
- }
- await fetch("/api/backend/auth/logout", {
- method: "POST",
- credentials: "include",
- cache: "no-store",
- })
-}
-
-/**
- * Dispatch a force-logout event without importing from logout-user (avoids circular dep).
- * The AuthLogoutListener picks this up and redirects to /login.
- */
-function forceLogout(reason: "session-expired" | "unauthorized"): void {
- if (typeof window === "undefined") return
- window.dispatchEvent(
- new CustomEvent("mydevtools:force-logout", { detail: { reason } })
- )
-}
-
-// ── Shared proxy helper (used by feature API libs) ─────────────────────────
-
-export type ProxyResponse = {
- status: number
- statusText: string
- headers: Record
- body: string
- isBase64?: boolean
- time: number
- size: number
- error?: string
-}
-
-/**
- * Parse a `/api/proxy` Response into a ProxyResponse envelope.
- * Throws a clear error when the proxy itself failed (e.g. dev pipe-lock 500
- * with empty body), instead of letting `Response.json()` blow up with
- * "Unexpected end of JSON input" and surface as unhandledRejection.
- */
-export async function parseProxyResponse(res: Response): Promise {
- const text = await res.text().catch(() => "")
- // Proxy non-OK (Next route 4xx/5xx itself, not the wrapped upstream). Surface body when present.
- if (!res.ok) {
- const snippet = text ? ` body=${text.slice(0, 200)}` : ""
- throw new Error(`Proxy request failed: ${res.status} ${res.statusText || ""}${snippet}`.trim())
- }
- try {
- return JSON.parse(text) as ProxyResponse
- } catch {
- throw new Error(`Proxy returned non-JSON response (status ${res.status})`)
- }
-}
-
-async function rawProxyJson(
- backendBaseUrl: string,
- method: string,
- path: string,
- body?: unknown
-): Promise<{ status: number; data: T | null }> {
- const url = new URL(path, backendBaseUrl).toString()
- const headersObj: Record = {}
- const proxyBody = body !== undefined ? JSON.stringify(body) : undefined
- if (proxyBody !== undefined && method !== "GET" && method !== "HEAD") {
- headersObj["Content-Type"] = "application/json"
- }
- const proxyRes = await fetch("/api/proxy", {
- method: "POST",
- credentials: "include",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ url, method, headers: headersObj, body: proxyBody }),
- })
- const proxyData = await parseProxyResponse(proxyRes)
- if (typeof proxyData.status !== "number") {
- throw new Error(
- `Proxy returned malformed envelope (no status): ${JSON.stringify(proxyData).slice(0, 200)}`
- )
- }
- if (!proxyData.body) return { status: proxyData.status, data: null }
- try {
- return { status: proxyData.status, data: JSON.parse(proxyData.body) as T }
- } catch {
- return { status: proxyData.status, data: proxyData.body as unknown as T }
- }
-}
-
-/**
- * Authenticated proxy call to the backend via `/api/proxy`.
- * On 401: tries token refresh, then Firebase session re-exchange.
- * On persistent 401/403: dispatches force-logout.
- */
-export async function proxyJsonAuthed(
- backendBaseUrl: string,
- method: string,
- path: string,
- body?: unknown
-): Promise<{ status: number; data: T | null }> {
- if (isDesktop()) {
- // Desktop: local store or remote bridge, decided per workspace/path.
- const res = await desktopDataFetch(
- method,
- path,
- body !== undefined ? JSON.stringify(body) : undefined
- )
- if (!res.body) return { status: res.status, data: null }
- try {
- return { status: res.status, data: JSON.parse(res.body) as T }
- } catch {
- return { status: res.status, data: res.body as unknown as T }
- }
- }
- let result = await rawProxyJson(backendBaseUrl, method, path, body)
-
- if (result.status === 401) {
- const refr = await dedupe("refresh", async () =>
- fetch(BACKEND_AUTH_REFRESH_PATH, {
- method: "POST",
- credentials: "include",
- cache: "no-store",
- })
- )
- if (refr.ok) {
- result = await rawProxyJson(backendBaseUrl, method, path, body)
- }
- }
-
- if (result.status === 401) {
- const u2 = auth.currentUser
- if (u2) {
- try {
- await ensureBackendSession(u2)
- } catch {
- // Silent re-exchange failed — fall through to forceLogout below.
- }
- }
- result = await rawProxyJson(backendBaseUrl, method, path, body)
- }
-
- if (result.status === 401 || result.status === 403) {
- forceLogout(result.status === 403 ? "unauthorized" : "session-expired")
- }
-
- return result
-}
-
-// ── /api/backend/... fetch helper ──────────────────────────────────────────
-
-/**
- * Same-origin fetch to `/api/backend/...` with cookies; refreshes access token on 401 once.
- * Triggers a force-logout if the session cannot be recovered (persistent 401 or 403).
- */
-export async function backendFetch(path: string, init?: RequestInit): Promise {
- if (isDesktop()) {
- const method = (init?.method || "GET").toUpperCase()
- const body = typeof init?.body === "string" ? init.body : undefined
- const res = await desktopDataFetch(method, normalizeBackendPath(path), body)
- return toResponse(res)
- }
- const run = () =>
- fetch(path, {
- ...init,
- credentials: "include",
- cache: "no-store",
- })
-
- let res = await run()
-
- if (res.status === 401) {
- const refr = await dedupe("refresh", async () =>
- fetch(BACKEND_AUTH_REFRESH_PATH, {
- method: "POST",
- credentials: "include",
- cache: "no-store",
- })
- )
- if (refr.ok) {
- res = await run()
- if (res.status === 401 || res.status === 403) {
- // Refresh succeeded but still getting 401/403 — session is truly invalid.
- forceLogout("unauthorized")
- }
- } else {
- // Refresh endpoint itself rejected — session has expired.
- forceLogout("session-expired")
- }
- } else if (res.status === 403) {
- forceLogout("unauthorized")
- }
-
- return res
-}
diff --git a/apps/desktop-ui/src/lib/desktop-handoff.ts b/apps/desktop-ui/src/lib/desktop-handoff.ts
deleted file mode 100644
index 6a2a9238..00000000
--- a/apps/desktop-ui/src/lib/desktop-handoff.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Web-side desktop sign-in handoff. Runs in the SYSTEM BROWSER (not the Tauri
- * webview) on the `/login?desktop=1&cb=` page: mints a short-lived
- * Firebase custom token for the current session and hands it back to the
- * desktop app via the loopback callback (or the mydevtools:// deep link).
- *
- * Called both after a fresh OAuth login and when the browser is already signed
- * in (so an existing session doesn't just bounce to /dashboard).
- */
-import { backendFetch } from "@/lib/backend-auth";
-
-/** True if the current URL is a desktop sign-in handoff. */
-export function isDesktopHandoff(search: string): boolean {
- return new URLSearchParams(search).get("desktop") === "1";
-}
-
-/**
- * Mint the desktop token and redirect to the app. Returns false if it couldn't
- * (caller decides what to do). On success it navigates away, so the promise
- * effectively never resolves in the happy path.
- */
-export async function handoffDesktopToken(search: string): Promise {
- const params = new URLSearchParams(search);
- if (params.get("desktop") !== "1") return false;
- try {
- const res = await backendFetch("/api/backend/auth/desktop-token", { method: "POST" });
- if (!res.ok) return false;
- const { token } = (await res.json()) as { token: string };
- const cb = params.get("cb");
- window.location.href = cb
- ? `http://127.0.0.1:${cb}/callback?token=${encodeURIComponent(token)}`
- : `mydevtools://auth?token=${encodeURIComponent(token)}`;
- return true;
- } catch {
- return false;
- }
-}
diff --git a/apps/desktop-ui/src/lib/desktop/activation.ts b/apps/desktop-ui/src/lib/desktop/activation.ts
deleted file mode 100644
index 9d78d79a..00000000
--- a/apps/desktop-ui/src/lib/desktop/activation.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * One-time desktop activation. The record is an account snapshot captured right
- * after browser sign-in, stored in the local Rust kv store — its presence is
- * what unlocks the app. Fully offline after that; no periodic recheck.
- */
-import { localApi } from "./bridge";
-import { remoteApiAuthed } from "./remote";
-
-export type ActivationRecord = {
- uid: string;
- email: string | null;
- display_name: string | null;
- created_at: number | null;
- activated_at?: number;
-};
-
-export async function getActivation(): Promise {
- const r = await localApi("GET", "/desktop/activation");
- return r.status === 200 ? (JSON.parse(r.body) as ActivationRecord) : null;
-}
-
-/** Fetch the signed-in profile and persist the activation record locally. */
-export async function completeActivation(): Promise {
- const res = await remoteApiAuthed("GET", "/api/v1/auth/me");
- if (res.status !== 200) {
- throw new Error(`Could not load your account (${res.status})`);
- }
- const me = JSON.parse(res.body) as ActivationRecord & Record;
- const record: ActivationRecord = {
- uid: me.uid,
- email: me.email ?? null,
- display_name: me.display_name ?? null,
- created_at: me.created_at ?? null,
- };
- const saved = await localApi("POST", "/desktop/activation", JSON.stringify(record));
- if (saved.status !== 200) {
- throw new Error(`Could not save activation (${saved.status})`);
- }
- return JSON.parse(saved.body) as ActivationRecord;
-}
-
-export async function resetActivation(): Promise {
- await localApi("DELETE", "/desktop/activation");
-}
diff --git a/apps/desktop-ui/src/lib/desktop/cloud-signin.ts b/apps/desktop-ui/src/lib/desktop/cloud-signin.ts
deleted file mode 100644
index 48c466e4..00000000
--- a/apps/desktop-ui/src/lib/desktop/cloud-signin.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Desktop cloud sign-in. OAuth popups don't work in WKWebView, so sign-in
- * happens in the system browser (web login page with ?desktop=1).
- *
- * Return leg = loopback server: the app binds an ephemeral localhost port and
- * the browser redirects the minted Firebase custom token to
- * http://127.0.0.1:/callback?token=... This works identically in
- * `tauri dev` and the packaged app (no mydevtools:// URL-scheme registration,
- * which macOS only routes to a bundled .app). The mydevtools:// deep link is
- * kept as a secondary path.
- */
-import { signInWithCustomToken } from "firebase/auth";
-
-import { auth } from "@/database/firebase";
-import { establishBackendSession } from "@/lib/backend-auth";
-import { checkRemoteSession, desktopWebBase } from "./remote";
-
-/** Exchange a Firebase custom token for a session and enter the workspace. */
-async function completeSignIn(token: string): Promise {
- const cred = await signInWithCustomToken(auth, token);
- const idToken = await cred.user.getIdToken();
- await establishBackendSession(idToken);
- await checkRemoteSession();
- // Refresh workspaces so remote orgs/workspaces appear in the switcher.
- const { useWorkspaceStore } = await import("@/store/workspace-store");
- await useWorkspaceStore.getState().loadFromBackend().catch(() => {});
- // Signal the app to enter the workspace (DesktopInit routes via Next so
- // static-export paths resolve correctly).
- if (typeof window !== "undefined") {
- window.dispatchEvent(new CustomEvent("mydevtools:desktop-authed"));
- }
-}
-
-/**
- * Start the browser sign-in: bind the loopback callback server, open the system
- * browser at the login page with the callback port, and finish when the token
- * arrives. Resolves after the session is established.
- */
-export async function startCloudSignIn(): Promise {
- const { invoke, Channel } = await import("@tauri-apps/api/core");
- const { openUrl } = await import("@tauri-apps/plugin-opener");
-
- const portChannel = new Channel<{ port: number }>();
- portChannel.onmessage = (msg) => {
- if (msg.port) {
- void openUrl(`${desktopWebBase()}/login?desktop=1&cb=${msg.port}`);
- }
- };
-
- const token = await invoke("await_browser_auth", { portChannel });
- await completeSignIn(token);
-}
-
-// ── Secondary path: mydevtools:// deep link (packaged app fallback) ──────────
-
-async function handleDeepLink(urls: string[]): Promise {
- for (const raw of urls) {
- let url: URL;
- try {
- url = new URL(raw);
- } catch {
- continue;
- }
- if (url.protocol !== "mydevtools:" || url.hostname !== "auth") continue;
- const token = url.searchParams.get("token");
- if (!token) continue;
- await completeSignIn(token);
- }
-}
-
-/** Listen for deep-link sign-in callbacks (and process a cold-start URL). */
-export async function initDeepLinkListener(): Promise {
- const { onOpenUrl, getCurrent } = await import("@tauri-apps/plugin-deep-link");
- await onOpenUrl((urls) => {
- void handleDeepLink(urls).catch((e) => console.error("Desktop sign-in failed:", e));
- });
- const initial = await getCurrent().catch(() => null);
- if (initial?.length) {
- void handleDeepLink(initial).catch((e) => console.error("Desktop sign-in failed:", e));
- }
-}
diff --git a/apps/desktop-ui/src/lib/desktop/remote.ts b/apps/desktop-ui/src/lib/desktop/remote.ts
deleted file mode 100644
index b5e9c635..00000000
--- a/apps/desktop-ui/src/lib/desktop/remote.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * Desktop remote bridge: calls FastAPI through the Rust `remote_api` command.
- * HttpOnly JWT cookies live in a Rust-side persistent jar (never the webview),
- * so the existing backend cookie flow works unchanged and without CORS.
- */
-import type { LocalApiResponse } from "./bridge";
-
-export const DESKTOP_BACKEND_BASE: string =
- process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL ||
- "http://localhost:8000";
-
-/**
- * Web origin the system browser opens for sign-in. Since the split, the web
- * app (which owns /login + the /api/backend proxy that mints the handoff token)
- * is a SEPARATE origin from this desktop-ui webview — so prefer the configured
- * site URL. In dev set NEXT_PUBLIC_SITE_URL=http://localhost:3000 (the web app);
- * only fall back to the webview origin if it's unset.
- */
-export function desktopWebBase(): string {
- const configured = process.env.NEXT_PUBLIC_SITE_URL;
- if (configured) return configured;
- if (typeof window !== "undefined") {
- const origin = window.location.origin;
- if (origin.startsWith("http://") || origin.startsWith("https://")) {
- return origin;
- }
- }
- return "https://mydevtools.tech";
-}
-
-export async function remoteApi(
- method: string,
- path: string,
- body?: string
-): Promise {
- const { invoke } = await import("@tauri-apps/api/core");
- const url = new URL(path, DESKTOP_BACKEND_BASE).toString();
- return invoke("remote_api", { method, url, body: body ?? null });
-}
-
-// ── Session state (module-level; survives route changes, not restarts —
-// restarts recover via the persisted Rust cookie jar + checkRemoteSession) ─
-
-let sessionOk = false;
-
-export function hasRemoteSession(): boolean {
- return sessionOk;
-}
-
-export function setRemoteSession(ok: boolean): void {
- if (sessionOk === ok) return;
- sessionOk = ok;
- if (typeof window !== "undefined") {
- window.dispatchEvent(new CustomEvent("mydevtools:desktop-session", { detail: { ok } }));
- }
-}
-
-/** Authenticated remote call with the web's 401→refresh→retry ladder. */
-export async function remoteApiAuthed(
- method: string,
- path: string,
- body?: string
-): Promise {
- let res = await remoteApi(method, path, body);
- if (res.status === 401) {
- const refr = await remoteApi("POST", "/api/v1/auth/refresh");
- if (refr.status >= 200 && refr.status < 300) {
- res = await remoteApi(method, path, body);
- }
- }
- if (res.status === 401 || res.status === 403) {
- setRemoteSession(false);
- }
- return res;
-}
-
-/** Probe the backend session (e.g. on startup, after network regain). */
-export async function checkRemoteSession(): Promise {
- try {
- const res = await remoteApiAuthed("GET", "/api/v1/auth/session/check");
- setRemoteSession(res.status >= 200 && res.status < 300);
- } catch {
- setRemoteSession(false);
- }
- return sessionOk;
-}
-
-/** Exchange a Firebase ID token for backend cookies (into the Rust jar). */
-export async function desktopEstablishSession(idToken: string): Promise {
- const res = await remoteApi(
- "POST",
- "/api/v1/auth/session",
- // long_lived → 60-day refresh cookie for the desktop app.
- JSON.stringify({ id_token: idToken, check_revoked: false, long_lived: true })
- );
- if (res.status < 200 || res.status >= 300) {
- setRemoteSession(false);
- throw new Error(`Desktop session exchange failed (${res.status})`);
- }
- setRemoteSession(true);
-}
-
-export async function desktopSignOut(): Promise {
- try {
- await remoteApi("POST", "/api/v1/auth/logout");
- } catch {
- // best-effort; jar is cleared regardless
- }
- const { invoke } = await import("@tauri-apps/api/core");
- await invoke("clear_remote_session");
- setRemoteSession(false);
-}
diff --git a/apps/desktop-ui/src/lib/desktop/router.ts b/apps/desktop-ui/src/lib/desktop/router.ts
deleted file mode 100644
index 3da4ad51..00000000
--- a/apps/desktop-ui/src/lib/desktop/router.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * Desktop data routing: decides per request whether a normalized
- * `/api/v1/...` path is served by the local SQLCipher store or the remote
- * FastAPI backend.
- *
- * Rules:
- * - the workspaces API merges the always-present local personal workspace
- * with remote orgs/workspaces when signed in;
- * - a non-local active workspace routes everything remote (shared/team
- * workspaces are cloud-only);
- * - everything else is local.
- */
-import { localApi, type LocalApiResponse } from "./bridge";
-import { hasRemoteSession, remoteApiAuthed } from "./remote";
-
-export const LOCAL_WORKSPACE_ID = "local-personal";
-
-const REMOTE_ONLY_PREFIXES: string[] = [];
-
-/** Live-DB routes run on native Rust drivers regardless of workspace. */
-const ALWAYS_LOCAL_PREFIXES = ["/api/sql-client/", "/api/nosql/", "/api/redis-commander/"];
-
-export function activeWorkspaceId(): string {
- if (typeof window === "undefined") return LOCAL_WORKSPACE_ID;
- try {
- const raw = window.localStorage.getItem("mdt-workspace-store");
- if (!raw) return LOCAL_WORKSPACE_ID;
- const parsed = JSON.parse(raw) as { state?: { activeWorkspaceId?: string | null } };
- return parsed.state?.activeWorkspaceId || LOCAL_WORKSPACE_ID;
- } catch {
- return LOCAL_WORKSPACE_ID;
- }
-}
-
-export function isRemoteWorkspaceActive(): boolean {
- return activeWorkspaceId() !== LOCAL_WORKSPACE_ID;
-}
-
-function parseArray(res: LocalApiResponse): unknown[] {
- try {
- const v = JSON.parse(res.body);
- return Array.isArray(v) ? v : [];
- } catch {
- return [];
- }
-}
-
-async function workspacesFetch(
- method: string,
- path: string,
- body?: string
-): Promise {
- const bare = path.split("?")[0];
- if (method === "GET" && (bare === "/api/v1/workspaces-api/orgs" || bare === "/api/v1/workspaces-api/workspaces")) {
- const local = await localApi(method, path, body);
- if (!hasRemoteSession()) return local;
- try {
- const remote = await remoteApiAuthed(method, path, body);
- if (remote.status >= 200 && remote.status < 300) {
- const merged = [...parseArray(local), ...parseArray(remote)];
- return { status: 200, body: JSON.stringify(merged) };
- }
- } catch {
- // offline / backend unreachable — local list is still valid
- }
- return local;
- }
- if (method === "POST" && bare === "/api/v1/workspaces-api/workspaces/active") {
- // Always record locally; propagate to the backend cookie when the target
- // is a remote workspace and we have a session.
- const local = await localApi(method, path, body);
- let workspaceId = "";
- try {
- workspaceId = (JSON.parse(body || "{}") as { workspace_id?: string }).workspace_id || "";
- } catch {
- /* ignore */
- }
- if (workspaceId && workspaceId !== LOCAL_WORKSPACE_ID && hasRemoteSession()) {
- try {
- return await remoteApiAuthed(method, path, body);
- } catch {
- return { status: 503, body: JSON.stringify({ detail: "Backend unreachable" }) };
- }
- }
- return local;
- }
- // Workspace detail / membership / crypto endpoints: local id → local, else remote.
- if (bare.includes(`/${LOCAL_WORKSPACE_ID}`) || !hasRemoteSession()) {
- return localApi(method, path, body);
- }
- return remoteApiAuthed(method, path, body);
-}
-
-/** Route a normalized `/api/v1/...` request (desktop only). */
-export async function desktopDataFetch(
- method: string,
- path: string,
- body?: string
-): Promise {
- if (ALWAYS_LOCAL_PREFIXES.some((p) => path.startsWith(p))) {
- return localApi(method, path, body);
- }
- if (REMOTE_ONLY_PREFIXES.some((p) => path.startsWith(p))) {
- if (!hasRemoteSession()) {
- return {
- status: 503,
- body: JSON.stringify({ detail: "This tool requires a network connection and cloud sign-in" }),
- };
- }
- return remoteApiAuthed(method, path, body);
- }
- if (path.startsWith("/api/v1/workspaces-api/")) {
- return workspacesFetch(method, path, body);
- }
- if (isRemoteWorkspaceActive() && hasRemoteSession()) {
- return remoteApiAuthed(method, path, body);
- }
- const res = await localApi(method, path, body);
- if (method !== "GET" && res.status < 300 && path.startsWith("/api/v1/")) {
- // Nudge the sync engine (debounced) after successful local writes.
- window.dispatchEvent(new CustomEvent("mydevtools:desktop-data-mutated"));
- }
- return res;
-}
diff --git a/apps/desktop-ui/src/lib/global-vault-api.ts b/apps/desktop-ui/src/lib/global-vault-api.ts
index ff2404e0..8d89ec3f 100644
--- a/apps/desktop-ui/src/lib/global-vault-api.ts
+++ b/apps/desktop-ui/src/lib/global-vault-api.ts
@@ -10,7 +10,7 @@
* stored on the server.
*/
-import { backendFetch } from "./backend-auth"
+import { apiFetch } from "./desktop/api-fetch"
export type KeyVerifier = {
encrypted: string
@@ -33,7 +33,7 @@ export type MasterVaultSetupRequest = {
* their master password.
*/
export async function getMasterVaultOrNull(): Promise {
- const res = await backendFetch("/api/backend/auth/master-vault")
+ const res = await apiFetch("/api/backend/auth/master-vault")
if (res.status === 404) return null
if (!res.ok) {
throw new Error(`Failed to fetch master vault (${res.status})`)
@@ -49,7 +49,7 @@ export async function getMasterVaultOrNull(): Promise {
export async function setupMasterVault(
body: MasterVaultSetupRequest
): Promise {
- const res = await backendFetch("/api/backend/auth/master-vault", {
+ const res = await apiFetch("/api/backend/auth/master-vault", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -78,7 +78,7 @@ export type BackupCodeDataOut = {
}
export async function storeBackupCodes(codes: BackupCodeEntry[]): Promise {
- const res = await backendFetch("/api/backend/auth/backup-codes", {
+ const res = await apiFetch("/api/backend/auth/backup-codes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codes }),
@@ -89,7 +89,7 @@ export async function storeBackupCodes(codes: BackupCodeEntry[]): Promise
}
export async function lookupBackupCode(codeId: string): Promise {
- const res = await backendFetch("/api/backend/auth/backup-codes/lookup", {
+ const res = await apiFetch("/api/backend/auth/backup-codes/lookup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeId }),
@@ -100,7 +100,7 @@ export async function lookupBackupCode(codeId: string): Promise {
- await backendFetch("/api/backend/auth/backup-codes/use", {
+ await apiFetch("/api/backend/auth/backup-codes/use", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeId }),
diff --git a/apps/desktop-ui/src/lib/logout-user.ts b/apps/desktop-ui/src/lib/logout-user.ts
deleted file mode 100644
index b95f5e96..00000000
--- a/apps/desktop-ui/src/lib/logout-user.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { signOut as firebaseSignOut } from "firebase/auth"
-import { auth } from "@/database/firebase"
-import { logoutBackendSession } from "@/lib/backend-auth"
-import { usePasswordStore } from "@/store/password-store"
-import { useMasterKeyStore } from "@/store/master-key-store"
-import { useUserKeypairStore } from "@/store/user-keypair-store"
-import { clearKey as clearVaultKey, clearMasterKey } from "@/lib/key-storage"
-
-export const FORCE_LOGOUT_EVENT = "mydevtools:force-logout"
-
-export type LogoutReason =
- | "manual"
- | "session-expired"
- | "session-sync-failed"
- | "unauthorized"
-
-export async function clearSensitiveClientState(): Promise {
- usePasswordStore.getState().clearPasswords()
- useMasterKeyStore.getState().clearKey()
- useUserKeypairStore.getState().clear()
-
- // Wipe localStorage so no user data is left behind on a forced/expired logout
- // (e.g. a refresh 401). Best-effort — never block logout on a storage failure.
- if (typeof window !== "undefined") {
- try {
- window.localStorage.clear()
- } catch {
- // ignore (private mode / storage disabled)
- }
- }
-
- // Clear persisted CryptoKeys (best-effort)
- await Promise.allSettled([clearVaultKey(), clearMasterKey()])
-}
-
-export async function logoutUser(reason: LogoutReason = "manual"): Promise {
- // Best-effort cleanup; don't block on individual failures.
- await Promise.allSettled([
- clearSensitiveClientState(),
- logoutBackendSession(),
- firebaseSignOut(auth),
- ])
-
- // Optional: broadcast (useful if multiple tabs are open)
- if (typeof window !== "undefined") {
- window.dispatchEvent(new CustomEvent(FORCE_LOGOUT_EVENT, { detail: { reason } }))
- }
-}
-
-export function requestLogout(reason: LogoutReason): void {
- if (typeof window === "undefined") return
- window.dispatchEvent(new CustomEvent(FORCE_LOGOUT_EVENT, { detail: { reason } }))
-}
-
diff --git a/apps/desktop-ui/src/lib/onboarding-api.ts b/apps/desktop-ui/src/lib/onboarding-api.ts
deleted file mode 100644
index 19faa8b0..00000000
--- a/apps/desktop-ui/src/lib/onboarding-api.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { backendFetch } from "@/lib/backend-auth"
-
-export interface UserProfileOut {
- uid: string
- onboarding_completed: boolean
-}
-
-export async function getMe(): Promise {
- const res = await backendFetch("/api/backend/auth/me")
- if (!res.ok) throw new Error("Failed to fetch user profile")
- return res.json() as Promise
-}
-
-export async function completeOnboarding(): Promise {
- const res = await backendFetch("/api/backend/auth/onboarding/complete", {
- method: "POST",
- })
- if (!res.ok) throw new Error("Failed to complete onboarding")
-}
-
-/** Persist the role picked during onboarding on the user profile. */
-export async function savePersona(persona: string): Promise {
- const res = await backendFetch("/api/backend/auth/profile", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ persona }),
- })
- if (!res.ok) throw new Error("Failed to save persona")
-}
diff --git a/apps/desktop-ui/src/lib/passkey.ts b/apps/desktop-ui/src/lib/passkey.ts
deleted file mode 100644
index c192080a..00000000
--- a/apps/desktop-ui/src/lib/passkey.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import {
- browserSupportsWebAuthn,
- browserSupportsWebAuthnAutofill,
- startAuthentication,
- startRegistration,
-} from "@simplewebauthn/browser"
-import { signInWithCustomToken } from "firebase/auth"
-
-import { auth } from "@/database/firebase"
-import { backendFetch } from "@/lib/backend-auth"
-
-export type PasskeySummary = {
- credential_id: string
- device_name: string | null
- backed_up: boolean
- transports: string[]
- created_at: number | null
- last_used_at: number | null
-}
-
-const BASE = "/api/backend/auth/passkey"
-
-async function jsonOrThrow(res: Response, fallback: string): Promise {
- if (res.ok) return (await res.json()) as T
- let detail = ""
- try {
- const data = (await res.json()) as { detail?: string }
- detail = typeof data?.detail === "string" ? data.detail : ""
- } catch {
- /* ignore */
- }
- throw new Error(detail || fallback)
-}
-
-// ── Registration (current user) ──────────────────────────────────────────────
-
-export async function registerPasskey(deviceName?: string): Promise {
- if (!browserSupportsWebAuthn()) {
- throw new Error("Your browser does not support passkeys.")
- }
- const beginRes = await backendFetch(`${BASE}/register/begin`, { method: "POST" })
- const optionsJSON = await jsonOrThrow>(
- beginRes,
- "Could not start passkey registration."
- )
-
- const attResp = await startRegistration({ optionsJSON: optionsJSON as never })
-
- const finishRes = await backendFetch(`${BASE}/register/finish`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ credential: attResp, device_name: deviceName ?? null }),
- })
- return jsonOrThrow(finishRes, "Passkey registration failed.")
-}
-
-// ── List / rename / delete ───────────────────────────────────────────────────
-
-export async function listPasskeys(): Promise {
- const res = await backendFetch(BASE, { method: "GET" })
- const data = await jsonOrThrow<{ passkeys: PasskeySummary[] }>(
- res,
- "Could not load passkeys."
- )
- return data.passkeys ?? []
-}
-
-export async function renamePasskey(credentialId: string, deviceName: string): Promise {
- const res = await backendFetch(`${BASE}/${encodeURIComponent(credentialId)}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ device_name: deviceName }),
- })
- await jsonOrThrow<{ ok: boolean }>(res, "Could not rename passkey.")
-}
-
-export async function deletePasskey(credentialId: string): Promise {
- const res = await backendFetch(`${BASE}/${encodeURIComponent(credentialId)}`, {
- method: "DELETE",
- })
- await jsonOrThrow<{ ok: boolean }>(res, "Could not delete passkey.")
-}
-
-// ── Login (anonymous) ────────────────────────────────────────────────────────
-
-async function runLogin(useBrowserAutofill: boolean): Promise<{ uid: string }> {
- const beginRes = await fetch(`${BASE}/login/begin`, {
- method: "POST",
- credentials: "include",
- cache: "no-store",
- })
- const optionsJSON = await jsonOrThrow>(
- beginRes,
- "Could not start passkey sign-in."
- )
-
- const asr = await startAuthentication({
- optionsJSON: optionsJSON as never,
- useBrowserAutofill,
- })
-
- const finishRes = await fetch(`${BASE}/login/finish`, {
- method: "POST",
- credentials: "include",
- cache: "no-store",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ credential: asr }),
- })
- const result = await jsonOrThrow<{ uid: string; firebase_custom_token: string | null }>(
- finishRes,
- "Passkey sign-in failed."
- )
-
- // Hydrate Firebase auth so RequireAuth (which gates on Firebase user) lets us through.
- if (result.firebase_custom_token) {
- await signInWithCustomToken(auth, result.firebase_custom_token)
- }
- return { uid: result.uid }
-}
-
-/** Explicit button click — modal passkey picker. */
-export async function signInWithPasskey(): Promise<{ uid: string }> {
- if (!browserSupportsWebAuthn()) {
- throw new Error("Your browser does not support passkeys.")
- }
- return runLogin(false)
-}
-
-/**
- * Conditional autofill: resolves when the user picks a passkey from the
- * browser's username-autofill dropdown. Caller must render an `` for autofill to surface. Returns null
- * when the platform does not support conditional mediation (caller should
- * fall back to an explicit button).
- */
-export async function startConditionalPasskeyAuth(): Promise<{ uid: string } | null> {
- if (!browserSupportsWebAuthn()) return null
- if (!(await browserSupportsWebAuthnAutofill())) return null
- return runLogin(true)
-}
diff --git a/apps/desktop-ui/src/lib/require-backend-session.ts b/apps/desktop-ui/src/lib/require-backend-session.ts
deleted file mode 100644
index 7c834571..00000000
--- a/apps/desktop-ui/src/lib/require-backend-session.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { NextResponse } from "next/server"
-import { createHash } from "crypto"
-
-const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL
-
-const SESSION_CACHE_TTL_MS = Number(process.env.AUTH_SESSION_CACHE_TTL_MS ?? 30_000)
-const SESSION_CACHE_MAX = 5_000
-
-type CacheEntry = { expiresAt: number }
-const sessionCache = new Map()
-const inflight = new Map>()
-
-function tokenKey(cookie: string | null, authorization: string | null): string | null {
- if (!cookie && !authorization) return null
- return createHash("sha256")
- .update(`${cookie ?? ""}${authorization ?? ""}`)
- .digest("hex")
-}
-
-function getCached(key: string): boolean {
- const entry = sessionCache.get(key)
- if (!entry) return false
- if (entry.expiresAt <= Date.now()) {
- sessionCache.delete(key)
- return false
- }
- return true
-}
-
-function setCached(key: string): void {
- if (sessionCache.size >= SESSION_CACHE_MAX) {
- const firstKey = sessionCache.keys().next().value
- if (firstKey) sessionCache.delete(firstKey)
- }
- sessionCache.set(key, { expiresAt: Date.now() + SESSION_CACHE_TTL_MS })
-}
-
-/**
- * Ensures the request has a valid backend JWT session (cookie or Bearer).
- * Returns null if OK, or a NextResponse to return from the route handler.
- *
- * Successful checks are cached per-instance for SESSION_CACHE_TTL_MS to avoid
- * a round-trip on every BFF API call. Revoked tokens remain accepted up to
- * the TTL window; tune via AUTH_SESSION_CACHE_TTL_MS (default 30s).
- */
-export async function requireBackendSession(request: Request): Promise {
- if (!FASTAPI_BASE_URL) {
- return NextResponse.json(
- { error: "NEXT_PUBLIC_FASTAPI_BASE_URL is not configured" },
- { status: 500 }
- )
- }
-
- const cookie = request.headers.get("cookie")
- const authorization = request.headers.get("authorization")
- const key = tokenKey(cookie, authorization)
-
- if (key && getCached(key)) return null
-
- if (key) {
- const pending = inflight.get(key)
- if (pending) return pending
- }
-
- const headers: Record = {}
- if (cookie) headers.cookie = cookie
- if (authorization) headers.authorization = authorization
-
- const task = (async (): Promise => {
- try {
- const checkRes = await fetch(`${FASTAPI_BASE_URL}/api/v1/auth/session/check`, {
- method: "GET",
- headers,
- cache: "no-store",
- })
-
- if (checkRes.ok) {
- if (key) setCached(key)
- return null
- }
- if (checkRes.status === 401 || checkRes.status === 403) {
- if (key) sessionCache.delete(key)
- return NextResponse.json({ error: "Unauthorized" }, { status: checkRes.status })
- }
- return NextResponse.json({ error: "Auth check failed" }, { status: 502 })
- } catch {
- return NextResponse.json({ error: "Failed to verify auth session" }, { status: 502 })
- }
- })()
-
- if (key) {
- inflight.set(key, task)
- try {
- return await task
- } finally {
- inflight.delete(key)
- }
- }
- return task
-}
diff --git a/apps/desktop-ui/src/lib/telemetry.ts b/apps/desktop-ui/src/lib/telemetry.ts
new file mode 100644
index 00000000..b23f530b
--- /dev/null
+++ b/apps/desktop-ui/src/lib/telemetry.ts
@@ -0,0 +1,116 @@
+"use client"
+
+/**
+ * Anonymous, opt-in usage telemetry (Aptabase ingest).
+ *
+ * The app has no accounts, so there is no user to attribute anything to — and
+ * we keep it that way. What leaves the device is a rotating session id, the app
+ * version, the locale, and an event name. No device id, no IP-derived identity,
+ * no cookies, nothing typed into a tool. Off until the user turns it on.
+ *
+ * Talks to Aptabase's `/api/v0/event` endpoint directly rather than through
+ * `@aptabase/web`: the payload is a dozen lines, and a privacy-first app that
+ * ships fewer third-party packages is easier to audit than one that ships more.
+ */
+
+const CONSENT_KEY = "telemetry-consent"
+const SDK_VERSION = "mydevtools-inline@1"
+/** Aptabase rotates the session after an hour of silence; match that. */
+const SESSION_IDLE_MS = 60 * 60 * 1000
+
+const APP_KEY = process.env.NEXT_PUBLIC_APTABASE_KEY ?? ""
+/** Only needed for self-hosted (`A-SH-…`) keys. */
+const SELF_HOST = process.env.NEXT_PUBLIC_APTABASE_HOST ?? ""
+
+export type Consent = "granted" | "denied" | "unset"
+
+export function getConsent(): Consent {
+ if (typeof window === "undefined") return "unset"
+ const raw = window.localStorage.getItem(CONSENT_KEY)
+ return raw === "granted" || raw === "denied" ? raw : "unset"
+}
+
+export function setConsent(next: "granted" | "denied"): void {
+ window.localStorage.setItem(CONSENT_KEY, next)
+ window.dispatchEvent(new Event(CONSENT_KEY))
+}
+
+/**
+ * Region lives in the app key (`A-EU-1234567890`). An unparseable key means no
+ * endpoint, which means `track` stays a no-op — that is how the app behaves in
+ * dev and in any build that ships without a key.
+ */
+export function ingestUrl(appKey: string, selfHost = ""): string | null {
+ const parts = appKey.split("-")
+ if (parts.length !== 3) return null
+ const region = parts[1]
+ if (region === "SH") return selfHost ? `${selfHost}/api/v0/event` : null
+ const host = region === "EU" ? "https://eu.aptabase.com" : region === "US" ? "https://us.aptabase.com" : null
+ return host ? `${host}/api/v0/event` : null
+}
+
+let sessionId = ""
+let lastSeen = 0
+
+export function sessionIdFor(now: number, random = Math.random()): string {
+ if (!sessionId || now - lastSeen > SESSION_IDLE_MS) {
+ const rand = Math.floor(random * 1e8).toString().padStart(8, "0")
+ sessionId = `${Math.floor(now / 1000)}${rand}`
+ }
+ lastSeen = now
+ return sessionId
+}
+
+let appVersion = ""
+
+/** Called once at shell mount so events can carry the running version. */
+export async function initTelemetry(): Promise {
+ const { isDesktop } = await import("@/lib/desktop/is-desktop")
+ if (!isDesktop()) return
+ try {
+ const { currentAppVersion } = await import("@/lib/desktop/updater")
+ appVersion = await currentAppVersion()
+ } catch {
+ // Version API unavailable — send events without it rather than not at all.
+ }
+ track("app_started")
+}
+
+export type EventProps = Record
+
+export function buildEvent(eventName: string, props: EventProps | undefined, now: number) {
+ return {
+ timestamp: new Date(now).toISOString(),
+ sessionId: sessionIdFor(now),
+ eventName,
+ systemProps: {
+ locale: typeof navigator === "undefined" ? "" : navigator.language,
+ isDebug: process.env.NODE_ENV !== "production",
+ appVersion,
+ sdkVersion: SDK_VERSION,
+ },
+ props,
+ }
+}
+
+/**
+ * Fire-and-forget. Never throws, never blocks the caller, and drops the event
+ * outright when offline — usage counts are directional, and a retry queue would
+ * mean persisting user behaviour to disk to protect a metric.
+ *
+ * ponytail: no offline queue. Add one only if the offline gap distorts numbers.
+ */
+export function track(eventName: string, props?: EventProps): void {
+ if (getConsent() !== "granted") return
+ const url = ingestUrl(APP_KEY, SELF_HOST)
+ if (!url) return
+
+ void fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "App-Key": APP_KEY },
+ credentials: "omit",
+ body: JSON.stringify(buildEvent(eventName, props, Date.now())),
+ }).catch(() => {
+ // Offline or endpoint down. Telemetry must never surface to the user.
+ })
+}
diff --git a/apps/desktop-ui/src/lib/tool-config.ts b/apps/desktop-ui/src/lib/tool-config.ts
index 1c863710..cba36c03 100644
--- a/apps/desktop-ui/src/lib/tool-config.ts
+++ b/apps/desktop-ui/src/lib/tool-config.ts
@@ -1,21 +1,8 @@
/**
- * Centralized configuration for all tools
- * Single source of truth for authentication requirements and tool metadata
+ * Centralized configuration for all tools.
+ * Single source of truth for tool metadata.
*/
-export const AUTH_REQUIRED_URLS = [
- '/app/to-do',
- '/app/s3-drive',
-] as const;
-
-/**
- * Check if a URL requires authentication
- */
-export function requiresAuth(url: string): boolean {
- // Check exact matches
- return AUTH_REQUIRED_URLS.some(authUrl => authUrl === url);
-}
-
/**
* Tool metadata for enhanced discovery and search
*/
@@ -26,14 +13,6 @@ export interface ToolMetadata {
tags: string[];
category: string;
keywords: string[];
- requiresAuth: boolean;
featured?: boolean;
badge?: string;
}
-
-/**
- * Get all auth-required URLs as a readonly array
- */
-export function getAuthRequiredUrls(): readonly string[] {
- return AUTH_REQUIRED_URLS;
-}
diff --git a/apps/desktop-ui/src/lib/tools-registry.ts b/apps/desktop-ui/src/lib/tools-registry.ts
index ade7a9fc..3602d541 100644
--- a/apps/desktop-ui/src/lib/tools-registry.ts
+++ b/apps/desktop-ui/src/lib/tools-registry.ts
@@ -4,7 +4,6 @@
import { sidebarData } from '@/components/sidebar/data/sidebar-data';
import { ToolMetadata } from './tool-config';
-import { requiresAuth } from './tool-config';
/**
* Extract enhanced metadata from sidebar data
@@ -25,7 +24,6 @@ export function getAllToolsMetadata(): ToolMetadata[] {
tags: extractTags(item.title, item.description || '', group.title),
category: group.title,
keywords: extractKeywords(item.title, item.description || ''),
- requiresAuth: requiresAuth(url),
badge: item.badge,
featured: false,
});
@@ -42,8 +40,7 @@ export function getAllToolsMetadata(): ToolMetadata[] {
tags: extractTags(subItem.title, subItem.description || '', item.title, group.title),
category: `${group.title} > ${item.title}`,
keywords: extractKeywords(subItem.title, subItem.description || ''),
- requiresAuth: requiresAuth(url),
- badge: subItem.badge,
+ badge: subItem.badge,
featured: false,
});
}
diff --git a/apps/desktop-ui/src/lib/user-keypair-api.ts b/apps/desktop-ui/src/lib/user-keypair-api.ts
index 83b10bf4..a1cadded 100644
--- a/apps/desktop-ui/src/lib/user-keypair-api.ts
+++ b/apps/desktop-ui/src/lib/user-keypair-api.ts
@@ -1,4 +1,4 @@
-import { backendFetch } from "./backend-auth"
+import { apiFetch } from "./desktop/api-fetch"
export type KeypairBlob = {
publicKey: string
@@ -10,7 +10,7 @@ export type KeypairBlob = {
const BASE = "/api/backend/workspaces-api"
export async function getKeypair(): Promise {
- const res = await backendFetch(`${BASE}/users/me/keypair`)
+ const res = await apiFetch(`${BASE}/users/me/keypair`)
if (res.status === 404) return null
if (!res.ok) throw new Error(`getKeypair failed (${res.status})`)
const body = await res.json()
@@ -18,7 +18,7 @@ export async function getKeypair(): Promise {
}
export async function setKeypair(blob: KeypairBlob): Promise {
- const res = await backendFetch(`${BASE}/users/me/keypair`, {
+ const res = await apiFetch(`${BASE}/users/me/keypair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(blob),
diff --git a/apps/desktop-ui/src/lib/user-preferences-api.ts b/apps/desktop-ui/src/lib/user-preferences-api.ts
index 83e234d8..fad3c0d7 100644
--- a/apps/desktop-ui/src/lib/user-preferences-api.ts
+++ b/apps/desktop-ui/src/lib/user-preferences-api.ts
@@ -13,6 +13,12 @@ export type UserPreferencesOut = {
theme: ThemePreference
accentColor: string
locale: string
+ /** Local profile — what the app calls you. No account behind it. */
+ displayName?: string | null
+ avatar?: string | null
+ /** First-run walkthrough state. */
+ onboardingCompleted?: boolean
+ persona?: string | null
enabledTools: string[]
toolFavorites: string[]
/** Keyed pinned-tools map added in T24. Present on all responses after backend migration. */
@@ -26,6 +32,10 @@ export type UserPreferencesPatch = {
theme?: ThemePreference
accentColor?: string
locale?: string
+ displayName?: string | null
+ avatar?: string | null
+ onboardingCompleted?: boolean
+ persona?: string | null
enabledTools?: string[]
/** Legacy flat array — only accepted by backend for one-release compat. */
toolFavorites?: string[]
diff --git a/apps/desktop-ui/src/lib/workspace-api.ts b/apps/desktop-ui/src/lib/workspace-api.ts
index 3156a185..8be53692 100644
--- a/apps/desktop-ui/src/lib/workspace-api.ts
+++ b/apps/desktop-ui/src/lib/workspace-api.ts
@@ -1,5 +1,5 @@
// apps/web/src/lib/workspace-api.ts
-import { backendFetch } from "./backend-auth"
+import { apiFetch } from "./desktop/api-fetch"
export type WorkspaceEncryption = {
scheme: string
@@ -24,19 +24,19 @@ export type Workspace = {
const BASE = "/api/backend/workspaces-api"
export async function listWorkspaces(): Promise {
- const res = await backendFetch(`${BASE}/workspaces`)
+ const res = await apiFetch(`${BASE}/workspaces`)
if (!res.ok) throw new Error(`listWorkspaces failed (${res.status})`)
return res.json()
}
export async function getWorkspace(id: string): Promise {
- const res = await backendFetch(`${BASE}/workspaces/${encodeURIComponent(id)}`)
+ const res = await apiFetch(`${BASE}/workspaces/${encodeURIComponent(id)}`)
if (!res.ok) throw new Error(`getWorkspace failed (${res.status})`)
return res.json()
}
export async function setActiveWorkspace(id: string): Promise {
- const res = await backendFetch(`${BASE}/workspaces/active`, {
+ const res = await apiFetch(`${BASE}/workspaces/active`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace_id: id }),
diff --git a/apps/desktop-ui/src/store/bookmark-store.ts b/apps/desktop-ui/src/store/bookmark-store.ts
index a8494c1f..89053bd5 100644
--- a/apps/desktop-ui/src/store/bookmark-store.ts
+++ b/apps/desktop-ui/src/store/bookmark-store.ts
@@ -1,13 +1,7 @@
import { useDeferredValue, useMemo } from "react"
import { create } from "zustand"
import { persist } from "zustand/middleware"
-import { auth } from "@/database/firebase"
-import { proxyJsonAuthed } from "@/lib/backend-auth"
-
-const BACKEND_BASE_URL: string =
- process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL ||
- "http://localhost:8000"
+import { apiRequestRaw } from "@/lib/backend-api"
export interface Bookmark {
id: string
@@ -103,10 +97,7 @@ const proxyRequest = async (
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/utils/useAuth.tsx b/apps/desktop-ui/src/utils/useAuth.tsx
index 3d1488c5..c5f83cf6 100644
--- a/apps/desktop-ui/src/utils/useAuth.tsx
+++ b/apps/desktop-ui/src/utils/useAuth.tsx
@@ -1,36 +1,39 @@
"use client";
-import { useEffect, useState } from "react";
-import { auth } from "../database/firebase";
-import { User } from "firebase/auth";
-// Export the return type
+/**
+ * Local identity. There are no accounts and no sign-in — the app is offline and
+ * single-user, so this resolves synchronously to one fixed user. `uid` is the
+ * scoping key for per-user local storage keys, `created_by` fields and query
+ * keys; keeping it constant means those keep working without a server.
+ *
+ * The display name and avatar are user-editable and live in local preferences
+ * (see `useAppUser`), not here — `useAuth` is only about identity.
+ */
+
+export const LOCAL_UID = "local";
+
+export interface LocalUser {
+ uid: typeof LOCAL_UID;
+ displayName: string | null;
+ email: null;
+ photoURL: string | null;
+}
+
export interface AuthState {
- user: User | null;
- loading: boolean;
+ user: LocalUser;
+ loading: false;
}
-/**
- * Desktop: the one-time activation record is the real gate (see RequireAuth /
- * DesktopInit). Firebase auth state is informational only — the user persists
- * locally from the activation sign-in — so `requireAuth` never redirects; the
- * app must keep working fully offline.
- */
-const useAuth = (_requireAuth: boolean = false): AuthState => {
- // auth.currentUser is synchronously available once Firebase has resolved auth
- // state. On client-side navigation within the app it is already populated, so
- // we avoid a spurious full-screen loading flash on every route change.
- const [user, setUser] = useState(() => auth.currentUser);
- const [loading, setLoading] = useState(() => auth.currentUser === null);
-
- useEffect(() => {
- const unsubscribe = auth.onAuthStateChanged((firebaseUser) => {
- setUser(firebaseUser);
- setLoading(false);
- });
- return () => unsubscribe();
- }, []);
-
- return { user, loading };
+const LOCAL_USER: LocalUser = {
+ uid: LOCAL_UID,
+ displayName: null,
+ email: null,
+ photoURL: null,
};
+const STATE: AuthState = { user: LOCAL_USER, loading: false };
+
+/** @param _requireAuth ignored — there is nothing to require. */
+const useAuth = (_requireAuth: boolean = false): AuthState => STATE;
+
export default useAuth;
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 91f9db5c..250af240 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@mydevtools/desktop",
- "version": "0.1.12",
+ "version": "0.1.13",
"private": true,
"scripts": {
"dev": "tauri dev",
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 9df2706d..f204b476 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -667,47 +667,10 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
- "percent-encoding",
"time",
"version_check",
]
-[[package]]
-name = "cookie_store"
-version = "0.21.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
-dependencies = [
- "cookie",
- "document-features",
- "idna",
- "log",
- "publicsuffix",
- "serde",
- "serde_derive",
- "serde_json",
- "time",
- "url",
-]
-
-[[package]]
-name = "cookie_store"
-version = "0.22.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
-dependencies = [
- "cookie",
- "document-features",
- "idna",
- "log",
- "publicsuffix",
- "serde",
- "serde_derive",
- "serde_json",
- "time",
- "url",
-]
-
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1116,24 +1079,6 @@ dependencies = [
"syn 2.0.118",
]
-[[package]]
-name = "dlv-list"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
-dependencies = [
- "const-random",
-]
-
-[[package]]
-name = "document-features"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
-dependencies = [
- "litrs",
-]
-
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -2124,7 +2069,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
- "windows-registry 0.6.1",
+ "windows-registry",
]
[[package]]
@@ -2310,7 +2255,7 @@ checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2 0.6.4",
"widestring",
- "windows-registry 0.6.1",
+ "windows-registry",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
@@ -2593,12 +2538,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
-[[package]]
-name = "litrs"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
-
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2889,12 +2828,11 @@ dependencies = [
[[package]]
name = "mydevtools-desktop"
-version = "0.1.12"
+version = "0.1.13"
dependencies = [
"base64 0.22.1",
"bytes",
"chrono",
- "cookie_store 0.21.1",
"dashmap",
"futures-util",
"h2",
@@ -2907,13 +2845,11 @@ dependencies = [
"rand 0.8.6",
"redis",
"reqwest 0.12.28",
- "reqwest_cookie_store",
"rusqlite",
"serde",
"serde_json",
"tauri",
"tauri-build",
- "tauri-plugin-deep-link",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
@@ -3408,16 +3344,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
-[[package]]
-name = "ordered-multimap"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
-dependencies = [
- "dlv-list",
- "hashbrown 0.14.5",
-]
-
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3811,22 +3737,6 @@ dependencies = [
"unicode-ident",
]
-[[package]]
-name = "psl-types"
-version = "2.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
-
-[[package]]
-name = "publicsuffix"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
-dependencies = [
- "idna",
- "psl-types",
-]
-
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -4046,8 +3956,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
- "cookie",
- "cookie_store 0.22.1",
"encoding_rs",
"futures-core",
"futures-util",
@@ -4123,18 +4031,6 @@ dependencies = [
"web-sys",
]
-[[package]]
-name = "reqwest_cookie_store"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2314c325724fea278d44c13a525ebf60074e33c05f13b4345c076eb65b2446b3"
-dependencies = [
- "bytes",
- "cookie_store 0.21.1",
- "reqwest 0.12.28",
- "url",
-]
-
[[package]]
name = "resolv-conf"
version = "0.7.6"
@@ -4169,16 +4065,6 @@ dependencies = [
"smallvec",
]
-[[package]]
-name = "rust-ini"
-version = "0.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
-dependencies = [
- "cfg-if",
- "ordered-multimap",
-]
-
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -5167,27 +5053,6 @@ dependencies = [
"walkdir",
]
-[[package]]
-name = "tauri-plugin-deep-link"
-version = "2.4.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
-dependencies = [
- "dunce",
- "plist",
- "rust-ini",
- "serde",
- "serde_json",
- "tauri",
- "tauri-plugin",
- "tauri-utils",
- "thiserror 2.0.18",
- "tracing",
- "url",
- "windows-registry 0.5.3",
- "windows-result 0.3.4",
-]
-
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -6472,17 +6337,6 @@ dependencies = [
"windows-link 0.1.3",
]
-[[package]]
-name = "windows-registry"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
-dependencies = [
- "windows-link 0.1.3",
- "windows-result 0.3.4",
- "windows-strings 0.4.2",
-]
-
[[package]]
name = "windows-registry"
version = "0.6.1"
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 227e2c5b..33d14d25 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mydevtools-desktop"
-version = "0.1.12"
+version = "0.1.13"
description = "MyDevTools desktop app"
edition = "2021"
@@ -23,9 +23,6 @@ rand = "0.8"
thiserror = "2"
reqwest = { version = "0.12", features = ["json", "gzip", "multipart", "stream"] }
base64 = "0.22"
-reqwest_cookie_store = "0.8"
-cookie_store = "0.21"
-tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-window-state = "2"
tauri-plugin-updater = "2"
diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json
index d1318d7f..7a89c039 100644
--- a/apps/desktop/src-tauri/capabilities/default.json
+++ b/apps/desktop/src-tauri/capabilities/default.json
@@ -3,5 +3,5 @@
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
- "permissions": ["core:default", "core:window:allow-start-dragging", "core:window:allow-is-fullscreen", "deep-link:default", "opener:default", "updater:default", "process:default"]
+ "permissions": ["core:default", "core:window:allow-start-dragging", "core:window:allow-is-fullscreen", "opener:default", "updater:default", "process:default"]
}
diff --git a/apps/desktop/src-tauri/src/db/migrations.rs b/apps/desktop/src-tauri/src/db/migrations.rs
index 882d151f..86947d6a 100644
--- a/apps/desktop/src-tauri/src/db/migrations.rs
+++ b/apps/desktop/src-tauri/src/db/migrations.rs
@@ -55,6 +55,12 @@ const MIGRATIONS: &[&str] = &[
);
CREATE INDEX idx_conflicts_open ON conflicts(created_at) WHERE resolved_at IS NULL;
",
+ // v3 — accounts removed. Drop the leftovers of the old sign-in flow: the
+ // serialized backend cookie jar (session tokens for a backend that no
+ // longer exists) and the activation snapshot that used to gate the app.
+ "
+ DELETE FROM kv WHERE k IN ('cookie_jar', 'activation');
+ ",
];
/// Highest schema version this build knows how to migrate to.
diff --git a/apps/desktop/src-tauri/src/error.rs b/apps/desktop/src-tauri/src/error.rs
index e244ccfa..757b2115 100644
--- a/apps/desktop/src-tauri/src/error.rs
+++ b/apps/desktop/src-tauri/src/error.rs
@@ -10,8 +10,6 @@ pub enum AppError {
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
- #[error("{0}")]
- Other(String),
}
pub type Result = std::result::Result;
diff --git a/apps/desktop/src-tauri/src/http/auth_server.rs b/apps/desktop/src-tauri/src/http/auth_server.rs
deleted file mode 100644
index 5f00bbcc..00000000
--- a/apps/desktop/src-tauri/src/http/auth_server.rs
+++ /dev/null
@@ -1,144 +0,0 @@
-//! Loopback OAuth callback server.
-//!
-//! Native-app auth pattern: bind an ephemeral localhost port, open the system
-//! browser to the web login (passing the port), and let the browser redirect
-//! the minted token back to `http://127.0.0.1:/callback?token=...`. Works
-//! identically in `tauri dev` and the packaged app — no URL-scheme / Info.plist
-//! registration needed (unlike the mydevtools:// deep link, which macOS only
-//! routes to a bundled .app).
-
-use std::time::Duration;
-
-use tauri::ipc::Channel;
-use tokio::io::{AsyncReadExt, AsyncWriteExt};
-use tokio::net::TcpListener;
-
-const AUTH_TIMEOUT: Duration = Duration::from_secs(300);
-
-fn parse_token(request: &str) -> Option {
- // First line: "GET /callback?token=XXX HTTP/1.1"
- let line = request.lines().next()?;
- let path = line.split_whitespace().nth(1)?;
- let query = path.split_once('?')?.1;
- for pair in query.split('&') {
- if let Some(v) = pair.strip_prefix("token=") {
- return Some(urldecode(v));
- }
- }
- None
-}
-
-fn urldecode(s: &str) -> String {
- let bytes = s.as_bytes();
- let mut out = Vec::with_capacity(bytes.len());
- let mut i = 0;
- while i < bytes.len() {
- match bytes[i] {
- b'%' if i + 2 < bytes.len() => {
- let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
- if let Some(b) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
- out.push(b);
- i += 3;
- continue;
- }
- out.push(bytes[i]);
- i += 1;
- }
- b'+' => {
- out.push(b' ');
- i += 1;
- }
- b => {
- out.push(b);
- i += 1;
- }
- }
- }
- String::from_utf8_lossy(&out).into_owned()
-}
-
-const DONE_HTML: &str = "MyDevTools
Signed in ✓
You can close this tab and return to the MyDevTools app.
";
-
-/// Bind a loopback listener, report its port on `port_channel`, then await the
-/// browser's `/callback?token=...` request and return the token.
-pub async fn await_browser_auth(port_channel: Channel) -> Result {
- let listener = TcpListener::bind("127.0.0.1:0")
- .await
- .map_err(|e| format!("failed to bind auth callback port: {e}"))?;
- let port = listener.local_addr().map_err(|e| e.to_string())?.port();
- port_channel
- .send(serde_json::json!({ "port": port }))
- .map_err(|e| e.to_string())?;
-
- match tokio::time::timeout(AUTH_TIMEOUT, accept_callback(&listener)).await {
- Ok(result) => result,
- Err(_) => Err("Sign-in timed out — no callback received".into()),
- }
-}
-
-/// Await the browser's `/callback?token=...` on an already-bound listener.
-/// Non-callback requests (favicon, preflight) get a 204 and are ignored.
-async fn accept_callback(listener: &TcpListener) -> Result {
- loop {
- let (mut socket, _) = listener.accept().await.map_err(|e| e.to_string())?;
- let mut buf = vec![0u8; 8192];
- let n = socket.read(&mut buf).await.map_err(|e| e.to_string())?;
- let request = String::from_utf8_lossy(&buf[..n]);
- if let Some(token) = parse_token(&request) {
- let resp = format!(
- "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
- DONE_HTML.len(),
- DONE_HTML
- );
- let _ = socket.write_all(resp.as_bytes()).await;
- let _ = socket.flush().await;
- return Ok(token);
- }
- let _ = socket
- .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n")
- .await;
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn parses_token_from_request() {
- let req = "GET /callback?token=abc.def-ghi%3D&x=1 HTTP/1.1\r\nHost: localhost\r\n\r\n";
- assert_eq!(parse_token(req).as_deref(), Some("abc.def-ghi="));
- }
-
- #[test]
- fn no_token_returns_none() {
- assert_eq!(parse_token("GET /favicon.ico HTTP/1.1\r\n\r\n"), None);
- }
-
- #[tokio::test]
- async fn loopback_callback_roundtrip() {
- let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
- let port = listener.local_addr().unwrap().port();
- let server = tokio::spawn(async move { accept_callback(&listener).await });
-
- // Simulate the browser: a favicon probe (ignored) then the real callback.
- let mut ignored = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
- ignored
- .write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n")
- .await
- .unwrap();
- let mut sink = Vec::new();
- let _ = ignored.read_to_end(&mut sink).await;
-
- let mut cb = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
- cb.write_all(b"GET /callback?token=tok-123%3D HTTP/1.1\r\nHost: localhost\r\n\r\n")
- .await
- .unwrap();
- let mut resp = Vec::new();
- cb.read_to_end(&mut resp).await.unwrap();
- assert!(String::from_utf8_lossy(&resp).contains("Signed in"));
-
- let token = server.await.unwrap().unwrap();
- assert_eq!(token, "tok-123=");
- }
-}
diff --git a/apps/desktop/src-tauri/src/http/mod.rs b/apps/desktop/src-tauri/src/http/mod.rs
index cd400f6a..0d15b565 100644
--- a/apps/desktop/src-tauri/src/http/mod.rs
+++ b/apps/desktop/src-tauri/src/http/mod.rs
@@ -1,5 +1,3 @@
-pub mod auth_server;
pub mod grpc;
pub mod mock_server;
pub mod proxy;
-pub mod remote;
diff --git a/apps/desktop/src-tauri/src/http/remote.rs b/apps/desktop/src-tauri/src/http/remote.rs
deleted file mode 100644
index 1c6adf31..00000000
--- a/apps/desktop/src-tauri/src/http/remote.rs
+++ /dev/null
@@ -1,96 +0,0 @@
-//! Remote bridge: Rust-side HTTP to the FastAPI backend with a persistent
-//! cookie jar (HttpOnly JWT cookies live here, never in the webview).
-//!
-//! Rust HTTP has no CORS, so the existing backend cookie flow works with zero
-//! backend changes. The jar is serialized into the SQLCipher kv table so the
-//! session survives restarts.
-
-use std::io::Cursor;
-use std::sync::Arc;
-
-use reqwest_cookie_store::CookieStoreMutex;
-use rusqlite::OptionalExtension;
-use serde::Serialize;
-
-use crate::error::{AppError, Result};
-use crate::state::AppState;
-
-const KV_JAR: &str = "cookie_jar";
-
-#[derive(Serialize)]
-pub struct RemoteResponse {
- pub status: u16,
- pub body: String,
-}
-
-pub struct RemoteHttp {
- pub client: reqwest::Client,
- pub jar: Arc,
-}
-
-impl RemoteHttp {
- pub fn init(db: &rusqlite::Connection) -> Result {
- let raw: Option = db
- .query_row("SELECT v FROM kv WHERE k = ?1", [KV_JAR], |r| r.get(0))
- .optional()?;
- let store = match raw {
- Some(json) => cookie_store::serde::json::load(Cursor::new(json.into_bytes()))
- .unwrap_or_default(),
- None => cookie_store::CookieStore::default(),
- };
- let jar = Arc::new(CookieStoreMutex::new(store));
- let client = reqwest::Client::builder()
- .cookie_provider(jar.clone())
- .user_agent("mydevtools-desktop")
- .build()
- .map_err(|e| AppError::Other(e.to_string()))?;
- Ok(Self { client, jar })
- }
-
- pub fn persist_jar(&self, db: &rusqlite::Connection) -> Result<()> {
- let mut buf = Vec::new();
- {
- let store = self.jar.lock().unwrap();
- cookie_store::serde::json::save_incl_expired_and_nonpersistent(&store, &mut buf)
- .map_err(|e| AppError::Other(e.to_string()))?;
- }
- let json = String::from_utf8_lossy(&buf).to_string();
- db.execute(
- "INSERT INTO kv (k, v) VALUES (?1, ?2) ON CONFLICT(k) DO UPDATE SET v = excluded.v",
- [KV_JAR, &json],
- )?;
- Ok(())
- }
-
- pub fn clear_jar(&self, db: &rusqlite::Connection) -> Result<()> {
- self.jar.lock().unwrap().clear();
- db.execute("DELETE FROM kv WHERE k = ?1", [KV_JAR])?;
- Ok(())
- }
-}
-
-/// Perform an HTTP request against the backend with the persistent jar.
-/// `url` is the full URL (base comes from the frontend's baked env config).
-pub async fn request(
- state: &AppState,
- method: &str,
- url: &str,
- body: Option,
-) -> Result {
- let method = reqwest::Method::from_bytes(method.as_bytes())
- .map_err(|_| AppError::Other(format!("bad method {method}")))?;
- let mut req = state.http.client.request(method, url);
- if let Some(b) = body {
- req = req.header("Content-Type", "application/json").body(b);
- }
- let res = req.send().await.map_err(|e| AppError::Other(format!("network error: {e}")))?;
- let status = res.status().as_u16();
- let body = res.text().await.unwrap_or_default();
-
- // Persist cookies after every call — refresh rotations must survive restart.
- {
- let db = state.db.lock().unwrap();
- state.http.persist_jar(&db)?;
- }
- Ok(RemoteResponse { status, body })
-}
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index 4edf1cc8..74240354 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -5,7 +5,6 @@ mod http;
mod router;
mod state;
-use http::remote::RemoteResponse;
use router::ApiResponse;
use state::AppState;
use tauri::Manager;
@@ -24,24 +23,6 @@ async fn local_api(
router::route(&state, &method, &path, body.as_deref()).map_err(|e| e.to_string())
}
-#[tauri::command]
-async fn remote_api(
- state: tauri::State<'_, AppState>,
- method: String,
- url: String,
- body: Option,
-) -> Result {
- http::remote::request(&state, &method, &url, body)
- .await
- .map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-async fn clear_remote_session(state: tauri::State<'_, AppState>) -> Result<(), String> {
- let db = state.db.lock().unwrap();
- state.http.clear_jar(&db).map_err(|e| e.to_string())
-}
-
#[tauri::command]
async fn http_request(input: serde_json::Value) -> Result {
Ok(http::proxy::http_request(input).await)
@@ -70,16 +51,8 @@ async fn proxy_grpc(input: serde_json::Value) -> Result,
-) -> Result {
- http::auth_server::await_browser_auth(port_channel).await
-}
-
pub fn run() {
tauri::Builder::default()
- .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init())
// Restore everything except SIZE. Sizes are saved in physical pixels, so
// a size saved on a HiDPI (scale-2) display restores 2x too large on a
@@ -123,14 +96,11 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
local_api,
- remote_api,
- clear_remote_session,
http_request,
http_request_stream,
http_request_stream_cancel,
mock_server_start,
- proxy_grpc,
- await_browser_auth
+ proxy_grpc
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/apps/desktop/src-tauri/src/router/activation.rs b/apps/desktop/src-tauri/src/router/activation.rs
deleted file mode 100644
index d541fe9a..00000000
--- a/apps/desktop/src-tauri/src/router/activation.rs
+++ /dev/null
@@ -1,89 +0,0 @@
-//! One-time desktop activation record (`/desktop/activation`).
-//!
-//! Stores the account snapshot captured at browser sign-in:
-//! `{uid, email, display_name, created_at, activated_at}`.
-//! The webview owns the shape; Rust stores it as an opaque JSON object in `kv`.
-//! Presence of the row == app is activated; it survives until the user resets
-//! app data (DELETE) or wipes the SQLCipher DB.
-
-use crate::db::now_ms;
-use crate::error::Result;
-use crate::router::ApiResponse;
-use crate::state::AppState;
-
-const KV_KEY: &str = "activation";
-
-pub fn handle(state: &AppState, method: &str, path: &str, body: Option<&str>) -> Result {
- if !path.is_empty() {
- return Ok(ApiResponse::detail(404, "Not found"));
- }
- let db = state.db.lock().unwrap();
- match method {
- "GET" => {
- let existing: Option = db
- .query_row("SELECT v FROM kv WHERE k = ?1", [KV_KEY], |r| r.get(0))
- .map(Some)
- .or_else(|e| match e {
- rusqlite::Error::QueryReturnedNoRows => Ok(None),
- other => Err(other),
- })?;
- match existing {
- Some(v) => Ok(ApiResponse { status: 200, body: v }),
- None => Ok(ApiResponse::detail(404, "Not activated")),
- }
- }
- "POST" => {
- let mut value: serde_json::Value =
- serde_json::from_str(body.unwrap_or("")).map_err(crate::error::AppError::from)?;
- if !value.is_object() {
- return Ok(ApiResponse::detail(422, "Expected a JSON object"));
- }
- value["activated_at"] = serde_json::json!(now_ms());
- let json = serde_json::to_string(&value)?;
- db.execute(
- "INSERT INTO kv (k, v) VALUES (?1, ?2) ON CONFLICT(k) DO UPDATE SET v = excluded.v",
- [KV_KEY, &json],
- )?;
- Ok(ApiResponse { status: 200, body: json })
- }
- "DELETE" => {
- db.execute("DELETE FROM kv WHERE k = ?1", [KV_KEY])?;
- Ok(ApiResponse::empty(204))
- }
- _ => Ok(ApiResponse::detail(405, "Method not allowed")),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::router::route;
- use crate::state::AppState;
-
- #[test]
- fn activation_lifecycle() {
- let state = AppState::in_memory();
- // Not activated initially.
- let r = route(&state, "GET", "/desktop/activation", None).unwrap();
- assert_eq!(r.status, 404);
- // Activate stamps activated_at and round-trips.
- let body = r#"{"uid":"u1","email":"a@b.c"}"#;
- let r = route(&state, "POST", "/desktop/activation", Some(body)).unwrap();
- assert_eq!(r.status, 200);
- let v: serde_json::Value = serde_json::from_str(&r.body).unwrap();
- assert_eq!(v["email"], "a@b.c");
- assert!(v["activated_at"].as_i64().unwrap() > 0);
- let r = route(&state, "GET", "/desktop/activation", None).unwrap();
- assert_eq!(r.status, 200);
- // Re-activation overwrites (fresh sign-in refreshes the snapshot).
- let r = route(&state, "POST", "/desktop/activation", Some(r#"{"uid":"u1","email":"new@b.c"}"#)).unwrap();
- assert_eq!(r.status, 200);
- let v: serde_json::Value = serde_json::from_str(&route(&state, "GET", "/desktop/activation", None).unwrap().body).unwrap();
- assert_eq!(v["email"], "new@b.c");
- // Reset clears it.
- let r = route(&state, "DELETE", "/desktop/activation", None).unwrap();
- assert_eq!(r.status, 204);
- assert_eq!(route(&state, "GET", "/desktop/activation", None).unwrap().status, 404);
- // Non-object body rejected.
- assert_eq!(route(&state, "POST", "/desktop/activation", Some("[1]")).unwrap().status, 422);
- }
-}
diff --git a/apps/desktop/src-tauri/src/router/mod.rs b/apps/desktop/src-tauri/src/router/mod.rs
index ae3f82aa..88daa445 100644
--- a/apps/desktop/src-tauri/src/router/mod.rs
+++ b/apps/desktop/src-tauri/src/router/mod.rs
@@ -1,4 +1,3 @@
-pub mod activation;
pub mod api_client;
pub mod backup;
pub mod backup_codes;
@@ -45,10 +44,6 @@ impl ApiResponse {
}
}
-/// Remote-only path prefixes (relative to /api/v1). Empty since S3 went
-/// direct-to-bucket and DNS moved to DoH — kept for the next cloud-only tool.
-const REMOTE_ONLY: &[&str] = &[];
-
/// Dispatch a request against the local store. Paths are normalized FastAPI
/// paths (`/api/v1/...`); query strings are split off here and passed along.
pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str>) -> Result {
@@ -58,18 +53,6 @@ pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str>
};
let path = path.trim_end_matches('/');
- // Session endpoints: the local store is authorized by OS login + master
- // vault, so session checks always succeed offline.
- match (method, path) {
- ("GET", "/api/v1/auth/session/check") => return Ok(ApiResponse::detail(200, "ok")),
- ("POST", "/api/v1/auth/refresh") => return Ok(ApiResponse::detail(200, "ok")),
- ("POST", "/api/v1/auth/logout") => return Ok(ApiResponse::detail(200, "ok")),
- _ => {}
- }
-
- if let Some(rest) = path.strip_prefix("/desktop/activation") {
- return activation::handle(state, method, rest, body);
- }
if let Some(rest) = path.strip_prefix("/desktop/backup") {
return backup::handle(state, method, rest, body);
}
@@ -142,13 +125,6 @@ pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str>
return preferences::handle(state, method, rest, query, body);
}
- if REMOTE_ONLY.iter().any(|p| rel.starts_with(p)) {
- return Ok(ApiResponse::detail(
- 503,
- "This tool requires a network connection and cloud sign-in",
- ));
- }
-
stubs::handle(method, path)
}
@@ -223,13 +199,6 @@ mod tests {
assert!(r.body.contains("nonexistent"));
}
- #[test]
- fn session_check_ok_offline() {
- let state = AppState::in_memory();
- let r = route(&state, "GET", "/api/v1/auth/session/check", None).unwrap();
- assert_eq!(r.status, 200);
- }
-
#[test]
fn envelope_entries_crud() {
let state = AppState::in_memory();
diff --git a/apps/desktop/src-tauri/src/router/preferences.rs b/apps/desktop/src-tauri/src/router/preferences.rs
index 1dc6f7ca..065f022a 100644
--- a/apps/desktop/src-tauri/src/router/preferences.rs
+++ b/apps/desktop/src-tauri/src/router/preferences.rs
@@ -59,12 +59,16 @@ fn defaults() -> Value {
"theme": "system",
"accentColor": "blue",
"locale": "en",
+ // Local profile: what the app calls you. There is no account behind it.
+ "displayName": null,
+ "avatar": null,
+ // First-run walkthrough state (used to live on the user account).
+ "onboardingCompleted": false,
+ "persona": null,
"enabledTools": DEFAULT_ENABLED_TOOLS,
"toolFavorites": [],
"pinnedToolsByWorkspace": {},
"toolStats": {},
- // updatedAt=0 marks an unconfigured (never-persisted) baseline so cloud
- // sync (LWW) prefers the remote prefs until the user changes one here.
"createdAt": 0,
"updatedAt": 0,
})
diff --git a/apps/desktop/src-tauri/src/state.rs b/apps/desktop/src-tauri/src/state.rs
index 93634ace..38327632 100644
--- a/apps/desktop/src-tauri/src/state.rs
+++ b/apps/desktop/src-tauri/src/state.rs
@@ -3,18 +3,15 @@ use std::sync::Mutex;
use crate::db;
use crate::error::Result;
-use crate::http::remote::RemoteHttp;
pub struct AppState {
pub db: Mutex,
- pub http: RemoteHttp,
}
impl AppState {
pub fn init(db_path: PathBuf) -> Result {
let conn = db::open(&db_path)?;
- let http = RemoteHttp::init(&conn)?;
- Ok(Self { db: Mutex::new(conn), http })
+ Ok(Self { db: Mutex::new(conn) })
}
/// In-memory state for tests (no Keychain, no SQLCipher key).
@@ -22,7 +19,6 @@ impl AppState {
pub fn in_memory() -> Self {
let conn = rusqlite::Connection::open_in_memory().unwrap();
db::migrations::run(&conn).unwrap();
- let http = RemoteHttp::init(&conn).unwrap();
- Self { db: Mutex::new(conn), http }
+ Self { db: Mutex::new(conn) }
}
}
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index c9a3255a..86c3b7dc 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MyDevTools",
- "version": "0.1.12",
+ "version": "0.1.13",
"identifier": "tech.mydevtools.desktop",
"build": {
"beforeDevCommand": "pnpm --filter @mydevtools/desktop-ui dev:tauri",
@@ -28,11 +28,6 @@
}
},
"plugins": {
- "deep-link": {
- "desktop": {
- "schemes": ["mydevtools"]
- }
- },
"updater": {
"endpoints": ["https://github.com/mydevtools-tech/mydevtools-releases/releases/latest/download/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIwQTI5NjQwRjg5MjgwNjQKUldSa2dKTDRRSmFpc0Rqc21XWGdNSGVPNlp4YVlVV0JWdFpJVk9kYzBIdkx2MUFHNHZWc1VKUmsK"
diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json
index bbd10f97..58e5a4dc 100644
--- a/apps/web/messages/af.json
+++ b/apps/web/messages/af.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Wat is MyDevTools?",
- "description": "’n Ontwikkelaar-werkspasie met aanmelding: ’n paneelbord en ’n stel gereedskap vir alledaagse take.",
- "p1": "Nadat jy aanmeld, vestig die app ’n sessie met die MyDevTools API sodat gereedskap jou data kan laai en stoor. Die sybalk wys elke app; jy kan gereedskap wat jy nie gebruik nie wegsteek in",
- "settingsLink": "Instellings",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Sommige funksies gebruik ’n meesterwagwoord (of globale kluis-sleutel) sodat geheime soos wagwoorde en verbindingsstringe op jou toestel geënkripteer word voordat dit die bediener bereik."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Vinnige skakels",
- "dashboard": "Paneelbord",
- "settings": "Instellings"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Gebruik hierdie hulpmiddel vanaf die sybalk vir sy toegewyde werkvloei."
},
"security": {
- "session": {
- "title": "Aanmelding en API-sessie",
- "description": "Identiteit en hoe die blaaier met jou backend praat.",
- "items": {
- "authentication": "Verifikasie gebruik Firebase vir aanmelding. Ná login ruil die app jou Firebase ID-token vir API-koekies.",
- "accessTokens": "Toegang-tokens is JWT’s wat op die bediener geteken word (HS256) en bewys watter gebruiker die API oproep.",
- "refreshTokens": "Verfris-tokens word in HttpOnly-koekies gestoor; die bediener stoor ’n SHA-256 hash van die token, nie die rou token nie."
- }
- },
"encryption": {
"title": "Enkripsie om te ken",
"description": "Kliënt-kant kripto vir die kluis en sensitiewe verbindingsdata.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 met SHA-256, 100 000 iterasies en ’n ewekansige sout lei ’n 256-bis sleutel uit jou meesterwagwoord af.",
"aes": "AES-256-GCM enkripteer data; ’n ewekansige 12-byte IV word per enkripsie gebruik.",
- "scope": "Die afgeleide sleutel bly in die blaaier (nie-uittrekbaar). Die bediener ontvang slegs ciphertext en IV’s, nie jou meesterwagwoord of plaintext geheime nie."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "As jy die meesterwagwoord vergeet, kan geënkripteerde data nie herstel word nie. Bêre dit veilig."
- },
- "logout": {
- "title": "Afmeld en plaaslike data",
- "body": "Afmeld maak geheime in geheue skoon, kluis-verwante toestand, en meester-sleutelmateriaal uit IndexedDB waar van toepassing, en beëindig die API-sessie. Data wat net in plaaslike berging is kan bly totdat jy dit in die blaaier uitvee."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Pas sigbare gereedskap in Instellings aan om die sybalk gefokus te hou."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json
index 2b6b481e..6c3d8b0d 100644
--- a/apps/web/messages/ar.json
+++ b/apps/web/messages/ar.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "ما هو MyDevTools؟",
- "description": "مساحة عمل للمطورين مع تسجيل الدخول: لوحة تحكم ومجموعة أدوات للمهام اليومية.",
- "p1": "بعد تسجيل الدخول، ينشئ التطبيق جلسة مع واجهة MyDevTools API حتى تتمكن الأدوات من تحميل بياناتك وحفظها. تعرض القائمة الجانبية جميع التطبيقات؛ ويمكنك إخفاء الأدوات التي لا تستخدمها من",
- "settingsLink": "الإعدادات",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "تستخدم بعض الميزات كلمة مرور رئيسية (أو مفتاح خزنة عام) بحيث يتم تشفير الأسرار مثل كلمات المرور وسلاسل الاتصال على جهازك قبل وصولها إلى الخادم."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "روابط سريعة",
- "dashboard": "لوحة التحكم",
- "settings": "الإعدادات"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "استخدم هذه الأداة من الشريط الجانبي لسير عملها المخصص."
},
"security": {
- "session": {
- "title": "تسجيل الدخول وجلسة API",
- "description": "الهوية وكيف يتواصل المتصفح مع الخادم الخلفي.",
- "items": {
- "authentication": "يستخدم التوثيق Firebase لتسجيل الدخول. بعد ذلك يتم استبدال رمز Firebase ID بملفات تعريف ارتباط للـ API.",
- "accessTokens": "رموز الوصول هي JWT موقعة على الخادم (HS256) وتحدد المستخدم الذي يستدعي الـ API.",
- "refreshTokens": "رموز التحديث محفوظة في Cookies من نوع HttpOnly؛ ويخزن الخادم قيمة SHA-256 للرمز وليس الرمز الخام."
- }
- },
"encryption": {
"title": "التشفير المهم معرفته",
"description": "تشفير من جهة العميل لخزنة البيانات وبيانات الاتصال الحساسة.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "تقوم PBKDF2 مع SHA-256 و100,000 تكرار وملح عشوائي باشتقاق مفتاح 256-بت من كلمة المرور الرئيسية.",
"aes": "تقوم AES-256-GCM بتشفير البيانات؛ ويتم استخدام IV بطول 12 بايت عشوائي لكل عملية تشفير.",
- "scope": "يبقى المفتاح المشتق داخل المتصفح (غير قابل للاستخراج). يستلم الخادم نصًا مُشفّرًا وIV وليس كلمة المرور الرئيسية أو نصًا صريحًا."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "إذا نسيت كلمة المرور الرئيسية فلن يمكن استعادة البيانات المشفرة. احتفظ بها بأمان."
- },
- "logout": {
- "title": "تسجيل الخروج والبيانات المحلية",
- "body": "عند تسجيل الخروج يتم مسح الأسرار من الذاكرة وحالة الخزنة ومادة المفتاح من IndexedDB عند توفرها، وإنهاء جلسة الـ API. وقد تبقى البيانات المخزنة محليًا على الجهاز حتى تقوم بمسحها من المتصفح."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "خصص الأدوات الظاهرة من الإعدادات للحفاظ على تركيز الشريط الجانبي."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json
index 214d5848..0a85a330 100644
--- a/apps/web/messages/ca.json
+++ b/apps/web/messages/ca.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Què és MyDevTools?",
- "description": "Un espai de treball per a desenvolupadors amb inici de sessió: un tauler i un conjunt d’eines per al dia a dia.",
- "p1": "Després d’iniciar sessió, l’app estableix una sessió amb l’API de MyDevTools perquè les eines puguin carregar i desar les teves dades. La barra lateral llista totes les apps; pots amagar les eines que no fas servir a",
- "settingsLink": "Configuració",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Algunes funcions utilitzen una contrasenya mestra (o clau global del vault) perquè secrets com contrasenyes i cadenes de connexió es xifrin al teu dispositiu abans d’arribar al servidor."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Enllaços ràpids",
- "dashboard": "Tauler",
- "settings": "Configuració"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Fes servir aquesta eina des de la barra lateral per al seu flux de treball dedicat."
},
"security": {
- "session": {
- "title": "Inici de sessió i sessió d’API",
- "description": "Identitat i com el navegador es comunica amb el backend.",
- "items": {
- "authentication": "Autenticació mitjançant Firebase. Després del login, l’app intercanvia el teu Firebase ID token per cookies de l’API.",
- "accessTokens": "Tokens d’accés són JWT signats al servidor (HS256) que proven quin usuari crida l’API.",
- "refreshTokens": "Tokens de refresc es guarden en cookies HttpOnly; el servidor desa un hash SHA-256 del token, no el token en brut."
- }
- },
"encryption": {
"title": "Xifrat que has de conèixer",
"description": "Criptografia al client per al vault i dades sensibles de connexió.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 amb SHA-256, 100.000 iteracions i una sal aleatòria deriva una clau de 256 bits a partir de la contrasenya mestra.",
"aes": "AES-256-GCM xifra dades; s’utilitza un IV de 12 bytes aleatori per cada xifrat.",
- "scope": "La clau derivada es manté al navegador (no exportable). El servidor només rep xifrat i IV, no la contrasenya mestra ni secrets en clar."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Si oblides la contrasenya mestra, no es poden recuperar les dades xifrades. Desa-la de manera segura."
- },
- "logout": {
- "title": "Tancar sessió i dades locals",
- "body": "En tancar sessió s’esborren secrets en memòria, estat del vault i, si escau, material de la clau mestra d’IndexedDB, i es finalitza la sessió de l’API. Les dades que només estiguin a l’emmagatzematge local poden romandre fins que les esborris al navegador."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Personalitza les eines visibles a Configuració per mantenir la barra lateral enfocada."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json
index 9003e3bc..ad601d7b 100644
--- a/apps/web/messages/cs.json
+++ b/apps/web/messages/cs.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Co je MyDevTools?",
- "description": "Vývojářský workspace s přihlášením: přehled a sada nástrojů pro každodenní práci.",
- "p1": "Po přihlášení aplikace naváže relaci s MyDevTools API, aby nástroje mohly načítat a ukládat vaše data. Postranní panel zobrazuje všechny aplikace; nástroje, které nepoužíváte, můžete skrýt v",
- "settingsLink": "Nastavení",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Některé funkce používají hlavní heslo (nebo globální klíč trezoru), aby se tajné údaje jako hesla a connection stringy šifrovaly na vašem zařízení dříve, než dorazí na server."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Rychlé odkazy",
- "dashboard": "Přehled",
- "settings": "Nastavení"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Používejte tento nástroj z postranního panelu v jeho vyhrazeném pracovním postupu."
},
"security": {
- "session": {
- "title": "Přihlášení a API relace",
- "description": "Identita a jak prohlížeč komunikuje s backendem.",
- "items": {
- "authentication": "Ověřování používá Firebase. Po přihlášení aplikace vymění váš Firebase ID token za API cookies.",
- "accessTokens": "Přístupové tokeny jsou JWT podepsané na serveru (HS256) a dokazují, který uživatel volá API.",
- "refreshTokens": "Obnovovací tokeny jsou uloženy v HttpOnly cookies; server ukládá SHA-256 hash tokenu, ne jeho surovou hodnotu."
- }
- },
"encryption": {
"title": "Šifrování, které byste měli znát",
"description": "Kryptografie na straně klienta pro trezor a citlivé údaje o připojení.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 s SHA-256, 100 000 iteracemi a náhodnou solí odvodí 256bitový klíč z hlavního hesla.",
"aes": "AES-256-GCM šifruje data; pro každé šifrování se používá náhodný 12bajtový IV.",
- "scope": "Odvozený klíč zůstává v prohlížeči (nelze exportovat). Server dostává pouze šifrotext a IV, nikoliv hlavní heslo ani tajná data v otevřené podobě."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Pokud zapomenete hlavní heslo, šifrovaná data nelze obnovit. Bezpečně si ho uložte."
- },
- "logout": {
- "title": "Odhlášení a lokální data",
- "body": "Odhlášení vymaže tajné údaje v paměti, stav trezoru a (pokud je to relevantní) materiál hlavního klíče z IndexedDB a ukončí API relaci. Data uložená pouze v lokálním úložišti zařízení mohou zůstat, dokud je nevymažete v prohlížeči."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Přizpůsobte viditelné nástroje v Nastavení, aby postranní panel zůstal přehledný."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json
index 58242df2..f8d50e42 100644
--- a/apps/web/messages/da.json
+++ b/apps/web/messages/da.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Hvad er MyDevTools?",
- "description": "Et udvikler‑workspace med login: en oversigt og et sæt værktøjer til hverdagsopgaver.",
- "p1": "Når du har logget ind, opretter appen en session med MyDevTools API, så værktøjer kan indlæse og gemme dine data. Sidebaren viser alle apps; du kan skjule værktøjer, du ikke bruger, i",
- "settingsLink": "Indstillinger",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Nogle funktioner bruger en masteradgangskode (eller global vault‑nøgle), så hemmeligheder som adgangskoder og forbindelsesstrenge krypteres på din enhed, før de når serveren."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Hurtige links",
- "dashboard": "Oversigt",
- "settings": "Indstillinger"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Brug dette værktøj fra sidebaren til dets dedikerede arbejdsgang."
},
"security": {
- "session": {
- "title": "Login og API‑session",
- "description": "Identitet og hvordan browseren taler med din backend.",
- "items": {
- "authentication": "Godkendelse bruger Firebase til login. Efter login udveksler appen din Firebase ID‑token til API‑cookies.",
- "accessTokens": "Access tokens er JWT’er signeret på serveren (HS256), som viser hvilken bruger der kalder API’et.",
- "refreshTokens": "Refresh tokens gemmes i HttpOnly cookies; serveren gemmer en SHA‑256 hash af tokenet, ikke selve tokenet."
- }
- },
"encryption": {
"title": "Kryptering du bør kende",
"description": "Klientside‑krypto for vault og følsomme forbindelsesdata.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 med SHA‑256, 100.000 iterationer og en tilfældig salt afleder en 256‑bit nøgle fra din masteradgangskode.",
"aes": "AES‑256‑GCM krypterer data; en tilfældig 12‑byte IV bruges for hver kryptering.",
- "scope": "Den afledte nøgle bliver i browseren (ikke‑udtrækkelig). Serveren modtager kun ciphertext og IV’er, ikke din masteradgangskode eller hemmeligheder i klartekst."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Hvis du glemmer masteradgangskoden, kan krypterede data ikke gendannes. Opbevar den sikkert."
- },
- "logout": {
- "title": "Log ud og lokale data",
- "body": "Log ud rydder hemmeligheder i hukommelsen, vault‑tilstand og (hvis relevant) master‑key‑materiale fra IndexedDB og afslutter API‑sessionen. Data, der kun ligger i lokal lagring på enheden, kan blive liggende, indtil du rydder dem i browseren."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Tilpas synlige værktøjer i Indstillinger for at holde sidebaren fokuseret."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json
index 8bd2e0db..a8f42dac 100644
--- a/apps/web/messages/de.json
+++ b/apps/web/messages/de.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Was ist MyDevTools?",
- "description": "Ein Entwickler‑Workspace mit Login: Dashboard und Tools für den Alltag.",
- "p1": "Nach dem Anmelden stellt die App eine Sitzung mit der MyDevTools‑API her, damit Tools Daten laden und speichern können. Die Seitenleiste listet alle Apps; ungenutzte Tools kannst du in",
- "settingsLink": "Einstellungen",
- "p1After": "ausblenden.",
- "p2": "Einige Funktionen nutzen ein Master‑Passwort (oder einen globalen Vault‑Key), sodass Geheimnisse wie Passwörter und Verbindungsstrings auf deinem Gerät verschlüsselt werden, bevor sie den Server erreichen."
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
+ "p1After": ".",
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Schnelllinks",
- "dashboard": "Dashboard",
- "settings": "Einstellungen"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Nutze dieses Tool über die Seitenleiste für den vorgesehenen Workflow."
},
"security": {
- "session": {
- "title": "Login und API‑Sitzung",
- "description": "Identität und wie der Browser mit deinem Backend spricht.",
- "items": {
- "authentication": "Authentifizierung erfolgt über Firebase. Nach dem Login tauscht die App dein Firebase‑ID‑Token gegen API‑Cookies.",
- "accessTokens": "Access‑Tokens sind serverseitig signierte JWTs (HS256). Sie zeigen, welcher Nutzer die API aufruft.",
- "refreshTokens": "Refresh‑Tokens liegen in HttpOnly‑Cookies; der Server speichert einen SHA‑256‑Hash des Tokens, nicht das Token im Klartext."
- }
- },
"encryption": {
"title": "Wichtige Verschlüsselung",
"description": "Client‑seitige Krypto für Vault und sensible Verbindungsdaten.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 mit SHA‑256, 100.000 Iterationen und zufälligem Salt leitet aus dem Master‑Passwort einen 256‑Bit‑Key ab.",
"aes": "AES‑256‑GCM verschlüsselt Nutzdaten; pro Verschlüsselung wird ein zufälliger 12‑Byte‑IV verwendet.",
- "scope": "Der abgeleitete Schlüssel bleibt im Browser (nicht exportierbar). Der Server erhält Ciphertext und IVs, nicht dein Master‑Passwort oder Klartext‑Geheimnisse."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Wenn du das Master‑Passwort vergisst, können verschlüsselte Daten nicht wiederhergestellt werden. Bewahre es sicher auf."
- },
- "logout": {
- "title": "Abmelden und lokale Daten",
- "body": "Beim Abmelden werden Secrets im Speicher, Vault‑Status und – falls zutreffend – Master‑Key‑Material aus IndexedDB gelöscht und die API‑Sitzung beendet. Daten, die nur im lokalen Speicher liegen, bleiben ggf. bis zur Browser‑Bereinigung erhalten."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Blende Tools in den Einstellungen aus, um die Seitenleiste fokussiert zu halten."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json
index f4fcb7fc..517199ff 100644
--- a/apps/web/messages/el.json
+++ b/apps/web/messages/el.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Τι είναι το MyDevTools;",
- "description": "Ένας χώρος εργασίας για προγραμματιστές με σύνδεση: ταμπλό και εργαλεία για καθημερινές εργασίες.",
- "p1": "Αφού συνδεθείτε, η εφαρμογή δημιουργεί μια συνεδρία με το MyDevTools API ώστε τα εργαλεία να μπορούν να φορτώνουν και να αποθηκεύουν τα δεδομένα σας. Η πλευρική γραμμή εμφανίζει όλες τις εφαρμογές· μπορείτε να κρύψετε εργαλεία που δεν χρησιμοποιείτε από τις",
- "settingsLink": "Ρυθμίσεις",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Ορισμένες λειτουργίες χρησιμοποιούν κύριο κωδικό (ή καθολικό κλειδί θησαυροφυλακίου) ώστε μυστικά όπως κωδικοί και strings σύνδεσης να κρυπτογραφούνται στη συσκευή σας πριν φτάσουν στον διακομιστή."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Γρήγοροι σύνδεσμοι",
- "dashboard": "Ταμπλό",
- "settings": "Ρυθμίσεις"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Χρησιμοποιήστε αυτό το εργαλείο από την πλευρική γραμμή για τη ροή εργασίας του."
},
"security": {
- "session": {
- "title": "Σύνδεση και συνεδρία API",
- "description": "Ταυτότητα και πώς ο browser επικοινωνεί με το backend.",
- "items": {
- "authentication": "Αυθεντικοποίηση με Firebase. Μετά το login, η εφαρμογή ανταλλάσσει το Firebase ID token με cookies API.",
- "accessTokens": "Access tokens είναι JWT υπογεγραμμένα στον server (HS256) και αποδεικνύουν ποιος χρήστης καλεί το API.",
- "refreshTokens": "Refresh tokens αποθηκεύονται σε HttpOnly cookies· ο server κρατά SHA-256 hash του token, όχι το ίδιο το token."
- }
- },
"encryption": {
"title": "Κρυπτογράφηση που πρέπει να γνωρίζετε",
"description": "Κρυπτογραφία στην πλευρά του πελάτη για το θησαυροφυλάκιο και ευαίσθητα δεδομένα σύνδεσης.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 με SHA-256, 100.000 επαναλήψεις και τυχαίο salt παράγει κλειδί 256-bit από τον κύριο κωδικό.",
"aes": "AES-256-GCM κρυπτογραφεί δεδομένα· χρησιμοποιείται τυχαίο IV 12 bytes σε κάθε κρυπτογράφηση.",
- "scope": "Το παράγωγο κλειδί μένει στον browser (μη εξαγώγιμο). Ο server λαμβάνει μόνο ciphertext και IV, όχι τον κύριο κωδικό ή μυστικά σε απλό κείμενο."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Αν ξεχάσετε τον κύριο κωδικό, τα κρυπτογραφημένα δεδομένα δεν μπορούν να ανακτηθούν. Φυλάξτε τον με ασφάλεια."
- },
- "logout": {
- "title": "Αποσύνδεση και τοπικά δεδομένα",
- "body": "Η αποσύνδεση καθαρίζει μυστικά από τη μνήμη, κατάσταση του θησαυροφυλακίου και (όπου ισχύει) υλικό κύριου κλειδιού από το IndexedDB και τερματίζει τη συνεδρία API. Δεδομένα που υπάρχουν μόνο στο local storage μπορεί να παραμείνουν μέχρι να τα διαγράψετε στον browser."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Προσαρμόστε τα ορατά εργαλεία στις Ρυθμίσεις για να παραμείνει εστιασμένη η πλευρική γραμμή."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 74aadf40..33cd614d 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "What is MyDevTools?",
- "description": "A signed-in developer workspace: a dashboard and a set of tools for everyday tasks.",
- "p1": "After you sign in with your account, the app establishes a session with the MyDevTools API so tools can load and save your data. The sidebar lists every app; you can hide tools you do not use from",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
"settingsLink": "Settings",
"p1After": ".",
- "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted on your device before they reach the server."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Quick links",
- "dashboard": "Dashboard",
- "settings": "Settings"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Use this tool from the sidebar for its dedicated workflow."
},
"security": {
- "session": {
- "title": "Sign-in and API session",
- "description": "Identity and how the browser talks to your backend.",
- "items": {
- "authentication": "Authentication uses Firebase for sign-in. After login, the app exchanges your Firebase ID token for API cookies.",
- "accessTokens": "Access tokens are JWTs signed on the server (HS256). They prove which user is calling the API.",
- "refreshTokens": "Refresh tokens are stored in HttpOnly cookies; the server stores a SHA-256 hash of the refresh token, not the raw token."
- }
- },
"encryption": {
"title": "Encryption you should know about",
"description": "Client-side crypto for vault and sensitive connection data.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 with SHA-256, 100,000 iterations, and a random salt derives a 256-bit key from your master password.",
"aes": "AES-256-GCM encrypts payloads; a random 12-byte IV is used per encryption.",
- "scope": "The derived key stays in the browser (non-extractable). The server receives ciphertext and IVs, not your master password or plaintext secrets."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "If you forget the master password, encrypted data cannot be recovered. Store it safely."
- },
- "logout": {
- "title": "Logout and local data",
- "body": "Logging out clears in-memory secrets, vault-related state, and master key material from IndexedDB where applicable, and ends the API session. Anything kept only in local storage on a device may remain until you clear it in the browser."
}
},
"tips": {
@@ -144,31 +131,5 @@
"settings": "Customize visible tools in Settings to keep the sidebar focused."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json
index 44bd81e5..ae0f0434 100644
--- a/apps/web/messages/es.json
+++ b/apps/web/messages/es.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "¿Qué es MyDevTools?",
- "description": "Un espacio de trabajo para desarrolladores con inicio de sesión: un tablero y un conjunto de herramientas para tareas diarias.",
- "p1": "Después de iniciar sesión con tu cuenta, la app establece una sesión con la API de MyDevTools para que las herramientas puedan cargar y guardar tus datos. La barra lateral muestra todas las apps; puedes ocultar las herramientas que no usas desde",
- "settingsLink": "Configuraciones",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Algunas funciones usan una contraseña maestra (o clave global del cofre) para que secretos como contraseñas y cadenas de conexión se cifren en tu dispositivo antes de llegar al servidor."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Enlaces rápidos",
- "dashboard": "Tablero",
- "settings": "Configuraciones"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Usa esta herramienta desde la barra lateral para su flujo de trabajo específico."
},
"security": {
- "session": {
- "title": "Inicio de sesión y sesión de API",
- "description": "Identidad y cómo el navegador se comunica con tu backend.",
- "items": {
- "authentication": "Autenticación usa Firebase para iniciar sesión. Después, la app intercambia tu token de ID de Firebase por cookies de la API.",
- "accessTokens": "Tokens de acceso son JWT firmados en el servidor (HS256). Identifican al usuario que llama a la API.",
- "refreshTokens": "Tokens de refresco se guardan en cookies HttpOnly; el servidor guarda un hash SHA-256 del token, no el token en claro."
- }
- },
"encryption": {
"title": "Cifrado que debes conocer",
"description": "Criptografía del lado del cliente para el cofre y datos sensibles de conexión.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 con SHA-256, 100.000 iteraciones y una sal aleatoria deriva una clave de 256 bits desde tu contraseña maestra.",
"aes": "AES-256-GCM cifra los datos; se usa un IV de 12 bytes aleatorio por cifrado.",
- "scope": "La clave derivada se mantiene en el navegador (no extraíble). El servidor recibe cifrado e IVs, no tu contraseña maestra ni secretos en texto plano."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Si olvidas la contraseña maestra, los datos cifrados no se pueden recuperar. Guárdala de forma segura."
- },
- "logout": {
- "title": "Cerrar sesión y datos locales",
- "body": "Al cerrar sesión se limpian secretos en memoria, estado del cofre y material de clave maestra de IndexedDB cuando aplica, y se termina la sesión de la API. Lo que solo esté en almacenamiento local del dispositivo puede permanecer hasta que lo borres en el navegador."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Personaliza las herramientas visibles en Configuraciones para mantener la barra lateral enfocada."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json
index 5c2e158c..ba751829 100644
--- a/apps/web/messages/fa.json
+++ b/apps/web/messages/fa.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "MyDevTools چیست؟",
- "description": "یک فضای کاری توسعهدهنده با ورود: داشبورد و مجموعهای از ابزارهای روزمره.",
- "p1": "پس از ورود، برنامه یک نشست با API MyDevTools برقرار میکند تا ابزارها بتوانند دادههای شما را بارگیری و ذخیره کنند. نوار کناری همه برنامهها را نشان میدهد؛ میتوانید ابزارهایی را که استفاده نمیکنید از",
- "settingsLink": "تنظیمات",
- "p1After": "مخفی کنید.",
- "p2": "برخی قابلیتها از رمز اصلی (یا کلید کلی خزانه) استفاده میکنند تا اطلاعات حساسی مثل گذرواژهها و رشتههای اتصال، قبل از رسیدن به سرور روی دستگاه شما رمزنگاری شوند."
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
+ "p1After": ".",
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "پیوندهای سریع",
- "dashboard": "داشبورد",
- "settings": "تنظیمات"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "این ابزار را از نوار کناری و مطابق گردشکار اختصاصی خودش استفاده کنید."
},
"security": {
- "session": {
- "title": "ورود و نشست API",
- "description": "هویت و اینکه مرورگر چگونه با بکاند ارتباط برقرار میکند.",
- "items": {
- "authentication": "احراز هویت از Firebase برای ورود استفاده میکند. پس از ورود، برنامه توکن شناسه Firebase را با کوکیهای API مبادله میکند.",
- "accessTokens": "توکنهای دسترسی JWTهای امضاشده در سرور (HS256) هستند و مشخص میکنند کدام کاربر API را فراخوانی میکند.",
- "refreshTokens": "توکنهای نوسازی در کوکیهای HttpOnly ذخیره میشوند؛ سرور هش SHA-256 توکن را نگه میدارد، نه خود توکن خام را."
- }
- },
"encryption": {
"title": "رمزنگاری مهم",
"description": "رمزنگاری سمت کلاینت برای خزانه و دادههای اتصال حساس.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 با SHA-256، 100,000 تکرار و salt تصادفی، یک کلید 256 بیتی را از رمز اصلی مشتق میکند.",
"aes": "AES-256-GCM دادهها را رمزنگاری میکند؛ برای هر رمزنگاری از IV 12 بایتی تصادفی استفاده میشود.",
- "scope": "کلید مشتقشده داخل مرورگر میماند (غیرقابل استخراج). سرور فقط متن رمز و IV را دریافت میکند، نه رمز اصلی یا دادههای محرمانه بهصورت متن ساده."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "اگر رمز اصلی را فراموش کنید، دادههای رمزنگاریشده قابل بازیابی نیستند. آن را امن نگه دارید."
- },
- "logout": {
- "title": "خروج و دادههای محلی",
- "body": "خروج، اطلاعات حساسِ حافظه، وضعیت خزانه و (در صورت وجود) مواد کلید اصلی را از IndexedDB پاک میکند و نشست API را پایان میدهد. دادههایی که فقط در ذخیرهسازی محلی دستگاه هستند ممکن است تا زمانی که در مرورگر پاکشان کنید باقی بمانند."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "در تنظیمات ابزارهای قابل مشاهده را شخصیسازی کنید تا نوار کناری متمرکز بماند."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 22ae256b..60368cff 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Qu’est-ce que MyDevTools ?",
- "description": "Un espace de travail développeur avec connexion : un tableau de bord et des outils pour le quotidien.",
- "p1": "Après connexion, l’app établit une session avec l’API MyDevTools pour charger et enregistrer vos données. La barre latérale liste toutes les apps ; vous pouvez masquer celles que vous n’utilisez pas depuis",
- "settingsLink": "Paramètres",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Certaines fonctionnalités utilisent un mot de passe maître (ou une clé de coffre globale) afin que des secrets comme les mots de passe et chaînes de connexion soient chiffrés sur votre appareil avant d’atteindre le serveur."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Liens rapides",
- "dashboard": "Tableau de bord",
- "settings": "Paramètres"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Utilisez cet outil depuis la barre latérale pour son flux de travail dédié."
},
"security": {
- "session": {
- "title": "Connexion et session API",
- "description": "Identité et communication entre le navigateur et votre backend.",
- "items": {
- "authentication": "Authentification via Firebase. Après connexion, l’app échange votre jeton d’ID Firebase contre des cookies API.",
- "accessTokens": "Jetons d’accès : JWT signés côté serveur (HS256) qui prouvent quel utilisateur appelle l’API.",
- "refreshTokens": "Jetons de rafraîchissement : stockés en cookies HttpOnly ; le serveur conserve un hash SHA-256 du jeton, pas le jeton brut."
- }
- },
"encryption": {
"title": "Chiffrement à connaître",
"description": "Crypto côté client pour le coffre et les données de connexion sensibles.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 avec SHA-256, 100 000 itérations et un sel aléatoire dérive une clé 256 bits depuis votre mot de passe maître.",
"aes": "AES-256-GCM chiffre les données ; un IV de 12 octets aléatoire est utilisé à chaque chiffrement.",
- "scope": "La clé dérivée reste dans le navigateur (non extractible). Le serveur reçoit le texte chiffré et les IV, pas votre mot de passe maître ni des secrets en clair."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Si vous oubliez le mot de passe maître, les données chiffrées ne peuvent pas être récupérées. Conservez-le en lieu sûr."
- },
- "logout": {
- "title": "Déconnexion et données locales",
- "body": "La déconnexion efface les secrets en mémoire, l’état du coffre et, si applicable, le matériel de clé maître dans IndexedDB, et termine la session API. Les données uniquement en stockage local peuvent rester jusqu’à suppression dans le navigateur."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Personnalisez les outils visibles dans Paramètres pour garder la barre latérale focalisée."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json
index 899b72c6..5e60929e 100644
--- a/apps/web/messages/id.json
+++ b/apps/web/messages/id.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Apa itu MyDevTools?",
- "description": "Workspace developer dengan login: dasbor dan kumpulan tools untuk kebutuhan sehari-hari.",
- "p1": "Setelah login, aplikasi membuat sesi dengan MyDevTools API agar tools dapat memuat dan menyimpan data Anda. Sidebar menampilkan semua aplikasi; Anda dapat menyembunyikan tools yang tidak digunakan dari",
- "settingsLink": "Pengaturan",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Beberapa fitur menggunakan kata sandi utama (atau kunci vault global) agar rahasia seperti kata sandi dan connection string dienkripsi di perangkat Anda sebelum mencapai server."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Tautan cepat",
- "dashboard": "Dasbor",
- "settings": "Pengaturan"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Gunakan tool ini dari sidebar untuk workflow khususnya."
},
"security": {
- "session": {
- "title": "Login dan sesi API",
- "description": "Identitas dan cara browser berkomunikasi dengan backend.",
- "items": {
- "authentication": "Autentikasi menggunakan Firebase untuk login. Setelah login, aplikasi menukar Firebase ID token Anda menjadi cookie API.",
- "accessTokens": "Access token adalah JWT yang ditandatangani server (HS256) untuk membuktikan pengguna yang memanggil API.",
- "refreshTokens": "Refresh token disimpan di cookie HttpOnly; server menyimpan hash SHA-256 dari token, bukan token mentahnya."
- }
- },
"encryption": {
"title": "Enkripsi yang perlu diketahui",
"description": "Kripto sisi klien untuk vault dan data koneksi sensitif.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 dengan SHA-256, 100.000 iterasi, dan salt acak menurunkan kunci 256-bit dari kata sandi utama Anda.",
"aes": "AES-256-GCM mengenkripsi data; IV 12-byte acak digunakan untuk setiap enkripsi.",
- "scope": "Kunci turunan tetap di browser (tidak dapat diekstrak). Server hanya menerima ciphertext dan IV, bukan kata sandi utama atau rahasia dalam plaintext."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Jika Anda lupa kata sandi utama, data terenkripsi tidak dapat dipulihkan. Simpan dengan aman."
- },
- "logout": {
- "title": "Logout dan data lokal",
- "body": "Logout akan menghapus rahasia di memori, status vault, dan (jika berlaku) material kunci utama dari IndexedDB, serta mengakhiri sesi API. Data yang hanya tersimpan di penyimpanan lokal perangkat dapat tetap ada sampai Anda menghapusnya di browser."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Sesuaikan tools yang terlihat di Pengaturan agar sidebar tetap fokus."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json
index 559eb0a2..7e29a5fc 100644
--- a/apps/web/messages/it.json
+++ b/apps/web/messages/it.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Che cos’è MyDevTools?",
- "description": "Uno spazio di lavoro per sviluppatori con accesso: una dashboard e una raccolta di strumenti quotidiani.",
- "p1": "Dopo l’accesso, l’app stabilisce una sessione con l’API di MyDevTools per consentire agli strumenti di caricare e salvare i tuoi dati. La barra laterale elenca tutte le app; puoi nascondere gli strumenti che non usi da",
- "settingsLink": "Impostazioni",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Alcune funzionalità usano una password principale (o chiave globale del vault) così che segreti come password e stringhe di connessione vengano crittografati sul tuo dispositivo prima di arrivare al server."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Link rapidi",
- "dashboard": "Pannello di controllo",
- "settings": "Impostazioni"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Usa questo strumento dalla barra laterale per il suo flusso di lavoro dedicato."
},
"security": {
- "session": {
- "title": "Accesso e sessione API",
- "description": "Identità e come il browser comunica con il backend.",
- "items": {
- "authentication": "Autenticazione tramite Firebase. Dopo il login, l’app scambia l’ID token di Firebase con cookie dell’API.",
- "accessTokens": "Token di accesso sono JWT firmati sul server (HS256) e indicano quale utente sta chiamando l’API.",
- "refreshTokens": "Token di refresh sono salvati in cookie HttpOnly; il server conserva un hash SHA-256 del token, non il token in chiaro."
- }
- },
"encryption": {
"title": "Crittografia da conoscere",
"description": "Crittografia lato client per vault e dati di connessione sensibili.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 con SHA-256, 100.000 iterazioni e un salt casuale deriva una chiave a 256 bit dalla password principale.",
"aes": "AES-256-GCM cifra i dati; viene usato un IV di 12 byte casuale per ogni cifratura.",
- "scope": "La chiave derivata resta nel browser (non esportabile). Il server riceve solo ciphertext e IV, non la password principale né segreti in chiaro."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Se dimentichi la password principale, i dati crittografati non possono essere recuperati. Conservala con cura."
- },
- "logout": {
- "title": "Logout e dati locali",
- "body": "Il logout elimina i segreti in memoria, lo stato del vault e, quando applicabile, il materiale della chiave principale da IndexedDB, e termina la sessione API. I dati mantenuti solo nello storage locale del dispositivo possono rimanere finché non li cancelli dal browser."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Personalizza gli strumenti visibili in Impostazioni per mantenere la barra laterale più pulita."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json
index 6adf36cf..c1c51777 100644
--- a/apps/web/messages/ja.json
+++ b/apps/web/messages/ja.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "MyDevTools とは?",
- "description": "ログインして使う開発者向けワークスペース。ダッシュボードと日常ツール群を提供します。",
- "p1": "ログイン後、アプリは MyDevTools API とセッションを確立し、各ツールがデータを読み書きできるようにします。サイドバーにはすべてのアプリが表示され、使わないツールは",
- "settingsLink": "設定",
- "p1After": "で非表示にできます。",
- "p2": "一部の機能では マスターパスワード(またはグローバルなボールトキー)を使用し、パスワードや接続文字列などの機密情報をサーバーに送る前に端末内で暗号化します。"
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
+ "p1After": ".",
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "クイックリンク",
- "dashboard": "ダッシュボード",
- "settings": "設定"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "このツールはサイドバーから開いて専用のワークフローで使用してください。"
},
"security": {
- "session": {
- "title": "ログインと API セッション",
- "description": "認証とブラウザがバックエンドと通信する仕組み。",
- "items": {
- "authentication": "認証は Firebase を使用します。ログイン後、Firebase の ID トークンを API の Cookie に交換します。",
- "accessTokens": "アクセストークンはサーバーで署名された JWT(HS256)で、API を呼び出すユーザーを証明します。",
- "refreshTokens": "リフレッシュトークンは HttpOnly Cookie に保存され、サーバー側にはトークンそのものではなく SHA-256 ハッシュが保存されます。"
- }
- },
"encryption": {
"title": "知っておくべき暗号化",
"description": "ボールトや機密の接続データのためのクライアント側暗号化。",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2(SHA-256、100,000 回、ランダム salt)でマスターパスワードから 256-bit キーを導出します。",
"aes": "AES-256-GCM で暗号化し、暗号化ごとにランダムな 12 バイト IV を使用します。",
- "scope": "導出キーはブラウザ内に保持され(取り出し不可)、サーバーに送られるのは 暗号文と IV のみです。マスターパスワードや平文の秘密は送信されません。"
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "マスターパスワードを忘れると暗号化されたデータは復元できません。安全に保管してください。"
- },
- "logout": {
- "title": "ログアウトとローカルデータ",
- "body": "ログアウト時にメモリ上の機密情報、ボールト状態、(該当する場合)IndexedDB 上のマスターキー素材を消去し、API セッションを終了します。端末のローカルストレージのみに残るデータは、ブラウザで削除するまで残る場合があります。"
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "設定で表示ツールをカスタマイズしてサイドバーを整理できます。"
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json
index e3982a0f..1f1c793c 100644
--- a/apps/web/messages/ko.json
+++ b/apps/web/messages/ko.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "MyDevTools란?",
- "description": "로그인 기반 개발자 워크스페이스: 대시보드와 일상적인 도구 모음.",
- "p1": "로그인하면 앱이 MyDevTools API와 세션을 설정해 도구가 데이터를 불러오고 저장할 수 있습니다. 사이드바에는 모든 앱이 표시되며, 사용하지 않는 도구는",
- "settingsLink": "설정",
- "p1After": "에서 숨길 수 있습니다.",
- "p2": "일부 기능은 마스터 비밀번호(또는 전역 볼트 키)를 사용하여 비밀번호나 DB 연결 문자열 같은 민감 정보가 서버로 전송되기 전에 기기에서 암호화되도록 합니다."
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
+ "p1After": ".",
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "빠른 링크",
- "dashboard": "대시보드",
- "settings": "설정"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "이 도구는 사이드바에서 열어 전용 워크플로로 사용하세요."
},
"security": {
- "session": {
- "title": "로그인 및 API 세션",
- "description": "신원 확인과 브라우저가 백엔드와 통신하는 방식.",
- "items": {
- "authentication": "인증은 Firebase를 사용합니다. 로그인 후 앱이 Firebase ID 토큰을 API 쿠키로 교환합니다.",
- "accessTokens": "액세스 토큰은 서버에서 서명된 JWT(HS256)로, API를 호출하는 사용자를 증명합니다.",
- "refreshTokens": "리프레시 토큰은 HttpOnly 쿠키에 저장되며, 서버는 토큰 원문이 아닌 SHA-256 해시를 저장합니다."
- }
- },
"encryption": {
"title": "알아두면 좋은 암호화",
"description": "볼트 및 민감한 연결 데이터에 대한 클라이언트 측 암호화.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2(SHA-256, 100,000회 반복, 랜덤 salt)로 마스터 비밀번호에서 256비트 키를 파생합니다.",
"aes": "AES-256-GCM으로 데이터를 암호화하며, 매 암호화마다 랜덤 12바이트 IV를 사용합니다.",
- "scope": "파생 키는 브라우저에 남아(추출 불가) 서버에는 암호문과 IV만 전달됩니다. 마스터 비밀번호나 평문 비밀은 전송되지 않습니다."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "마스터 비밀번호를 잊으면 암호화된 데이터를 복구할 수 없습니다. 안전하게 보관하세요."
- },
- "logout": {
- "title": "로그아웃 및 로컬 데이터",
- "body": "로그아웃 시 메모리의 민감 정보, 볼트 상태, (해당되는 경우) IndexedDB의 마스터 키 자료를 삭제하고 API 세션을 종료합니다. 기기의 로컬 저장소에만 있는 데이터는 브라우저에서 지우기 전까지 남아 있을 수 있습니다."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "설정에서 표시할 도구를 선택해 사이드바를 더 깔끔하게 유지하세요."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json
index 982abc4e..17455d56 100644
--- a/apps/web/messages/ms.json
+++ b/apps/web/messages/ms.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Apakah MyDevTools?",
- "description": "Ruang kerja pembangun dengan log masuk: papan pemuka dan set alat untuk tugasan harian.",
- "p1": "Selepas anda log masuk, aplikasi mewujudkan sesi dengan MyDevTools API supaya alat boleh memuat dan menyimpan data anda. Bar sisi menyenaraikan semua aplikasi; anda boleh menyembunyikan alat yang tidak digunakan dari",
- "settingsLink": "Tetapan",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Sesetengah ciri menggunakan kata laluan induk (atau kunci vault global) supaya rahsia seperti kata laluan dan rentetan sambungan disulitkan pada peranti anda sebelum sampai ke pelayan."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Pautan pantas",
- "dashboard": "Papan Pemuka",
- "settings": "Tetapan"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Gunakan alat ini dari bar sisi untuk aliran kerja khususnya."
},
"security": {
- "session": {
- "title": "Log masuk dan sesi API",
- "description": "Identiti dan cara pelayar berkomunikasi dengan backend.",
- "items": {
- "authentication": "Pengesahan menggunakan Firebase untuk log masuk. Selepas log masuk, aplikasi menukar token ID Firebase anda kepada kuki API.",
- "accessTokens": "Token akses ialah JWT yang ditandatangani pelayan (HS256) untuk membuktikan pengguna yang memanggil API.",
- "refreshTokens": "Token segar semula disimpan dalam kuki HttpOnly; pelayan menyimpan hash SHA-256 token, bukan token mentah."
- }
- },
"encryption": {
"title": "Penyulitan yang perlu diketahui",
"description": "Kripto sisi klien untuk vault dan data sambungan sensitif.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 dengan SHA-256, 100,000 iterasi dan salt rawak menjana kunci 256-bit daripada kata laluan induk anda.",
"aes": "AES-256-GCM menyulitkan data; IV 12 bait rawak digunakan bagi setiap penyulitan.",
- "scope": "Kunci terbitan kekal dalam pelayar (tidak boleh diekstrak). Pelayan hanya menerima ciphertext dan IV, bukan kata laluan induk atau rahsia dalam teks biasa."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Jika anda terlupa kata laluan induk, data yang disulitkan tidak boleh dipulihkan. Simpan dengan selamat."
- },
- "logout": {
- "title": "Log keluar dan data tempatan",
- "body": "Log keluar akan mengosongkan rahsia dalam ingatan, status vault, dan (jika berkenaan) bahan kunci induk daripada IndexedDB serta menamatkan sesi API. Data yang hanya berada dalam storan tempatan peranti mungkin kekal sehingga anda memadamkannya dalam pelayar."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Sesuaikan alat yang kelihatan dalam Tetapan untuk memastikan bar sisi kekal fokus."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json
index 37bedc54..be0c60d4 100644
--- a/apps/web/messages/nb.json
+++ b/apps/web/messages/nb.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Hva er MyDevTools?",
- "description": "Et utvikler‑workspace med innlogging: et dashbord og et sett med verktøy for hverdagsoppgaver.",
- "p1": "Når du logger inn, oppretter appen en økt med MyDevTools API slik at verktøy kan laste og lagre dataene dine. Sidebaren viser alle apper; du kan skjule verktøy du ikke bruker i",
- "settingsLink": "Innstillinger",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Noen funksjoner bruker et hovedpassord (eller global vault‑nøkkel) slik at hemmeligheter som passord og tilkoblingsstrenger krypteres på enheten din før de når serveren."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Hurtiglenker",
- "dashboard": "Dashbord",
- "settings": "Innstillinger"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Bruk dette verktøyet fra sidebaren for den dedikerte arbeidsflyten."
},
"security": {
- "session": {
- "title": "Innlogging og API‑økt",
- "description": "Identitet og hvordan nettleseren snakker med backenden din.",
- "items": {
- "authentication": "Autentisering bruker Firebase for innlogging. Etter innlogging bytter appen Firebase ID‑tokenet ditt mot API‑cookies.",
- "accessTokens": "Tilgangstoken er JWT-er signert på serveren (HS256) og beviser hvilken bruker som kaller API-et.",
- "refreshTokens": "Oppfriskningstoken lagres i HttpOnly cookies; serveren lagrer en SHA-256 hash av tokenet, ikke selve tokenet."
- }
- },
"encryption": {
"title": "Kryptering du bør kjenne til",
"description": "Klientside‑krypto for vault og sensitive tilkoblingsdata.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 med SHA-256, 100 000 iterasjoner og tilfeldig salt avleder en 256-bit nøkkel fra hovedpassordet ditt.",
"aes": "AES-256-GCM krypterer data; en tilfeldig 12-byte IV brukes for hver kryptering.",
- "scope": "Den avledede nøkkelen blir i nettleseren (ikke-eksporterbar). Serveren mottar kun ciphertext og IV-er, ikke hovedpassordet ditt eller hemmeligheter i klartekst."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Hvis du glemmer hovedpassordet, kan krypterte data ikke gjenopprettes. Oppbevar det trygt."
- },
- "logout": {
- "title": "Logg ut og lokale data",
- "body": "Når du logger ut, ryddes hemmeligheter i minnet, vault‑status og (der det gjelder) hovednøkkelmateriale fra IndexedDB, og API‑økten avsluttes. Data som bare ligger i lokal lagring kan bli værende til du sletter det i nettleseren."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Tilpass synlige verktøy i Innstillinger for å holde sidebaren fokusert."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json
index 3a2b69f4..f33c4e74 100644
--- a/apps/web/messages/nl.json
+++ b/apps/web/messages/nl.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Wat is MyDevTools?",
- "description": "Een developer-workspace met login: een dashboard en een set tools voor dagelijks gebruik.",
- "p1": "Na het inloggen maakt de app een sessie met de MyDevTools API zodat tools je data kunnen laden en opslaan. De zijbalk toont alle apps; tools die je niet gebruikt kun je verbergen via",
- "settingsLink": "Instellingen",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Sommige functies gebruiken een masterwachtwoord (of globale vault-sleutel) zodat geheimen zoals wachtwoorden en connection strings op je apparaat worden versleuteld voordat ze de server bereiken."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Snelle links",
- "dashboard": "Dashboard",
- "settings": "Instellingen"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Gebruik deze tool via de zijbalk voor de bijbehorende workflow."
},
"security": {
- "session": {
- "title": "Inloggen en API-sessie",
- "description": "Identiteit en hoe de browser met je backend praat.",
- "items": {
- "authentication": "Authenticatie gebruikt Firebase voor inloggen. Na login wisselt de app je Firebase ID-token om voor API-cookies.",
- "accessTokens": "Access tokens zijn server-ondertekende JWT’s (HS256) en tonen welke gebruiker de API aanroept.",
- "refreshTokens": "Refresh tokens worden opgeslagen in HttpOnly cookies; de server bewaart een SHA-256 hash van de refresh token, niet de ruwe token."
- }
- },
"encryption": {
"title": "Versleuteling die je moet kennen",
"description": "Client-side crypto voor vault en gevoelige connectiegegevens.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 met SHA-256, 100.000 iteraties en een willekeurige salt leidt een 256-bit sleutel af uit je masterwachtwoord.",
"aes": "AES-256-GCM versleutelt payloads; per versleuteling wordt een willekeurige 12-byte IV gebruikt.",
- "scope": "De afgeleide sleutel blijft in de browser (niet exporteerbaar). De server ontvangt alleen ciphertext en IV’s, niet je masterwachtwoord of plaintext secrets."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Als je het masterwachtwoord vergeet, kunnen versleutelde gegevens niet worden hersteld. Bewaar het veilig."
- },
- "logout": {
- "title": "Uitloggen en lokale data",
- "body": "Uitloggen wist geheimen in het geheugen, vault-status en (waar van toepassing) masterkey-materiaal uit IndexedDB en beëindigt de API-sessie. Data die alleen in lokale opslag staat kan blijven totdat je die in de browser wist."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Pas zichtbare tools aan in Instellingen om de zijbalk gefocust te houden."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json
index 3260850d..f0e022b3 100644
--- a/apps/web/messages/pl.json
+++ b/apps/web/messages/pl.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Czym jest MyDevTools?",
- "description": "Środowisko dla programistów z logowaniem: dashboard i zestaw narzędzi na co dzień.",
- "p1": "Po zalogowaniu aplikacja ustanawia sesję z API MyDevTools, dzięki czemu narzędzia mogą wczytywać i zapisywać Twoje dane. Pasek boczny pokazuje wszystkie aplikacje; nieużywane narzędzia możesz ukryć w",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
"settingsLink": "Settings",
"p1After": ".",
- "p2": "Niektóre funkcje używają hasła głównego (lub globalnego klucza sejfu), aby sekrety takie jak hasła i connection stringi były szyfrowane na Twoim urządzeniu zanim trafią na serwer."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Szybkie linki",
- "dashboard": "Dashboard",
- "settings": "Settings"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Korzystaj z tego narzędzia z paska bocznego w jego dedykowanym workflow."
},
"security": {
- "session": {
- "title": "Logowanie i sesja API",
- "description": "Tożsamość i sposób, w jaki przeglądarka komunikuje się z backendem.",
- "items": {
- "authentication": "Uwierzytelnianie korzysta z Firebase. Po zalogowaniu aplikacja wymienia Firebase ID token na ciasteczka API.",
- "accessTokens": "Tokeny dostępu to podpisane na serwerze JWT (HS256), które potwierdzają użytkownika wywołującego API.",
- "refreshTokens": "Tokeny odświeżania są przechowywane w HttpOnly cookies; serwer trzyma hash SHA-256 tokenu, a nie sam token."
- }
- },
"encryption": {
"title": "Szyfrowanie, które warto znać",
"description": "Kryptografia po stronie klienta dla sejfu i wrażliwych danych połączeń.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 z SHA-256, 100 000 iteracji i losową solą wyprowadza 256-bitowy klucz z hasła głównego.",
"aes": "AES-256-GCM szyfruje dane; dla każdego szyfrowania używany jest losowy 12-bajtowy IV.",
- "scope": "Wyprowadzony klucz pozostaje w przeglądarce (nie da się go wyeksportować). Serwer otrzymuje tylko szyfrogram i IV, nie hasło główne ani sekrety w postaci jawnej."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Jeśli zapomnisz hasła głównego, zaszyfrowanych danych nie da się odzyskać. Przechowuj je bezpiecznie."
- },
- "logout": {
- "title": "Wylogowanie i dane lokalne",
- "body": "Wylogowanie czyści sekrety z pamięci, stan sejfu i (jeśli dotyczy) materiał klucza głównego z IndexedDB oraz kończy sesję API. Dane zapisane tylko w local storage mogą pozostać, dopóki nie wyczyścisz ich w przeglądarce."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Dostosuj widoczne narzędzia w Settings, aby pasek boczny był bardziej przejrzysty."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json
index 92c8b666..338df83a 100644
--- a/apps/web/messages/pt-BR.json
+++ b/apps/web/messages/pt-BR.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "O que é o MyDevTools?",
- "description": "Um workspace para devs com login: um painel e um conjunto de ferramentas do dia a dia.",
- "p1": "Depois de fazer login, o app cria uma sessão com a API do MyDevTools para que as ferramentas possam carregar e salvar seus dados. A barra lateral lista todos os apps; você pode ocultar ferramentas que não usa em",
- "settingsLink": "Configurações",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Alguns recursos usam uma senha mestra (ou chave global do cofre) para que segredos como senhas e strings de conexão sejam criptografados no seu dispositivo antes de chegar ao servidor."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Links rápidos",
- "dashboard": "Painel",
- "settings": "Configurações"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Use esta ferramenta pela barra lateral para o fluxo de trabalho dedicado."
},
"security": {
- "session": {
- "title": "Login e sessão de API",
- "description": "Identidade e como o navegador conversa com o backend.",
- "items": {
- "authentication": "Autenticação usa Firebase para login. Depois, o app troca seu Firebase ID token por cookies da API.",
- "accessTokens": "Tokens de acesso são JWT assinados no servidor (HS256) e provam qual usuário está chamando a API.",
- "refreshTokens": "Tokens de refresh ficam em cookies HttpOnly; o servidor armazena um hash SHA-256 do token, não o token em si."
- }
- },
"encryption": {
"title": "Criptografia que você deve conhecer",
"description": "Cripto no cliente para o cofre e dados sensíveis de conexão.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 com SHA-256, 100.000 iterações e um salt aleatório deriva uma chave de 256 bits a partir da senha mestra.",
"aes": "AES-256-GCM criptografa os dados; um IV de 12 bytes aleatório é usado por criptografia.",
- "scope": "A chave derivada fica no navegador (não extraível). O servidor recebe apenas ciphertext e IVs, nunca sua senha mestra ou segredos em texto puro."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Se você esquecer a senha mestra, os dados criptografados não podem ser recuperados. Guarde-a com segurança."
- },
- "logout": {
- "title": "Sair e dados locais",
- "body": "Ao sair, o app limpa segredos em memória, estado do cofre e material de chave mestra do IndexedDB quando aplicável, e encerra a sessão da API. O que estiver apenas no armazenamento local pode permanecer até você limpar no navegador."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Personalize as ferramentas visíveis em Configurações para manter a barra lateral focada."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json
index fc18ad51..26d45581 100644
--- a/apps/web/messages/pt.json
+++ b/apps/web/messages/pt.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "O que é o MyDevTools?",
- "description": "Um espaço de trabalho para programadores com sessão iniciada: um painel e um conjunto de ferramentas para o dia a dia.",
- "p1": "Depois de iniciar sessão, a app estabelece uma sessão com a API do MyDevTools para que as ferramentas possam carregar e guardar os seus dados. A barra lateral lista todas as apps; pode ocultar ferramentas que não utiliza em",
- "settingsLink": "Definições",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Algumas funcionalidades usam uma palavra-passe mestre (ou chave global do cofre) para que segredos como palavras-passe e strings de ligação sejam encriptados no seu dispositivo antes de chegarem ao servidor."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Ligações rápidas",
- "dashboard": "Painel",
- "settings": "Definições"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Use esta ferramenta a partir da barra lateral para o seu fluxo de trabalho dedicado."
},
"security": {
- "session": {
- "title": "Sessão e API",
- "description": "Identidade e como o browser comunica com o seu backend.",
- "items": {
- "authentication": "Autenticação via Firebase. Após o login, a app troca o seu Firebase ID token por cookies da API.",
- "accessTokens": "Tokens de acesso são JWT assinados no servidor (HS256). Provam qual utilizador está a chamar a API.",
- "refreshTokens": "Tokens de renovação são guardados em cookies HttpOnly; o servidor guarda um hash SHA-256 do token, não o token em bruto."
- }
- },
"encryption": {
"title": "Encriptação importante",
"description": "Criptografia no cliente para o cofre e dados sensíveis de ligação.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 com SHA-256, 100.000 iterações e um salt aleatório deriva uma chave de 256 bits a partir da palavra-passe mestre.",
"aes": "AES-256-GCM encripta os dados; é usado um IV de 12 bytes aleatório por cada encriptação.",
- "scope": "A chave derivada fica no browser (não extraível). O servidor recebe apenas ciphertext e IVs, não a palavra-passe mestre nem segredos em texto simples."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Se esquecer a palavra-passe mestre, os dados encriptados não podem ser recuperados. Guarde-a em segurança."
- },
- "logout": {
- "title": "Terminar sessão e dados locais",
- "body": "Terminar sessão limpa segredos em memória, estado do cofre e material da chave mestre do IndexedDB quando aplicável, e termina a sessão da API. Dados guardados apenas no armazenamento local podem permanecer até os limpar no browser."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Personalize as ferramentas visíveis nas Definições para manter a barra lateral focada."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json
index f72a5bae..a4b586b6 100644
--- a/apps/web/messages/ru.json
+++ b/apps/web/messages/ru.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Что такое MyDevTools?",
- "description": "Рабочее пространство разработчика с входом: панель и набор инструментов на каждый день.",
- "p1": "После входа приложение устанавливает сессию с API MyDevTools, чтобы инструменты могли загружать и сохранять ваши данные. Боковая панель показывает все приложения; ненужные инструменты можно скрыть в",
- "settingsLink": "Настройках",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Некоторые функции используют мастер‑пароль (или глобальный ключ хранилища), чтобы секреты — например, пароли и строки подключения — шифровались на вашем устройстве до отправки на сервер."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Быстрые ссылки",
- "dashboard": "Панель управления",
- "settings": "Настройки"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Используйте этот инструмент через боковую панель — там находится его основной рабочий процесс."
},
"security": {
- "session": {
- "title": "Вход и API‑сессия",
- "description": "Идентификация и то, как браузер взаимодействует с бэкендом.",
- "items": {
- "authentication": "Аутентификация выполняется через Firebase. После входа приложение обменивает Firebase ID‑токен на cookies API.",
- "accessTokens": "Access‑токены — это JWT, подписанные на сервере (HS256), которые подтверждают пользователя при вызове API.",
- "refreshTokens": "Refresh‑токены хранятся в HttpOnly cookies; сервер сохраняет SHA‑256 хэш refresh‑токена, а не сам токен."
- }
- },
"encryption": {
"title": "Важное о шифровании",
"description": "Криптография на стороне клиента для хранилища и чувствительных данных подключений.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 с SHA‑256, 100 000 итераций и случайной солью выводит 256‑битный ключ из мастер‑пароля.",
"aes": "AES‑256‑GCM шифрует данные; для каждого шифрования используется случайный IV 12 байт.",
- "scope": "Производный ключ остаётся в браузере (не экспортируется). Сервер получает только шифртекст и IV, а не мастер‑пароль или секреты в открытом виде."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Если вы забудете мастер‑пароль, зашифрованные данные восстановить нельзя. Храните его в безопасности."
- },
- "logout": {
- "title": "Выход и локальные данные",
- "body": "Выход очищает секреты в памяти, состояние хранилища и (если применимо) материал мастер‑ключа в IndexedDB, а также завершает API‑сессию. Данные, сохранённые только в локальном хранилище устройства, могут оставаться до очистки в браузере."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Настройте видимые инструменты в Настройках, чтобы боковая панель оставалась компактной."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json
index 822fba2a..93865750 100644
--- a/apps/web/messages/sv.json
+++ b/apps/web/messages/sv.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Vad är MyDevTools?",
- "description": "En utvecklar‑workspace med inloggning: ett instrumentpanel och en uppsättning verktyg för vardagen.",
- "p1": "Efter att du loggat in skapar appen en session med MyDevTools API så att verktygen kan läsa in och spara dina data. Sidofältet listar alla appar; du kan dölja verktyg du inte använder i",
- "settingsLink": "Inställningar",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Vissa funktioner använder ett huvudlösenord (eller global vault‑nyckel) så att hemligheter som lösenord och anslutningssträngar krypteras på din enhet innan de når servern."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Snabblänkar",
- "dashboard": "Instrumentpanel",
- "settings": "Inställningar"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Använd det här verktyget från sidofältet för dess dedikerade arbetsflöde."
},
"security": {
- "session": {
- "title": "Inloggning och API‑session",
- "description": "Identitet och hur webbläsaren pratar med din backend.",
- "items": {
- "authentication": "Autentisering använder Firebase för inloggning. Efter inloggning byter appen din Firebase ID‑token mot API‑cookies.",
- "accessTokens": "Åtkomsttoken är JWT:er signerade på servern (HS256) som bevisar vilken användare som anropar API:et.",
- "refreshTokens": "Refresh‑token lagras i HttpOnly cookies; servern sparar en SHA‑256‑hash av token, inte token i klartext."
- }
- },
"encryption": {
"title": "Kryptering du bör känna till",
"description": "Klientside‑krypto för vault och känslig anslutningsdata.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 med SHA‑256, 100 000 iterationer och slumpmässig salt härleder en 256‑bitars nyckel från ditt huvudlösenord.",
"aes": "AES‑256‑GCM krypterar data; en slumpmässig 12‑byte IV används per kryptering.",
- "scope": "Den härledda nyckeln stannar i webbläsaren (ej exporterbar). Servern tar bara emot ciphertext och IV, inte ditt huvudlösenord eller hemligheter i klartext."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Om du glömmer huvudlösenordet kan krypterade data inte återställas. Förvara det säkert."
- },
- "logout": {
- "title": "Logga ut och lokal data",
- "body": "Utloggning rensar hemligheter i minnet, vault‑status och (om tillämpligt) huvudnyckelmaterial från IndexedDB och avslutar API‑sessionen. Data som endast finns i lokal lagring kan ligga kvar tills du rensar den i webbläsaren."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Anpassa synliga verktyg i Inställningar för att hålla sidofältet fokuserat."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json
index e48b2d98..63074c9f 100644
--- a/apps/web/messages/tr.json
+++ b/apps/web/messages/tr.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "MyDevTools nedir?",
- "description": "Giriş yapılan bir geliştirici çalışma alanı: bir gösterge paneli ve günlük araçlar.",
- "p1": "Hesabınızla giriş yaptıktan sonra uygulama, araçların verilerinizi yükleyip kaydedebilmesi için MyDevTools API ile bir oturum kurar. Yan menü tüm uygulamaları listeler; kullanmadığınız araçları",
- "settingsLink": "Ayarlar",
- "p1After": "sayfasından gizleyebilirsiniz.",
- "p2": "Bazı özellikler ana parola (veya global kasa anahtarı) kullanır; böylece parolalar ve bağlantı dizeleri gibi sırlar sunucuya ulaşmadan önce cihazınızda şifrelenir."
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
+ "p1After": ".",
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Hızlı bağlantılar",
- "dashboard": "Gösterge Paneli",
- "settings": "Ayarlar"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Bu aracı yan menüden, kendi iş akışı üzerinden kullanın."
},
"security": {
- "session": {
- "title": "Giriş ve API oturumu",
- "description": "Kimlik ve tarayıcının backend ile nasıl konuştuğu.",
- "items": {
- "authentication": "Kimlik doğrulama için Firebase kullanılır. Girişten sonra uygulama Firebase ID token’ınızı API çerezleriyle değiştirir.",
- "accessTokens": "Erişim token’ları sunucuda imzalanan JWT’lerdir (HS256) ve API’yi çağıran kullanıcıyı kanıtlar.",
- "refreshTokens": "Yenileme token’ları HttpOnly çerezlerde saklanır; sunucu ham token yerine SHA-256 hash’ini tutar."
- }
- },
"encryption": {
"title": "Bilmeniz gereken şifreleme",
"description": "Kasa ve hassas bağlantı verileri için istemci tarafı kripto.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 (SHA-256, 100.000 iterasyon, rastgele salt) ana parolanızdan 256-bit anahtar türetir.",
"aes": "AES-256-GCM veriyi şifreler; her şifreleme için rastgele 12 bayt IV kullanılır.",
- "scope": "Türetilen anahtar tarayıcıda kalır (dışa aktarılamaz). Sunucu yalnızca şifreli metin ve IV alır; ana parola veya düz metin sırlar gönderilmez."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Ana parolanızı unutursanız, şifrelenmiş veriler kurtarılamaz. Güvenle saklayın."
- },
- "logout": {
- "title": "Çıkış ve yerel veriler",
- "body": "Çıkış yapmak bellek içindeki sırları, kasa durumunu ve (varsa) IndexedDB’deki ana anahtar materyalini temizler ve API oturumunu sonlandırır. Yalnızca yerel depoda tutulan veriler, tarayıcıdan temizleyene kadar kalabilir."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Yan menüyü odaklı tutmak için Ayarlar’dan görünür araçları özelleştirin."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json
index c641bd85..0b80b1e3 100644
--- a/apps/web/messages/uk.json
+++ b/apps/web/messages/uk.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "Що таке MyDevTools?",
- "description": "Робочий простір розробника з входом: панель та набір інструментів для щоденних задач.",
- "p1": "Після входу застосунок встановлює сесію з MyDevTools API, щоб інструменти могли завантажувати та зберігати ваші дані. Бічна панель показує всі застосунки; непотрібні інструменти можна приховати в",
- "settingsLink": "Налаштуваннях",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Деякі функції використовують майстер‑пароль (або глобальний ключ сховища), щоб секрети на кшталт паролів і рядків підключення шифрувалися на вашому пристрої до того, як потраплять на сервер."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Швидкі посилання",
- "dashboard": "Панель",
- "settings": "Налаштування"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Користуйтеся цим інструментом з бічної панелі — там знаходиться його основний робочий процес."
},
"security": {
- "session": {
- "title": "Вхід і API‑сесія",
- "description": "Ідентичність і те, як браузер взаємодіє з бекендом.",
- "items": {
- "authentication": "Автентифікація використовує Firebase для входу. Після входу застосунок обмінює Firebase ID‑токен на cookies API.",
- "accessTokens": "Токени доступу — це JWT, підписані на сервері (HS256), які підтверджують користувача при виклику API.",
- "refreshTokens": "Токени оновлення зберігаються в HttpOnly cookies; сервер зберігає SHA-256 хеш токена, а не сам токен."
- }
- },
"encryption": {
"title": "Шифрування, яке варто знати",
"description": "Криптографія на стороні клієнта для сховища та чутливих даних підключень.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 з SHA-256, 100 000 ітерацій і випадковою сіллю виводить 256‑бітний ключ з майстер‑пароля.",
"aes": "AES‑256‑GCM шифрує дані; для кожного шифрування використовується випадковий IV 12 байт.",
- "scope": "Похідний ключ залишається в браузері (не експортується). Сервер отримує лише шифртекст та IV, а не майстер‑пароль чи секрети у відкритому вигляді."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Якщо ви забудете майстер‑пароль, зашифровані дані неможливо відновити. Зберігайте його безпечно."
- },
- "logout": {
- "title": "Вихід і локальні дані",
- "body": "Вихід очищає секрети в пам’яті, стан сховища та (за потреби) матеріал майстер‑ключа з IndexedDB, а також завершує API‑сесію. Дані, що зберігаються лише в локальному сховищі, можуть залишатися, доки ви не очистите їх у браузері."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Налаштуйте видимі інструменти в Налаштуваннях, щоб бічна панель залишалася охайною."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json
index 8f1e39b5..1d8144e4 100644
--- a/apps/web/messages/vi.json
+++ b/apps/web/messages/vi.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "MyDevTools là gì?",
- "description": "Một không gian làm việc dành cho dev có đăng nhập: bảng điều khiển và bộ công cụ hằng ngày.",
- "p1": "Sau khi đăng nhập, ứng dụng thiết lập phiên với MyDevTools API để các công cụ có thể tải và lưu dữ liệu của bạn. Thanh bên liệt kê mọi ứng dụng; bạn có thể ẩn các công cụ không dùng trong",
- "settingsLink": "Cài đặt",
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
"p1After": ".",
- "p2": "Một số tính năng dùng mật khẩu chính (hoặc khóa vault toàn cục) để các bí mật như mật khẩu và chuỗi kết nối được mã hóa trên thiết bị của bạn trước khi đến máy chủ."
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "Liên kết nhanh",
- "dashboard": "Bảng điều khiển",
- "settings": "Cài đặt"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "Hãy dùng công cụ này từ thanh bên theo workflow riêng của nó."
},
"security": {
- "session": {
- "title": "Đăng nhập và phiên API",
- "description": "Danh tính và cách trình duyệt giao tiếp với backend.",
- "items": {
- "authentication": "Xác thực dùng Firebase để đăng nhập. Sau đó ứng dụng đổi Firebase ID token sang cookie API.",
- "accessTokens": "Access token là JWT được ký trên máy chủ (HS256) và xác định người dùng gọi API.",
- "refreshTokens": "Refresh token được lưu trong HttpOnly cookie; máy chủ lưu hash SHA-256 của refresh token chứ không lưu token thô."
- }
- },
"encryption": {
"title": "Mã hóa cần biết",
"description": "Crypto phía client cho vault và dữ liệu kết nối nhạy cảm.",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2 với SHA-256, 100.000 vòng lặp và salt ngẫu nhiên để suy ra khóa 256-bit từ mật khẩu chính.",
"aes": "AES-256-GCM mã hóa dữ liệu; mỗi lần mã hóa dùng IV 12 byte ngẫu nhiên.",
- "scope": "Khóa suy ra ở lại trong trình duyệt (không trích xuất được). Máy chủ chỉ nhận bản mã và IV, không nhận mật khẩu chính hay bí mật dạng rõ."
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "Nếu bạn quên mật khẩu chính, dữ liệu đã mã hóa sẽ không thể khôi phục. Hãy lưu giữ cẩn thận."
- },
- "logout": {
- "title": "Đăng xuất và dữ liệu cục bộ",
- "body": "Đăng xuất sẽ xóa bí mật trong bộ nhớ, trạng thái vault, và (khi áp dụng) vật liệu khóa chính khỏi IndexedDB, đồng thời kết thúc phiên API. Dữ liệu chỉ nằm trong local storage có thể vẫn còn cho đến khi bạn tự xóa trong trình duyệt."
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "Tùy chỉnh công cụ hiển thị trong Cài đặt để thanh bên gọn và tập trung hơn."
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json
index a4bb0cf0..ee67f485 100644
--- a/apps/web/messages/zh.json
+++ b/apps/web/messages/zh.json
@@ -90,16 +90,16 @@
"overview": {
"whatIs": {
"title": "什么是 MyDevTools?",
- "description": "一个可登录的开发者工作区:包含控制台与日常工具集合。",
- "p1": "登录后,应用会与 MyDevTools API 建立会话,让工具能够加载与保存你的数据。侧边栏列出所有应用;你可以在",
- "settingsLink": "设置",
- "p1After": "中隐藏不常用的工具。",
- "p2": "部分功能使用 主密码(或全局保险库密钥),使密码、数据库连接串等敏感信息在到达服务器前先在你的设备上完成加密。"
+ "description": "A local-first developer toolkit: a desktop app with 80+ tools for everyday tasks.",
+ "p1": "Everything runs on your device — no account, no sign-in, no server. The sidebar lists every app; you can hide tools you do not use from",
+ "settingsLink": "Settings",
+ "p1After": ".",
+ "p2": "Some features use a master password (or global vault key) so that secrets like passwords and database connection strings are encrypted at rest on your device."
},
"quickLinks": {
"title": "快捷链接",
- "dashboard": "控制台",
- "settings": "设置"
+ "download": "Download",
+ "security": "Security"
}
},
"apps": {
@@ -109,15 +109,6 @@
"generic": "请从侧边栏使用该工具的专用工作流程。"
},
"security": {
- "session": {
- "title": "登录与 API 会话",
- "description": "身份与浏览器如何与后端通信。",
- "items": {
- "authentication": "认证 使用 Firebase 登录。登录后,应用会将 Firebase ID Token 交换为 API Cookie。",
- "accessTokens": "访问令牌 为服务器签名的 JWT(HS256),用于证明调用 API 的用户身份。",
- "refreshTokens": "刷新令牌 存放在 HttpOnly Cookie 中;服务器保存的是刷新令牌的 SHA-256 哈希值,而不是原始令牌。"
- }
- },
"encryption": {
"title": "你需要了解的加密",
"description": "保险库与敏感连接数据的客户端加密。",
@@ -125,13 +116,9 @@
"items": {
"kdf": "PBKDF2(SHA-256、100,000 次迭代与随机盐)从主密码派生 256 位密钥。",
"aes": "AES-256-GCM 加密数据;每次加密都会使用随机的 12 字节 IV。",
- "scope": "派生密钥保留在浏览器中(不可导出)。服务器只接收 密文与 IV,不会接收主密码或明文秘密。"
+ "scope": "The derived key stays in the app (non-extractable) and only ciphertext and IVs are written to disk — never your master password or plaintext secrets."
},
"warning": "如果你忘记主密码,加密数据将无法恢复。请妥善保管。"
- },
- "logout": {
- "title": "退出登录与本地数据",
- "body": "退出登录会清除内存中的敏感信息、保险库相关状态,并在适用时从 IndexedDB 清除主密钥材料,同时结束 API 会话。仅保存在设备本地存储中的数据可能会保留,直到你在浏览器中清理。"
}
},
"tips": {
@@ -144,32 +131,5 @@
"settings": "在设置中自定义可见工具,让侧边栏更聚焦。"
}
}
- },
- "Dashboard": {
- "title": "Dashboard",
- "navPlan": "Plan & billing",
- "navSecurity": "Security",
- "passkeysTitle": "Passkeys",
- "passkeysDescription": "Passwordless sign-in for your account. Add a passkey to sign in with Face ID, Touch ID, or your device PIN.",
- "passkeyNotSupported": "Your browser does not support passkeys.",
- "passkeysEmpty": "No passkeys yet. Add one to sign in without a password.",
- "addPasskey": "Add passkey",
- "namePrompt": "Name this passkey (e.g. \"MacBook\", \"iPhone\")",
- "renamePrompt": "Rename this passkey",
- "deleteConfirm": "Remove this passkey? You won't be able to sign in with it anymore.",
- "unnamedPasskey": "Unnamed passkey",
- "backedUp": "Synced",
- "added": "Added",
- "lastUsed": "Last used",
- "neverUsed": "Never used",
- "rename": "Rename",
- "delete": "Delete",
- "passkeyAdded": "Passkey added",
- "passkeyAddError": "Could not add passkey",
- "passkeyRenamed": "Passkey renamed",
- "passkeyRenameError": "Could not rename passkey",
- "passkeyDeleted": "Passkey removed",
- "passkeyDeleteError": "Could not remove passkey",
- "passkeysLoadError": "Could not load passkeys"
}
}
diff --git a/apps/web/package.json b/apps/web/package.json
index a725ee01..e5b90a6c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -25,14 +25,12 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
- "@simplewebauthn/browser": "^11.0.0",
"@space-man/react-theme-animation": "^1.1.1",
"@tabler/icons-react": "^3.35.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "firebase": "^12.5.0",
"framer-motion": "^12.23.24",
"geist": "^1.5.1",
"lucide-react": "^0.552.0",
diff --git a/apps/web/src/app/account/plan/page.tsx b/apps/web/src/app/account/plan/page.tsx
index 136d1f13..4025ead8 100644
--- a/apps/web/src/app/account/plan/page.tsx
+++ b/apps/web/src/app/account/plan/page.tsx
@@ -1,6 +1,7 @@
// URL kept alive: shipped desktop builds hard-open /account/plan.
+// There are no accounts or plans any more — send it to the download page.
import { redirect } from 'next/navigation'
export default function AccountPlanPage() {
- redirect('/dashboard')
+ redirect('/download')
}
diff --git a/apps/web/src/app/api/backend/[...path]/route.ts b/apps/web/src/app/api/backend/[...path]/route.ts
deleted file mode 100644
index 7500279a..00000000
--- a/apps/web/src/app/api/backend/[...path]/route.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { NextRequest, NextResponse } from "next/server"
-
-const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL
-
-function requireBaseUrl() {
- if (!FASTAPI_BASE_URL) {
- return NextResponse.json(
- { error: "NEXT_PUBLIC_FASTAPI_BASE_URL is not configured" },
- { status: 500 }
- )
- }
- return null
-}
-
-function appendSetCookiesFromUpstream(upstream: Response, res: NextResponse) {
- const anyHeaders = upstream.headers as Headers & { getSetCookie?: () => string[] }
- if (typeof anyHeaders.getSetCookie === "function") {
- for (const c of anyHeaders.getSetCookie()) {
- res.headers.append("Set-Cookie", c)
- }
- return
- }
- const single = upstream.headers.get("set-cookie")
- if (single) {
- res.headers.append("Set-Cookie", single)
- }
-}
-
-async function forward(req: NextRequest, method: string, pathSegments: string[]) {
- const baseUrlError = requireBaseUrl()
- if (baseUrlError) return baseUrlError
-
- const url = new URL(req.url)
- const upstreamUrl = new URL(`/api/v1/${pathSegments.join("/")}`, FASTAPI_BASE_URL)
- upstreamUrl.search = url.search
-
- const headers: Record = {}
- const auth = req.headers.get("authorization")
- if (auth) headers["authorization"] = auth
-
- const cookie = req.headers.get("cookie")
- if (cookie) headers["cookie"] = cookie
-
- const contentType = req.headers.get("content-type")
- if (contentType) headers["content-type"] = contentType
-
- // Forward the real client's User-Agent + IP so the backend audit log records
- // the actual device, not this Next.js server's fetch agent.
- const userAgent = req.headers.get("user-agent")
- if (userAgent) headers["user-agent"] = userAgent
-
- const forwardedFor = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip")
- if (forwardedFor) headers["x-forwarded-for"] = forwardedFor
-
- let body: BodyInit | undefined = undefined
- if (method !== "GET" && method !== "HEAD") {
- body = await req.text()
- }
-
- const upstreamRes = await fetch(upstreamUrl.toString(), {
- method,
- headers,
- body,
- cache: "no-store",
- })
-
- // Per HTTP spec, 204/304 MUST NOT carry a body — the Response constructor
- // throws if one is provided. Return body-less here regardless of upstream
- // Content-Type. (FastAPI sets Content-Type: application/json on every route
- // even when status_code=204 returns no body.)
- if (upstreamRes.status === 204 || upstreamRes.status === 304) {
- const res = new NextResponse(null, { status: upstreamRes.status })
- appendSetCookiesFromUpstream(upstreamRes, res)
- return res
- }
-
- const upstreamContentType = upstreamRes.headers.get("content-type") || ""
- if (!upstreamContentType.includes("application/json")) {
- const text = await upstreamRes.text()
- const res = new NextResponse(text, {
- status: upstreamRes.status,
- headers: {
- "content-type": upstreamContentType || "text/plain",
- },
- })
- appendSetCookiesFromUpstream(upstreamRes, res)
- return res
- }
-
- const json = await upstreamRes.json().catch(() => null)
- const res = NextResponse.json(json, { status: upstreamRes.status })
- appendSetCookiesFromUpstream(upstreamRes, res)
- return res
-}
-
-export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
- const { path } = await ctx.params
- return forward(req, "GET", path)
-}
-
-export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
- const { path } = await ctx.params
- return forward(req, "POST", path)
-}
-
-export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
- const { path } = await ctx.params
- return forward(req, "PATCH", path)
-}
-
-export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
- const { path } = await ctx.params
- return forward(req, "DELETE", path)
-}
diff --git a/apps/web/src/app/api/proxy/route.ts b/apps/web/src/app/api/proxy/route.ts
deleted file mode 100644
index 492296af..00000000
--- a/apps/web/src/app/api/proxy/route.ts
+++ /dev/null
@@ -1,432 +0,0 @@
-import { requireBackendSession } from "@/lib/require-backend-session"
-import { NextRequest, NextResponse } from "next/server"
-
-// Node runtime + raised wall-clock budget so big multipart bodies and slow upstreams
-// don't get cut off by the platform's default 10s edge limit.
-export const runtime = "nodejs"
-export const maxDuration = 60
-
-// ── SSRF Protection: only allow proxying to the configured backend ────────────
-const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || "http://localhost:8000"
-
-/** Resolve the allowed backend host once at module load. */
-function getAllowedHost(): string {
- try {
- return new URL(FASTAPI_BASE_URL).host
- } catch {
- return "localhost:8000"
- }
-}
-
-const ALLOWED_HOST = getAllowedHost()
-
-const DEFAULT_PROXY_TIMEOUT_MS = 30_000
-const MAX_PROXY_TIMEOUT_MS = 55_000 // stay under `maxDuration` so we surface a timeout, not a 504
-const MAX_REDIRECTS = Number(process.env.PROXY_MAX_REDIRECTS ?? 5)
-
-/** Pick an effective per-request timeout, clamped to the platform budget. */
-function resolveTimeoutMs(userValue: unknown): number {
- const n = Number(userValue)
- if (!Number.isFinite(n) || n <= 0) return DEFAULT_PROXY_TIMEOUT_MS
- return Math.min(Math.floor(n), MAX_PROXY_TIMEOUT_MS)
-}
-
-/**
- * Heuristic: is this content-type safe to decode as UTF-8 text?
- * Default to base64 for anything else — zip/xlsx/fonts/octet-stream were previously
- * returned as garbled text strings.
- */
-function isTextualContentType(ct: string): boolean {
- if (!ct) return false
- const lower = ct.toLowerCase()
- if (lower.startsWith("text/")) return true
- if (lower.includes("json")) return true
- if (lower.includes("xml")) return true
- if (lower.includes("javascript") || lower.includes("ecmascript")) return true
- if (lower.includes("html")) return true
- if (lower.includes("yaml")) return true
- if (lower.includes("csv")) return true
- if (lower.includes("urlencoded")) return true
- if (lower.includes("graphql")) return true
- if (lower.includes("x-ndjson")) return true
- return false
-}
-
-/** In `next dev`, NODE_ENV is `development` — allow localhost/private targets without extra env (metadata still blocked). */
-function allowPrivateProxyTargets(): boolean {
- return (
- (process.env.ALLOW_PRIVATE_PROXY_TARGETS || "").toLowerCase() === "true" ||
- process.env.NODE_ENV !== "production"
- )
-}
-
-/**
- * Block SSRF-prone targets. Always allows the configured FastAPI host (port must match).
- * In non-production, allows localhost/private IPs except well-known metadata endpoints.
- */
-function isBlockedRequestTarget(hostname: string, host: string): boolean {
- if (host === ALLOWED_HOST) {
- return false
- }
-
- const hl = hostname.toLowerCase()
-
- if (allowPrivateProxyTargets()) {
- const metadataHosts = [
- "169.254.169.254",
- "metadata.google.internal",
- "metadata.google",
- "100.100.100.200",
- ]
- return metadataHosts.some((b) => hl === b)
- }
-
- const ipv6Bare = hl.replace(/^\[|\]$/g, "")
- const probablyIpv6 = hl.includes(":")
-
- if (hl === "localhost" || hl.endsWith(".localhost")) return true
- if (hl.endsWith(".local")) return true
- if (hl.endsWith(".internal")) return true
- if (ipv6Bare === "::1") return true
- if (probablyIpv6) {
- if (ipv6Bare.startsWith("fe80:")) return true
- if (ipv6Bare.startsWith("fc") || ipv6Bare.startsWith("fd")) return true
- }
-
- // Block common internal/metadata endpoints
- const blocked = [
- "169.254.169.254", // AWS/GCP metadata
- "metadata.google.internal",
- "metadata.google",
- "100.100.100.200", // Alibaba metadata
- "fd00::",
- "[::1]",
- "0.0.0.0",
- ]
- if (blocked.some((b) => hostname === b)) return true
-
- // Block private IP ranges
- const parts = hostname.split(".")
- if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {
- const a = parseInt(parts[0]!)
- const b = parseInt(parts[1]!)
- if (a === 10) return true // 10.0.0.0/8
- if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
- if (a === 192 && b === 168) return true // 192.168.0.0/16
- if (a === 127) return true // 127.0.0.0/8
- if (a === 0) return true // 0.0.0.0/8
- }
-
- return false
-}
-
-/** Apply both guards (SSRF + scheme) consistently for every hop. Throws on block. */
-function assertHopAllowed(parsed: URL): void {
- if (isBlockedRequestTarget(parsed.hostname, parsed.host)) {
- throw new ProxyHopBlockedError(`Blocked target: ${parsed.hostname}`)
- }
- if (!["http:", "https:"].includes(parsed.protocol)) {
- throw new ProxyHopBlockedError(`Blocked scheme: ${parsed.protocol}`)
- }
-}
-
-class ProxyHopBlockedError extends Error {
- readonly isProxyBlock = true
-}
-
-interface RedirectHop {
- url: string
- status: number
-}
-
-interface FetchWithRedirectsResult {
- response: Response
- finalUrl: string
- /** All hops walked BEFORE the final response. Final hop not included. */
- chain: RedirectHop[]
-}
-
-/**
- * Manual redirect follower so each hop runs through `isBlockedRequestTarget`.
- * Default `fetch` (redirect: "follow") resolves redirects inside undici without
- * giving us a chance to inspect — an open-redirect on the target host could land
- * us on `169.254.169.254` or another internal IP.
- */
-async function fetchFollowingRedirects(args: {
- initialUrl: string
- method: string
- headers: Record
- buildBody: () => BodyInit | undefined
- signal: AbortSignal
-}): Promise {
- const { initialUrl, headers, buildBody, signal } = args
- const chain: RedirectHop[] = []
- let currentUrl = initialUrl
- let currentMethod = args.method
- let dropBody = false
-
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
- const parsed = new URL(currentUrl)
- assertHopAllowed(parsed)
-
- const useBody =
- !dropBody && currentMethod !== "GET" && currentMethod !== "HEAD"
- ? buildBody()
- : undefined
-
- const response = await fetch(currentUrl, {
- method: currentMethod,
- headers,
- body: useBody,
- redirect: "manual",
- signal,
- })
-
- const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304
- if (!isRedirect) {
- return { response, finalUrl: currentUrl, chain }
- }
-
- const location = response.headers.get("location")
- if (!location) {
- // 30x without Location — treat as terminal.
- return { response, finalUrl: currentUrl, chain }
- }
-
- chain.push({ url: currentUrl, status: response.status })
-
- const nextUrl = new URL(location, currentUrl).toString()
-
- // RFC 7231 §6.4.4: 303 always switches to GET and drops the body.
- // 301/302 historically (and matching browser fetch) switch POST/PUT/… to GET.
- // 307/308 preserve both method and body.
- if (response.status === 303) {
- currentMethod = "GET"
- dropBody = true
- } else if (
- (response.status === 301 || response.status === 302) &&
- currentMethod !== "GET" &&
- currentMethod !== "HEAD"
- ) {
- currentMethod = "GET"
- dropBody = true
- }
-
- currentUrl = nextUrl
- }
-
- throw new ProxyHopBlockedError(`Too many redirects (>${MAX_REDIRECTS})`)
-}
-
-/** Pull every Set-Cookie header as a discrete entry — Fetch joins them with ", " otherwise. */
-function readSetCookies(headers: Headers): string[] {
- type WithGetSetCookie = Headers & { getSetCookie?: () => string[] }
- const h = headers as WithGetSetCookie
- if (typeof h.getSetCookie === "function") {
- return h.getSetCookie()
- }
- const joined = headers.get("set-cookie")
- // Fallback for older runtimes — best-effort, single entry.
- return joined ? [joined] : []
-}
-
-export async function POST(req: NextRequest) {
- try {
- const authError = await requireBackendSession(req)
- if (authError) return authError
-
- const { url, method, headers, body, timeoutMs } = await req.json()
-
- if (!url) {
- return NextResponse.json({
- status: 400,
- statusText: "Bad Request",
- headers: {},
- body: "URL is required",
- time: 0,
- size: 0,
- error: "URL is required",
- })
- }
-
- let parsed: URL
- try {
- parsed = new URL(url)
- } catch {
- return NextResponse.json({
- status: 400,
- statusText: "Bad Request",
- headers: {},
- body: "Invalid URL format",
- time: 0,
- size: 0,
- error: "Invalid URL format",
- })
- }
-
- try {
- assertHopAllowed(parsed)
- } catch (e) {
- const msg = (e as Error).message
- return NextResponse.json({
- status: 403,
- statusText: "Forbidden",
- headers: {},
- body: msg.startsWith("Blocked scheme")
- ? "Only HTTP(S) URLs are allowed"
- : "Requests to internal/private addresses are not allowed",
- time: 0,
- size: 0,
- error: msg,
- })
- }
-
- const startTime = performance.now()
-
- const effectiveTimeoutMs = resolveTimeoutMs(timeoutMs)
- const proxyController = new AbortController()
- const proxyTimeout = setTimeout(() => proxyController.abort(), effectiveTimeoutMs)
-
- // Propagate client disconnect (Strict Mode unmount, navigation) to upstream so
- // we don't keep reading a response no one will receive.
- const onClientAbort = () => proxyController.abort()
- req.signal.addEventListener("abort", onClientAbort, { once: true })
-
- const requestHeaders = { ...(headers || {}) } as Record
-
- // ── Cookie forwarding: ONLY forward cookies to the trusted backend ───
- const isBackendRequest = parsed.host === ALLOWED_HOST
- const incomingCookie = req.headers.get("cookie")
- if (isBackendRequest && incomingCookie && !Object.keys(requestHeaders).some((k) => k.toLowerCase() === "cookie")) {
- requestHeaders["cookie"] = incomingCookie
- }
-
- // For trusted backend calls only, forward the real client's User-Agent + IP so the
- // audit log records the actual device (not this server's fetch agent). Never leak
- // these to arbitrary SSRF-checked targets.
- if (isBackendRequest) {
- const hasHeader = (name: string) =>
- Object.keys(requestHeaders).some((k) => k.toLowerCase() === name)
- const userAgent = req.headers.get("user-agent")
- if (userAgent && !hasHeader("user-agent")) requestHeaders["user-agent"] = userAgent
- const forwardedFor = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip")
- if (forwardedFor && !hasHeader("x-forwarded-for")) requestHeaders["x-forwarded-for"] = forwardedFor
- }
-
- // Body builder: called per hop so 307/308 redirects can re-emit the same payload
- // (FormData streams are consumed after one fetch and can't be reused directly).
- const isMultipart =
- body && typeof body === "object" && body.mode === "form-data" && Array.isArray(body.entries)
-
- const buildBody = (): BodyInit | undefined => {
- if (!body) return undefined
- if (!isMultipart) return body as BodyInit
- const form = new FormData()
- for (const entry of body.entries) {
- if (!entry?.key) continue
- if (entry.type === "file") {
- if (!entry.fileContentBase64) continue
- const fileBuffer = Buffer.from(entry.fileContentBase64, "base64")
- const blob = new Blob([fileBuffer], { type: entry.fileType || "application/octet-stream" })
- form.append(entry.key, blob, entry.fileName || "upload.bin")
- } else {
- form.append(entry.key, entry.value || "")
- }
- }
- return form
- }
-
- if (isMultipart) {
- // Let undici set the multipart Content-Type with its own boundary.
- const contentTypeKey = Object.keys(requestHeaders).find((key) => key.toLowerCase() === "content-type")
- if (contentTypeKey) delete requestHeaders[contentTypeKey]
- }
-
- let walked: FetchWithRedirectsResult
- try {
- walked = await fetchFollowingRedirects({
- initialUrl: url,
- method,
- headers: requestHeaders,
- buildBody,
- signal: proxyController.signal,
- })
- } finally {
- clearTimeout(proxyTimeout)
- req.signal.removeEventListener("abort", onClientAbort)
- }
-
- const { response, chain: redirectChain } = walked
- const endTime = performance.now()
- const time = Math.round(endTime - startTime)
-
- const responseHeaders: Record = {}
- response.headers.forEach((value, key) => {
- responseHeaders[key] = value
- })
-
- const setCookies = readSetCookies(response.headers)
-
- const contentType = response.headers.get("content-type") || ""
- let responseBody: string
- let isBase64 = false
-
- if (isTextualContentType(contentType)) {
- responseBody = await response.text()
- } else {
- // Everything non-textual (octet-stream, zip/xlsx/font/protobuf/binary, also
- // missing content-type) goes through base64 so the client gets faithful bytes
- // it can preview-or-download instead of UTF-8-mangled garbage.
- const buffer = await response.arrayBuffer()
- responseBody = Buffer.from(buffer).toString("base64")
- isBase64 = true
- }
-
- const declaredLength = Number(response.headers.get("content-length"))
- const size = Number.isFinite(declaredLength) && declaredLength > 0
- ? declaredLength
- : isBase64
- ? Buffer.from(responseBody, "base64").length
- : Buffer.byteLength(responseBody, "utf8")
-
- return NextResponse.json({
- status: response.status,
- statusText: response.statusText,
- headers: responseHeaders,
- setCookies,
- redirectChain,
- body: responseBody,
- isBase64,
- time,
- size,
- })
-
- } catch (error) {
- const err = error as Error
- // Client aborted (Strict Mode unmount, navigation) — no point returning a body,
- // and trying to write one triggers Next's "ReadableStream is locked" pipe error.
- if (req.signal.aborted || err?.name === "AbortError") {
- return new NextResponse(null, { status: 499 })
- }
- if (err instanceof ProxyHopBlockedError) {
- return NextResponse.json({
- status: 403,
- statusText: "Forbidden",
- headers: {},
- body: err.message,
- time: 0,
- size: 0,
- error: err.message,
- })
- }
- return NextResponse.json({
- status: 0,
- statusText: "Error",
- headers: {},
- body: err.message,
- time: 0,
- size: 0,
- error: err.message,
- })
- }
-}
diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx
deleted file mode 100644
index 0d240327..00000000
--- a/apps/web/src/app/dashboard/page.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { useTranslations } from 'next-intl'
-import { KeyRound, Loader2 } from 'lucide-react'
-import { DashboardShell, type DashboardNavItem } from '@/components/dashboard-shell'
-import { PasskeysManager } from '@/components/passkeys-manager'
-import { useAccountProfile } from '@/lib/use-account-profile'
-
-export default function DashboardPage() {
- const t = useTranslations('Dashboard')
- const { profile } = useAccountProfile('/dashboard')
- const [tab, setTab] = useState('security')
-
- const items: DashboardNavItem[] = [
- { key: 'security', label: t('navSecurity'), icon: KeyRound },
- ]
-
- if (!profile) {
- return (
-
-
-
-
- )
-}
diff --git a/apps/web/src/app/download/page.tsx b/apps/web/src/app/download/page.tsx
index b25fbab8..3a5d0094 100644
--- a/apps/web/src/app/download/page.tsx
+++ b/apps/web/src/app/download/page.tsx
@@ -9,11 +9,11 @@ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://mydevtools.tech";
export const metadata: Metadata = {
title: "Download MyDevTools for macOS",
description:
- "Download the MyDevTools desktop app for macOS — your entire dev toolkit, native. Universal build for Apple Silicon and Intel, signed and notarized by Apple.",
+ "Download the MyDevTools desktop app for macOS — your entire dev toolkit, native. Completely offline, no account required, free for everyone. Universal build for Apple Silicon and Intel, signed and notarized by Apple.",
alternates: { canonical: `${baseUrl}/download` },
openGraph: {
title: "Download MyDevTools for macOS | MyDevTools",
- description: "Native macOS desktop app — signed & notarized. Apple Silicon and Intel.",
+ description: "Native macOS desktop app — offline, no account, free for everyone. Signed & notarized, Apple Silicon and Intel.",
url: `${baseUrl}/download`,
siteName: "MyDevTools",
type: "website",
@@ -38,8 +38,9 @@ export default function DownloadPage() {
native on your Mac.
- The full MyDevTools suite as a signed, notarized macOS app. Works offline
- and connects to your local databases — everything stays on your device.
+ The full MyDevTools suite as a signed, notarized macOS app. Works completely
+ offline and connects to your local databases — everything stays on your
+ device. No account, no sign-in, free for everyone.
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index cd60b699..d8c2787b 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -4,7 +4,6 @@ import Link from "next/link";
import { Menu, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "./modeToggle";
-import useAuth from "@/utils/useAuth";
import { Logo } from "./logo";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
@@ -50,13 +49,8 @@ type HeaderProps = {
export function Header({ showThemeToggle = true }: HeaderProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
- const { user } = useAuth(false);
- // Signed-in users go to the web dashboard (subscription/billing + passkeys);
- // everyone else gets the sign-in CTA. Default to "Get Started" until auth
- // resolves so SSR/first paint never flashes "Dashboard".
- const cta = user
- ? { href: "/dashboard", label: "Dashboard" }
- : { href: "/login", label: "Get Started" };
+ // No accounts — the only action is getting the app.
+ const cta = { href: "/download", label: "Download" };
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
diff --git a/apps/web/src/components/legal-agreement-footer.tsx b/apps/web/src/components/legal-agreement-footer.tsx
index d32cd234..688ecf19 100644
--- a/apps/web/src/components/legal-agreement-footer.tsx
+++ b/apps/web/src/components/legal-agreement-footer.tsx
@@ -10,7 +10,7 @@ import {
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
-const LAST_UPDATED = "July 22, 2026";
+const LAST_UPDATED = "August 13, 2026";
function TermsBody() {
return (
@@ -26,11 +26,10 @@ function TermsBody() {
-
2. Your account
+
2. No account
- You sign in with a supported provider, and we associate your account with your name and
- email address. You are responsible for keeping your account and sign-in credentials
- secure and for all activity under your account.
+ MyDevTools has no accounts and no sign-in. You download the app and use it. We do not
+ issue credentials, and there is nothing for you to register.
@@ -51,9 +50,9 @@ function TermsBody() {
4. Acceptable use
- You agree to use the Service lawfully and not to infringe others' rights, disrupt the
- Service, or attempt to access accounts or data that are not yours. We may suspend or
- terminate access for conduct that we reasonably believe violates these terms.
+ You agree to use the Service lawfully and not to infringe others' rights or attempt
+ to access data that is not yours. The app is licensed under the GNU AGPL v3; your rights
+ to use, modify, and redistribute it are governed by that license.
@@ -91,27 +90,28 @@ function PrivacyBody() {
1. Our approach
- MyDevTools is built to be private by design. The only personal information we hold on our
- servers is your account details. Everything you create with the tools stays on
- your device — it is not sent to us. Sensitive vault data is additionally encrypted on your
- device so that only you can read it.
+ MyDevTools is built to be private by design. The app has no accounts and no backend: we
+ hold no personal information about you at all. Everything you create with the tools stays
+ on your device — it is not sent to us. Sensitive vault data is additionally encrypted on
+ your device so that only you can read it.
2. Information we store
-
We store only the minimum needed to run your account:
-
-
Your name and email address, from the sign-in provider you use.
-
+
+ The app stores nothing with us. This website uses privacy-oriented analytics (page views
+ and aggregate usage) to understand what people find useful. Those analytics are not tied
+ to an identity, because there is no identity to tie them to.
+
3. Your tool data stays local
The content you create with the tools (notes, snippets, requests, keys, and other tool
- data) is saved locally on your device. It is not sent to us and is not part of your
- account.
+ data) is saved locally on your device. It is never sent to us — the app works fully
+ offline and has no server to send it to.
@@ -136,25 +136,25 @@ function PrivacyBody() {
6. How we use what we store
- We use your account information to provide the Service, provide support, communicate
- service-related messages, and comply with legal obligations.
+ Website analytics are used only to understand which pages and tools people find useful.
+ They are not used for advertising and are not shared or sold.
7. Retention & deletion
- We keep account data for as long as your account is active or as required by
- law. When you delete your account, we delete your account data; data stored on your device
- is removed when you delete it locally.
+ We hold no account data to retain or delete. Data stored on your device is removed when
+ you delete it locally or uninstall the app.
8. Your rights
- Depending on your location, you may have rights to access, correct, export, or delete your
- personal information. Contact us to exercise these rights.
+ Depending on your location, you may have rights to access, correct, export, or delete
+ personal information a service holds about you. We hold none, so there is nothing for us
+ to produce or erase. Contact us with any question about this.
diff --git a/apps/web/src/components/login-form.tsx b/apps/web/src/components/login-form.tsx
deleted file mode 100644
index d0ef00ff..00000000
--- a/apps/web/src/components/login-form.tsx
+++ /dev/null
@@ -1,314 +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";
-
-export function LoginForm() {
- 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 your Dashboard.";
- 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 (
-