From 5d3de37ff0122c3c8a3cf2eecc97c58e106f540e Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Mon, 11 May 2026 12:43:22 -0700 Subject: [PATCH] feat: add local dev persona FAB (#2146) * feat: add local dev persona fab * fix: time out stalled dev persona auth * fix: restrict dev persona auth to local deployments * chore: refresh static check dependencies --- .env.local.example | 1 + bun.lock | 23 ++-- convex/auth.ts | 15 +++ convex/lib/devAuth.test.ts | 44 +++++++ convex/lib/devAuth.ts | 10 ++ convex/users.ts | 56 ++++++++ package.json | 1 + src/components/AppProviders.tsx | 5 + src/components/DevPersonaFab.test.tsx | 179 +++++++++++++++++++++++++ src/components/DevPersonaFab.tsx | 182 ++++++++++++++++++++++++++ 10 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 convex/lib/devAuth.test.ts create mode 100644 convex/lib/devAuth.ts create mode 100644 src/components/DevPersonaFab.test.tsx create mode 100644 src/components/DevPersonaFab.tsx diff --git a/.env.local.example b/.env.local.example index 68ad06fe..b51f3452 100644 --- a/.env.local.example +++ b/.env.local.example @@ -4,6 +4,7 @@ VITE_CONVEX_SITE_URL= VITE_SOULHUB_SITE_URL= VITE_SOULHUB_HOST= VITE_SITE_MODE= +VITE_ENABLE_DEV_AUTH= SITE_URL=http://localhost:3000 CONVEX_SITE_URL= diff --git a/bun.lock b/bun.lock index 2b085b26..8c6a38df 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "h3": "2.0.1-rc.22", "ignore": "^7.0.5", "lucide-react": "1.14.0", + "mime": "^4.1.0", "monaco-editor": "^0.55.1", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -78,7 +79,7 @@ }, "packages/clawhub": { "name": "clawhub", - "version": "0.12.3", + "version": "0.13.0", "bin": { "clawdhub": "bin/clawdhub.js", "clawhub": "bin/clawdhub.js", @@ -357,23 +358,23 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], + "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], @@ -1179,7 +1180,7 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="], + "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], "nf3": ["nf3@0.3.16", "", {}, "sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw=="], diff --git a/convex/auth.ts b/convex/auth.ts index 6b541f3f..fc7c7b8e 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,9 +1,11 @@ import GitHub from "@auth/core/providers/github"; +import { ConvexCredentials } from "@convex-dev/auth/providers/ConvexCredentials"; import { convexAuth } from "@convex-dev/auth/server"; import type { GenericMutationCtx } from "convex/server"; import { ConvexError } from "convex/values"; import { internal } from "./_generated/api"; import type { DataModel, Id } from "./_generated/dataModel"; +import { isLocalDevAuthEnabled } from "./lib/devAuth"; import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync"; export const BANNED_REAUTH_MESSAGE = @@ -12,6 +14,7 @@ export const DELETED_ACCOUNT_REAUTH_MESSAGE = "This account has been permanently deleted and cannot be restored."; const REAUTH_BLOCKING_BAN_ACTIONS = new Set(["user.ban", "user.autoban.malware"]); +const DEV_PERSONAS = new Set(["owner", "user", "admin"]); function getBannedReauthMessage(reason: string | undefined) { const normalizedReason = reason?.trim(); @@ -84,6 +87,18 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ }; }, }), + ConvexCredentials({ + id: "dev-persona", + authorize: async (credentials, ctx) => { + if (!isLocalDevAuthEnabled()) throw new Error("Dev auth is disabled"); + const persona = typeof credentials.persona === "string" ? credentials.persona : ""; + if (!DEV_PERSONAS.has(persona)) throw new Error("Unknown dev persona"); + const userId: Id<"users"> = await ctx.runMutation(internal.users.upsertDevPersonaInternal, { + persona: persona as "owner" | "user" | "admin", + }); + return { userId }; + }, + }), ], callbacks: { /** diff --git a/convex/lib/devAuth.test.ts b/convex/lib/devAuth.test.ts new file mode 100644 index 00000000..a1ec7e6f --- /dev/null +++ b/convex/lib/devAuth.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { isLocalDevAuthEnabled } from "./devAuth"; + +describe("isLocalDevAuthEnabled", () => { + it("requires the explicit dev auth flag", () => { + expect(isLocalDevAuthEnabled({ CONVEX_DEPLOYMENT: "local:clawhub" })).toBe(false); + }); + + it("allows local Convex deployments", () => { + expect( + isLocalDevAuthEnabled({ + DEV_AUTH_ENABLED: "1", + CONVEX_DEPLOYMENT: "local:clawhub", + }), + ).toBe(true); + }); + + it("allows anonymous local Convex deployments", () => { + expect( + isLocalDevAuthEnabled({ + DEV_AUTH_ENABLED: "1", + CONVEX_DEPLOYMENT: "anonymous:clawhub", + }), + ).toBe(true); + }); + + it("rejects cloud dev deployments even when the dev auth flag is set", () => { + expect( + isLocalDevAuthEnabled({ + DEV_AUTH_ENABLED: "1", + CONVEX_DEPLOYMENT: "dev:clever-rabbit-123", + }), + ).toBe(false); + }); + + it("rejects production deployments", () => { + expect( + isLocalDevAuthEnabled({ + DEV_AUTH_ENABLED: "1", + CONVEX_DEPLOYMENT: "prod:wry-manatee-359", + }), + ).toBe(false); + }); +}); diff --git a/convex/lib/devAuth.ts b/convex/lib/devAuth.ts new file mode 100644 index 00000000..65ddb0d4 --- /dev/null +++ b/convex/lib/devAuth.ts @@ -0,0 +1,10 @@ +type DevAuthEnv = { + CONVEX_DEPLOYMENT?: string; + DEV_AUTH_ENABLED?: string; +}; + +export function isLocalDevAuthEnabled(env: DevAuthEnv = process.env) { + if (env.DEV_AUTH_ENABLED !== "1") return false; + const deployment = env.CONVEX_DEPLOYMENT ?? ""; + return deployment.startsWith("local:") || deployment.startsWith("anonymous:"); +} diff --git a/convex/users.ts b/convex/users.ts index b3f0182c..887c5196 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -9,6 +9,7 @@ import { getOptionalActiveAuthUserId, requireUser, } from "./lib/access"; +import { isLocalDevAuthEnabled } from "./lib/devAuth"; import { syncGitHubProfile } from "./lib/githubAccount"; import { toPublicUser } from "./lib/public"; import { isReservedPublicOwnerHandle } from "./lib/publicRouteReservations"; @@ -33,6 +34,26 @@ const MAX_USER_LIST_LIMIT = 200; const MAX_USER_SEARCH_SCAN = 5_000; const MIN_USER_SEARCH_SCAN = 500; +const DEV_PERSONAS = { + owner: { + handle: "local", + displayName: "Local Owner", + role: "user", + }, + user: { + handle: "local-user", + displayName: "Local User", + role: "user", + }, + admin: { + handle: "local-admin", + displayName: "Local Admin", + role: "admin", + }, +} as const; + +type DevPersona = keyof typeof DEV_PERSONAS; + export const getById = query({ args: { userId: v.id("users") }, handler: async (ctx, args) => toPublicUser(await ctx.db.get(args.userId)), @@ -43,6 +64,41 @@ export const getByIdInternal = internalQuery({ handler: async (ctx, args) => ctx.db.get(args.userId), }); +export const upsertDevPersonaInternal = internalMutation({ + args: { persona: v.union(v.literal("owner"), v.literal("user"), v.literal("admin")) }, + handler: async (ctx, args): Promise> => { + if (!isLocalDevAuthEnabled()) throw new Error("Dev auth is disabled"); + + const persona = DEV_PERSONAS[args.persona as DevPersona]; + const now = Date.now(); + const existing = await getUserByHandleOrPersonalPublisher(ctx, persona.handle); + const patch = { + handle: persona.handle, + displayName: persona.displayName, + name: persona.displayName, + role: persona.role, + deletedAt: undefined, + deactivatedAt: undefined, + purgedAt: undefined, + banReason: undefined, + updatedAt: now, + }; + const userId = + existing?._id ?? + (await ctx.db.insert("users", { + ...patch, + createdAt: now, + })); + if (existing) { + await ctx.db.patch(existing._id, patch); + } + const user = await ctx.db.get(userId); + if (!user) throw new Error("Dev persona was not created"); + await ensurePersonalPublisherForUser(ctx, user); + return userId; + }, +}); + export const getByHandleInternal = internalQuery({ args: { handle: v.string() }, handler: async (ctx, args) => { diff --git a/package.json b/package.json index 5499327b..0567ff7d 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "h3": "2.0.1-rc.22", "ignore": "^7.0.5", "lucide-react": "1.14.0", + "mime": "^4.1.0", "monaco-editor": "^0.55.1", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index 1c9d1c50..3b4cab08 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -7,6 +7,8 @@ import { normalizeAuthErrorMessage, } from "../lib/authErrorMessage"; import { clearAuthError, setAuthError } from "../lib/useAuthError"; +import { ClientOnly } from "./ClientOnly"; +import { DevPersonaFab } from "./DevPersonaFab"; import { TooltipProvider } from "./ui/tooltip"; import { UserBootstrap } from "./UserBootstrap"; @@ -92,6 +94,9 @@ export function AppProviders({ children }: { children: React.ReactNode }) { {children} + + + ); diff --git a/src/components/DevPersonaFab.test.tsx b/src/components/DevPersonaFab.test.tsx new file mode 100644 index 00000000..4c21dbb7 --- /dev/null +++ b/src/components/DevPersonaFab.test.tsx @@ -0,0 +1,179 @@ +/* @vitest-environment jsdom */ + +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DevPersonaFab } from "./DevPersonaFab"; + +const signInMock = vi.fn(); +const signOutMock = vi.fn(); +const authStatusMock = vi.fn(); + +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: () => ({ + signIn: signInMock, + signOut: signOutMock, + }), +})); + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../lib/runtimeEnv", () => ({ + getRuntimeEnv: (name: string) => { + if (name === "VITE_ENABLE_DEV_AUTH") return process.env.VITE_ENABLE_DEV_AUTH; + return undefined; + }, +})); + +vi.mock("../lib/useAuthStatus", () => ({ + useAuthStatus: () => authStatusMock(), +})); + +vi.mock("./ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + disabled, + onSelect, + }: { + children: ReactNode; + disabled?: boolean; + onSelect?: (event: { preventDefault: () => void }) => void; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("./ui/select", () => ({ + Select: ({ children }: { children: ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectTrigger: ({ children, id }: { children: ReactNode; id?: string }) => ( + + ), + SelectValue: () => Auth, +})); + +function setHostname(hostname: string) { + Object.defineProperty(window, "location", { + value: { hostname }, + configurable: true, + }); +} + +describe("DevPersonaFab", () => { + beforeEach(() => { + vi.useRealTimers(); + signInMock.mockReset(); + signOutMock.mockReset(); + signInMock.mockResolvedValue({ signingIn: true }); + signOutMock.mockResolvedValue(undefined); + authStatusMock.mockReturnValue({ + isAuthenticated: false, + isLoading: false, + me: null, + }); + process.env.VITE_ENABLE_DEV_AUTH = "1"; + setHostname("localhost"); + }); + + it("stays hidden unless local dev auth is enabled", () => { + process.env.VITE_ENABLE_DEV_AUTH = "0"; + + render(); + + expect(screen.queryByRole("button", { name: /open local dev personas/i })).toBeNull(); + }); + + it("stays hidden away from localhost", () => { + setHostname("clawhub.ai"); + + render(); + + expect(screen.queryByRole("button", { name: /open local dev personas/i })).toBeNull(); + }); + + it.each([ + ["owner", /use owner/i], + ["user", /use user/i], + ["admin", /use admin/i], + ] as const)("signs in with the %s local persona", async (persona, label) => { + render(); + + fireEvent.click(screen.getByRole("button", { name: label })); + + await waitFor(() => { + expect(signInMock).toHaveBeenCalledWith("dev-persona", { persona }); + }); + }); + + it("signs out before switching between local personas", async () => { + authStatusMock.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + me: { handle: "local" }, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /use user/i })); + + await waitFor(() => { + expect(signOutMock).toHaveBeenCalledBefore(signInMock); + expect(signInMock).toHaveBeenCalledWith("dev-persona", { persona: "user" }); + }); + }); + + it("signs out of the current local persona", async () => { + authStatusMock.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + me: { handle: "local-admin" }, + }); + + render(); + + expect(screen.getByLabelText("Active persona")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + + await waitFor(() => { + expect(signOutMock).toHaveBeenCalled(); + }); + }); + + it("recovers when local dev auth does not respond", async () => { + vi.useFakeTimers(); + signInMock.mockReturnValue(new Promise(() => {})); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /use user/i })); + expect(screen.getByRole("button", { name: /switching/i }).hasAttribute("disabled")).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + + expect(toast.error).toHaveBeenCalledWith( + "Dev persona auth timed out. Check the local Convex backend.", + ); + expect(screen.getByRole("button", { name: /use user/i }).hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/src/components/DevPersonaFab.tsx b/src/components/DevPersonaFab.tsx new file mode 100644 index 00000000..c1e1bc79 --- /dev/null +++ b/src/components/DevPersonaFab.tsx @@ -0,0 +1,182 @@ +import { useAuthActions } from "@convex-dev/auth/react"; +import { Check, Shield, User, UserCog, Wrench } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { getRuntimeEnv } from "../lib/runtimeEnv"; +import { useAuthStatus } from "../lib/useAuthStatus"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; + +type DevPersona = "owner" | "user" | "admin"; + +const DEV_PERSONA_AUTH_TIMEOUT_MS = 10_000; + +const PERSONAS: Array<{ + value: DevPersona; + label: string; + description: string; + icon: typeof User; +}> = [ + { + value: "owner", + label: "Use Owner", + description: "@local", + icon: UserCog, + }, + { + value: "user", + label: "Use User", + description: "@local-user", + icon: User, + }, + { + value: "admin", + label: "Use Admin", + description: "@local-admin", + icon: Shield, + }, +]; + +function isLocalDevPersonaEnabled() { + if (getRuntimeEnv("VITE_ENABLE_DEV_AUTH") !== "1") return false; + if (typeof window === "undefined") return false; + const hostname = window.location.hostname.toLowerCase(); + return hostname === "localhost" || hostname === "127.0.0.1"; +} + +async function withDevAuthTimeout(operation: Promise) { + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error("Dev persona auth timed out. Check the local Convex backend.")); + }, DEV_PERSONA_AUTH_TIMEOUT_MS); + }), + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} + +export function DevPersonaFab() { + const [busyPersona, setBusyPersona] = useState(null); + const [section, setSection] = useState("auth"); + const { signIn, signOut } = useAuthActions(); + const { me, isAuthenticated } = useAuthStatus(); + + if (!isLocalDevPersonaEnabled()) return null; + + async function usePersona(persona: DevPersona) { + setBusyPersona(persona); + try { + if (isAuthenticated) { + await withDevAuthTimeout(signOut()); + } + const result = await withDevAuthTimeout(signIn("dev-persona", { persona })); + if (result.signingIn === false) { + throw new Error("Dev persona sign-in did not create a session"); + } + toast.success(`Using ${persona} persona`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Dev persona sign-in failed"); + } finally { + setBusyPersona(null); + } + } + + async function endSession() { + setBusyPersona("sign-out"); + try { + await withDevAuthTimeout(signOut()); + toast.success("Signed out of dev persona"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Sign out failed"); + } finally { + setBusyPersona(null); + } + } + + const activeHandle = me?.handle ? `@${me.handle}` : null; + + return ( +
+ + + + + +
+ + +
+ + {PERSONAS.map((persona) => { + const Icon = persona.icon; + const busy = busyPersona === persona.value; + const active = persona.description === activeHandle; + return ( + { + event.preventDefault(); + void usePersona(persona.value); + }} + > + + + + ); + })} + + { + event.preventDefault(); + void endSession(); + }} + > + {busyPersona === "sign-out" ? "Signing out..." : "Sign out"} + +
+
+
+ ); +}