mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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: {
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:");
|
||||
}
|
||||
@@ -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<Id<"users">> => {
|
||||
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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
<ClientOnly>
|
||||
<DevPersonaFab />
|
||||
</ClientOnly>
|
||||
</TooltipProvider>
|
||||
</ConvexAuthProvider>
|
||||
);
|
||||
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onSelect?: (event: { preventDefault: () => void }) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect?.({ preventDefault: vi.fn() })}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./ui/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({ children, id }: { children: ReactNode; id?: string }) => (
|
||||
<button aria-labelledby={id} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: () => <span>Auth</span>,
|
||||
}));
|
||||
|
||||
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(<DevPersonaFab />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /open local dev personas/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("stays hidden away from localhost", () => {
|
||||
setHostname("clawhub.ai");
|
||||
|
||||
render(<DevPersonaFab />);
|
||||
|
||||
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(<DevPersonaFab />);
|
||||
|
||||
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(<DevPersonaFab />);
|
||||
|
||||
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(<DevPersonaFab />);
|
||||
|
||||
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(<DevPersonaFab />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(operation: Promise<T>) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, 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<DevPersona | "sign-out" | null>(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 (
|
||||
<div className="fixed right-5 bottom-5 z-[70] flex flex-col items-end gap-2 sm:right-6 sm:bottom-6">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-14 w-14 shrink-0 cursor-pointer items-center justify-center rounded-[9999px] border border-[color:var(--line)] bg-[color:var(--surface)] p-0 text-[color:var(--ink)] shadow-[var(--shadow)] transition-colors hover:bg-[color:var(--surface-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]"
|
||||
aria-label="Open local dev personas"
|
||||
>
|
||||
<Wrench size={22} aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[220px]">
|
||||
<div className="px-2 py-1.5">
|
||||
<label className="sr-only" htmlFor="dev-persona-section">
|
||||
Local dev control section
|
||||
</label>
|
||||
<Select value={section} onValueChange={setSection}>
|
||||
<SelectTrigger
|
||||
id="dev-persona-section"
|
||||
size="sm"
|
||||
className="h-9 text-sm font-semibold"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="auth">Auth</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{PERSONAS.map((persona) => {
|
||||
const Icon = persona.icon;
|
||||
const busy = busyPersona === persona.value;
|
||||
const active = persona.description === activeHandle;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={persona.value}
|
||||
disabled={busyPersona !== null}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void usePersona(persona.value);
|
||||
}}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span>{busy ? "Switching..." : persona.label}</span>
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">
|
||||
{persona.description}
|
||||
</span>
|
||||
</span>
|
||||
{active ? <Check size={15} aria-label="Active persona" /> : null}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={busyPersona !== null || !isAuthenticated}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void endSession();
|
||||
}}
|
||||
>
|
||||
{busyPersona === "sign-out" ? "Signing out..." : "Sign out"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user