diff --git a/specs/auth-loading.md b/specs/auth-loading.md new file mode 100644 index 00000000..c95441fd --- /dev/null +++ b/specs/auth-loading.md @@ -0,0 +1,28 @@ +# Auth Loading Semantics + +ClawHub treats session resolution and user-profile resolution as one protected-page gate. + +`useAuthStatus()` is the canonical client hook for auth-aware UI. It owns the `users.me` +query and keeps `isLoading` true until Convex auth has resolved and, for authenticated +sessions, the current user document has resolved. + +Protected routes must not render signed-out prompts, permission-denied states, empty states, +or user-scoped content while `useAuthStatus().isLoading` is true. They should render a +route-shaped skeleton until the gate resolves. + +User-scoped queries must be skipped until a current user exists: + +```ts +const { isAuthenticated, isLoading, me } = useAuthStatus(); +const result = useQuery(api.some.userScopedQuery, me ? { userId: me._id } : "skip"); +``` + +After the loading gate resolves: + +- `!isAuthenticated || !me` means the viewer should see the signed-out state. +- `me` means user-scoped queries can start. +- Public pages may render public content without waiting for auth, but personalized controls + and ownership/publisher queries must stay skipped until `me` exists. + +This prevents flash sequences such as login prompt -> loading state -> real content, and +empty state -> loaded user content. diff --git a/src/__tests__/import.route.test.tsx b/src/__tests__/import.route.test.tsx index 9de38361..714264ff 100644 --- a/src/__tests__/import.route.test.tsx +++ b/src/__tests__/import.route.test.tsx @@ -94,6 +94,19 @@ describe("Import route", () => { }); }); + it("keeps the signed-out prompt hidden while auth is resolving", () => { + useAuthStatusMock.mockReturnValue({ + isAuthenticated: false, + isLoading: true, + me: undefined, + }); + + render(); + + expect(screen.getByLabelText(/loading github import/i)).toBeTruthy(); + expect(screen.queryByText(/sign in to import/i)).toBeNull(); + }); + it("blocks import preflight when slug availability reports a collision", async () => { useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { if (args === "skip") return undefined; diff --git a/src/__tests__/skills-publish-route.test.tsx b/src/__tests__/skills-publish-route.test.tsx index 9ca6b006..549111b8 100644 --- a/src/__tests__/skills-publish-route.test.tsx +++ b/src/__tests__/skills-publish-route.test.tsx @@ -63,9 +63,10 @@ describe("Upload route", () => { isLoading: false, me: { _id: "users:1" }, }); - useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { + useQueryMock.mockImplementation((fn: unknown, args: unknown) => { if (args === "skip") return undefined; - if (args === undefined) { + const name = fn ? getFunctionName(fn as Parameters[0]) : ""; + if (name === "publishers:listMine") { return [ { publisher: { @@ -437,9 +438,10 @@ describe("Upload route", () => { }); it("blocks publish in preflight when slug availability reports a collision", async () => { - useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { + useQueryMock.mockImplementation((fn: unknown, args: unknown) => { if (args === "skip") return undefined; - if (args === undefined) { + const name = fn ? getFunctionName(fn as Parameters[0]) : ""; + if (name === "publishers:listMine") { return [ { publisher: { diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index e0d1eb12..52c050f8 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -188,7 +188,7 @@ export function SkillDetailPage({ const updateSummary = useMutation(api.skills.updateSummary); const getReadme = useAction(api.skills.getReadme); const getSkillCard = useAction(api.skills.getSkillCard); - const myPublishers = useQuery(api.publishers.listMine) as + const myPublishers = useQuery(api.publishers.listMine, me ? {} : "skip") as | Array<{ publisher: { _id: Id<"publishers"> }; role: string }> | undefined; diff --git a/src/components/skeletons/ProtectedPageSkeletons.tsx b/src/components/skeletons/ProtectedPageSkeletons.tsx new file mode 100644 index 00000000..44afb2c6 --- /dev/null +++ b/src/components/skeletons/ProtectedPageSkeletons.tsx @@ -0,0 +1,163 @@ +import { Container } from "../layout/Container"; +import { Card, CardContent } from "../ui/card"; +import { Skeleton } from "../ui/skeleton"; + +export function StarsSkeleton() { + return ( +
+
+ +
+
+ {Array.from({ length: 4 }, (_, index) => ( +
+ +
+ + + +
+
+ ))} +
+
+ ); +} + +export function SettingsSkeleton() { + return ( +
+
+
+ + +
+ +
+ +
+
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+
+
+ + +
+
+
+
+
+ ); +} + +export function ImportGitHubSkeleton() { + return ( +
+ +
+
+ + + +
+ +
+
+ + +
+ + + +
+
+
+ + +
+ + + + +
+
+
+
+
+
+ ); +} + +export function ManagementSkeleton() { + return ( +
+ + + {Array.from({ length: 3 }, (_section, index) => ( + +
+
+ + +
+
+ {Array.from({ length: 3 }, (_item, row) => ( +
+
+ + +
+
+ +
+
+ ))} +
+
+
+ ))} +
+ ); +} + +export function AuthFlowSkeleton({ title }: { title: string }) { + return ( +
+ + + +
+ + + +
+
+
+
+
+ ); +} diff --git a/src/lib/useAuthStatus.test.tsx b/src/lib/useAuthStatus.test.tsx index b95c2b3e..e2d578b8 100644 --- a/src/lib/useAuthStatus.test.tsx +++ b/src/lib/useAuthStatus.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAuthStatus } from "./useAuthStatus"; const useConvexAuthMock = vi.fn(); @@ -26,7 +26,25 @@ function Probe() { } describe("useAuthStatus", () => { - it("does not keep auth loading true when only the profile query is unresolved", () => { + beforeEach(() => { + useConvexAuthMock.mockReset(); + useQueryMock.mockReset(); + }); + + it("skips the current-user query while auth is still resolving", () => { + useConvexAuthMock.mockReturnValue({ + isAuthenticated: false, + isLoading: true, + }); + useQueryMock.mockReturnValue(undefined); + + render(); + + expect(useQueryMock.mock.calls[0]?.[1]).toBe("skip"); + expect(screen.getByText('{"isAuthenticated":false,"isLoading":true}')).toBeTruthy(); + }); + + it("returns a signed-out state after auth resolves unauthenticated", () => { useConvexAuthMock.mockReturnValue({ isAuthenticated: false, isLoading: false, @@ -35,10 +53,11 @@ describe("useAuthStatus", () => { render(); - expect(screen.getByText('{"isAuthenticated":false,"isLoading":false}')).toBeTruthy(); + expect(useQueryMock.mock.calls[0]?.[1]).toBe("skip"); + expect(screen.getByText('{"isAuthenticated":false,"isLoading":false,"me":null}')).toBeTruthy(); }); - it("preserves authenticated session state before the profile query resolves", () => { + it("keeps loading true until the authenticated profile query resolves", () => { useConvexAuthMock.mockReturnValue({ isAuthenticated: true, isLoading: false, @@ -47,6 +66,22 @@ describe("useAuthStatus", () => { render(); - expect(screen.getByText('{"isAuthenticated":true,"isLoading":false}')).toBeTruthy(); + expect(useQueryMock.mock.calls[0]?.[1]).toEqual({}); + expect(screen.getByText('{"isAuthenticated":true,"isLoading":true}')).toBeTruthy(); + }); + + it("returns the resolved current user for authenticated sessions", () => { + const me = { _id: "users:1", handle: "local" }; + useConvexAuthMock.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }); + useQueryMock.mockReturnValue(me); + + render(); + + expect( + screen.getByText(JSON.stringify({ isAuthenticated: true, isLoading: false, me })), + ).toBeTruthy(); }); }); diff --git a/src/lib/useAuthStatus.ts b/src/lib/useAuthStatus.ts index af25ac1a..3b0c645e 100644 --- a/src/lib/useAuthStatus.ts +++ b/src/lib/useAuthStatus.ts @@ -4,10 +4,17 @@ import type { Doc } from "../../convex/_generated/dataModel"; export function useAuthStatus() { const auth = useConvexAuth(); - const me = useQuery(api.users.me) as Doc<"users"> | null | undefined; + const shouldLoadUser = !auth.isLoading && auth.isAuthenticated; + const userResult = useQuery(api.users.me, shouldLoadUser ? {} : "skip") as + | Doc<"users"> + | null + | undefined; + const isUserLoading = shouldLoadUser && userResult === undefined; + const me = shouldLoadUser ? userResult : auth.isLoading ? undefined : null; + return { me, - isLoading: auth.isLoading, + isLoading: auth.isLoading || isUserLoading, isAuthenticated: auth.isAuthenticated, }; } diff --git a/src/routes/-dashboard.test.tsx b/src/routes/-dashboard.test.tsx index 471ecf73..dd5d11ed 100644 --- a/src/routes/-dashboard.test.tsx +++ b/src/routes/-dashboard.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { getFunctionName } from "convex/server"; import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Id } from "../../convex/_generated/dataModel"; @@ -9,6 +10,7 @@ import { Dashboard } from "./dashboard"; const mocks = vi.hoisted(() => ({ useQuery: vi.fn(), usePaginatedQuery: vi.fn(), + useAuthStatus: vi.fn(), })); vi.mock("convex/react", () => ({ @@ -16,6 +18,10 @@ vi.mock("convex/react", () => ({ usePaginatedQuery: (...args: unknown[]) => mocks.usePaginatedQuery(...args), })); +vi.mock("../lib/useAuthStatus", () => ({ + useAuthStatus: () => mocks.useAuthStatus(), +})); + vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: unknown) => config, Link: ({ @@ -228,18 +234,16 @@ function arrangeDashboard({ skills?: TestSkill[]; packages?: TestPackage[]; }) { - let unscopedQueryCount = 0; mocks.usePaginatedQuery.mockReturnValue({ results: skills, status: "Exhausted", loadMore: vi.fn(), }); - mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => { + mocks.useQuery.mockImplementation((query: unknown, args: unknown) => { if (args === "skip") return undefined; - if (args === undefined) { - unscopedQueryCount += 1; - return unscopedQueryCount % 2 === 1 ? me : publishers; - } + const name = getFunctionName(query as never); + if (name === "publishers:listMine") return publishers; + if (name === "packages:list") return packages; return packages; }); } @@ -256,11 +260,17 @@ describe("Dashboard rows", () => { beforeEach(() => { mocks.useQuery.mockReset(); mocks.usePaginatedQuery.mockReset(); + mocks.useAuthStatus.mockReset(); mocks.usePaginatedQuery.mockReturnValue({ results: [], status: "LoadingFirstPage", loadMore: vi.fn(), }); + mocks.useAuthStatus.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + me, + }); }); it("renders compact clickable artifact cards with status and inventory context", () => { @@ -324,13 +334,10 @@ describe("Dashboard rows", () => { status: "Exhausted", loadMore: vi.fn(), }); - let unscopedQueryCount = 0; - mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => { + mocks.useQuery.mockImplementation((query: unknown, args: unknown) => { if (args === "skip") return undefined; - if (args === undefined) { - unscopedQueryCount += 1; - return unscopedQueryCount % 2 === 1 ? me : orgPublishers; - } + const name = getFunctionName(query as never); + if (name === "publishers:listMine") return orgPublishers; if ( typeof args === "object" && args !== null && @@ -382,13 +389,10 @@ describe("Dashboard rows", () => { status: "Exhausted", loadMore: vi.fn(), }); - let unscopedQueryCount = 0; - mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => { + mocks.useQuery.mockImplementation((query: unknown, args: unknown) => { if (args === "skip") return undefined; - if (args === undefined) { - unscopedQueryCount += 1; - return unscopedQueryCount % 2 === 1 ? me : orgPublishers; - } + const name = getFunctionName(query as never); + if (name === "publishers:listMine") return orgPublishers; return []; }); @@ -404,6 +408,11 @@ describe("Dashboard rows", () => { }); it("renders a skeleton while auth state is loading", () => { + mocks.useAuthStatus.mockReturnValue({ + isAuthenticated: false, + isLoading: true, + me: undefined, + }); mocks.useQuery.mockReturnValue(undefined); renderDashboard(); @@ -428,25 +437,26 @@ describe("Dashboard rows", () => { arrangeDashboard({ skills: [createSkill()], }); - let unscopedQueryCount = 0; - mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => { + mocks.useAuthStatus.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + me: { ...me, handle: "Local Owner" }, + }); + mocks.useQuery.mockImplementation((query: unknown, args: unknown) => { if (args === "skip") return undefined; - if (args === undefined) { - unscopedQueryCount += 1; - return unscopedQueryCount % 2 === 1 - ? { ...me, handle: "Local Owner" } - : [ - { - publisher: { - _id: "publishers:stale" as Id<"publishers">, - handle: "Local Owner", - displayName: "Local Owner", - kind: "user" as const, - }, - role: "owner" as const, - }, - ]; - } + const name = getFunctionName(query as never); + if (name === "publishers:listMine") + return [ + { + publisher: { + _id: "publishers:stale" as Id<"publishers">, + handle: "Local Owner", + displayName: "Local Owner", + kind: "user" as const, + }, + role: "owner" as const, + }, + ]; return []; }); diff --git a/src/routes/-settings.test.tsx b/src/routes/-settings.test.tsx index 4d17471c..e976546d 100644 --- a/src/routes/-settings.test.tsx +++ b/src/routes/-settings.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { getFunctionName } from "convex/server"; import type { ReactNode } from "react"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +10,7 @@ import { Settings } from "./settings"; const useQueryMock = vi.fn(); const useMutationMock = vi.fn(); const useAuthActionsMock = vi.fn(); +const useAuthStatusMock = vi.fn(); const { navigateMock, searchMock } = vi.hoisted(() => ({ navigateMock: vi.fn(), searchMock: vi.fn(() => ({})), @@ -23,6 +25,10 @@ vi.mock("@convex-dev/auth/react", () => ({ useAuthActions: () => useAuthActionsMock(), })); +vi.mock("../lib/useAuthStatus", () => ({ + useAuthStatus: () => useAuthStatusMock(), +})); + vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: unknown) => config, Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, @@ -83,12 +89,18 @@ function mockSignedInSettings({ memberships?: Array; members?: typeof orgMembers; } = {}) { + useAuthStatusMock.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + me: signedInUser, + }); searchMock.mockReturnValue(search); useQueryMock.mockImplementation((query, args) => { - if (query === api.users.me) return signedInUser; if (args === "skip") return undefined; - if (args && typeof args === "object" && "publisherHandle" in args) return members; - if (args && typeof args === "object") return []; + const name = getFunctionName(query); + if (name === "tokens:listMine") return []; + if (name === "publishers:listMine") return memberships; + if (name === "publishers:listMembers") return members; return memberships; }); } @@ -99,6 +111,7 @@ describe("Settings", () => { useQueryMock.mockReset(); useMutationMock.mockReset(); useAuthActionsMock.mockReset(); + useAuthStatusMock.mockReset(); navigateMock.mockReset(); searchMock.mockReset(); searchMock.mockReturnValue({}); @@ -108,15 +121,25 @@ describe("Settings", () => { useAuthActionsMock.mockReturnValue({ signIn: vi.fn(), }); + useAuthStatusMock.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + me: signedInUser, + }); }); - it("skips token loading until auth has resolved", () => { + it("shows the settings skeleton until auth has resolved", () => { + useAuthStatusMock.mockReturnValue({ + isAuthenticated: false, + isLoading: true, + me: undefined, + }); useQueryMock.mockImplementation(() => undefined); render(); - expect(screen.getByRole("heading", { name: /sign in to access settings/i })).toBeTruthy(); - expect(screen.getByRole("button", { name: /sign in with github/i })).toBeTruthy(); + expect(screen.getByLabelText(/loading settings/i)).toBeTruthy(); + expect(screen.queryByRole("heading", { name: /sign in to access settings/i })).toBeNull(); expect(useQueryMock.mock.calls.some(([, args]) => args === "skip")).toBe(true); }); diff --git a/src/routes/-stars.test.tsx b/src/routes/-stars.test.tsx index 3c8cd1da..a8c55364 100644 --- a/src/routes/-stars.test.tsx +++ b/src/routes/-stars.test.tsx @@ -6,12 +6,11 @@ import { Stars } from "./stars"; const useQueryMock = vi.fn(); const useMutationMock = vi.fn(); -const useConvexAuthMock = vi.fn(); +const useAuthStatusMock = vi.fn(); const useAuthActionsMock = vi.fn(); const navigateMock = vi.fn(); vi.mock("convex/react", () => ({ - useConvexAuth: () => useConvexAuthMock(), useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: (...args: unknown[]) => useMutationMock(...args), })); @@ -20,6 +19,10 @@ vi.mock("@convex-dev/auth/react", () => ({ useAuthActions: () => useAuthActionsMock(), })); +vi.mock("../lib/useAuthStatus", () => ({ + useAuthStatus: () => useAuthStatusMock(), +})); + vi.mock("@tanstack/react-router", async () => { const actual = await vi.importActual("@tanstack/react-router"); @@ -61,20 +64,24 @@ describe("Stars", () => { beforeEach(() => { useQueryMock.mockReset(); useMutationMock.mockReset(); - useConvexAuthMock.mockReset(); + useAuthStatusMock.mockReset(); useAuthActionsMock.mockReset(); navigateMock.mockReset(); useMutationMock.mockReturnValue(toggleStarMock); - useConvexAuthMock.mockReturnValue({ isAuthenticated: true, isLoading: false }); + useAuthStatusMock.mockReturnValue({ + isAuthenticated: true, + isLoading: false, + me: { _id: "user_123" }, + }); useAuthActionsMock.mockReturnValue({ signIn: vi.fn() }); toggleStarMock.mockReset(); toggleStarMock.mockResolvedValue(null); }); it("shows sign-in prompt when user is not authenticated", () => { - useConvexAuthMock.mockReturnValue({ isAuthenticated: false, isLoading: false }); + useAuthStatusMock.mockReturnValue({ isAuthenticated: false, isLoading: false, me: null }); useQueryMock.mockImplementation((_query, args) => { - if (args === undefined) return null; + if (args === "skip") return undefined; return undefined; }); @@ -85,9 +92,9 @@ describe("Stars", () => { }); it("shows skeleton while loading", () => { - useConvexAuthMock.mockReturnValue({ isAuthenticated: false, isLoading: true }); + useAuthStatusMock.mockReturnValue({ isAuthenticated: false, isLoading: true, me: undefined }); useQueryMock.mockImplementation((_query, args) => { - if (args === undefined) return { _id: "user_123" }; + if (args === "skip") return undefined; return undefined; }); @@ -99,7 +106,7 @@ describe("Stars", () => { it("shows empty state when user has no stars", () => { useQueryMock.mockImplementation((_query, args) => { - if (args === undefined) return { _id: "user_123" }; + if (args === "skip") return undefined; return []; }); @@ -114,7 +121,6 @@ describe("Stars", () => { it("renders skill cards when user has stars", () => { useQueryMock.mockImplementation((_query, args) => { - if (args === undefined) return { _id: "user_123" }; if (args === "skip") return undefined; return [makeSkill({ _id: "skill_1", slug: "test-skill", displayName: "Test Skill" })]; }); @@ -129,7 +135,6 @@ describe("Stars", () => { it("calls toggleStar when unstar button is clicked", () => { const skill = makeSkill({ _id: "skill_1", slug: "test-skill", displayName: "Test Skill" }); useQueryMock.mockImplementation((_query, args) => { - if (args === undefined) return { _id: "user_123" }; if (args === "skip") return undefined; return [skill]; }); diff --git a/src/routes/cli/auth.tsx b/src/routes/cli/auth.tsx index 2e52d4cd..0c08a118 100644 --- a/src/routes/cli/auth.tsx +++ b/src/routes/cli/auth.tsx @@ -6,6 +6,7 @@ import { api } from "../../../convex/_generated/api"; import { Container } from "../../components/layout/Container"; import { SignInButton } from "../../components/SignInButton"; import { SignInPrompt } from "../../components/SignInPrompt"; +import { AuthFlowSkeleton } from "../../components/skeletons/ProtectedPageSkeletons"; import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; import { getClawHubSiteUrl, normalizeClawHubSiteOrigin } from "../../lib/site"; import { useAuthError } from "../../lib/useAuthError"; @@ -134,6 +135,10 @@ export function CliAuth({ ); } + if (isLoading) { + return ; + } + if (!isAuthenticated || !me) { return ( ; + } + return (
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 951e4c9b..44645bfd 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { usePaginatedQuery, useQuery } from "convex/react"; import { Box, Loader2, Package, Plus, Settings } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { ArtifactCard } from "../components/artifacts/ArtifactCard"; @@ -18,6 +18,7 @@ import { SelectValue, } from "../components/ui/select"; import { buildPluginDetailHref } from "../lib/pluginRoutes"; +import { useAuthStatus } from "../lib/useAuthStatus"; const emptyPluginPublishSearch = { ownerHandle: undefined, @@ -103,8 +104,8 @@ export const Route = createFileRoute("/dashboard")({ }); export function Dashboard() { - const me = useQuery(api.users.me) as Doc<"users"> | null | undefined; - const publishers = useQuery(api.publishers.listMine) as + const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus(); + const publishers = useQuery(api.publishers.listMine, me ? {} : "skip") as | Array<{ publisher: { _id: string; @@ -116,14 +117,19 @@ export function Dashboard() { }> | undefined; const [selectedPublisherId, setSelectedPublisherId] = useState(""); - const selectedPublisher = - publishers?.find((entry) => entry.publisher._id === selectedPublisherId) ?? null; + const defaultPublisher = + publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0] ?? null; + const selectedPublisherFromState = selectedPublisherId + ? (publishers?.find((entry) => entry.publisher._id === selectedPublisherId) ?? null) + : null; + const selectedPublisher = selectedPublisherFromState ?? defaultPublisher ?? null; + const activePublisherId = selectedPublisher?.publisher._id ?? ""; const skillsQueryArgs = selectedPublisher?.publisher.kind === "user" && me?._id ? { ownerUserId: me._id } - : selectedPublisherId - ? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"] } + : activePublisherId + ? { ownerPublisherId: activePublisherId as Doc<"publishers">["_id"] } : me?._id ? { ownerUserId: me._id } : "skip"; @@ -137,42 +143,38 @@ export function Dashboard() { const mySkills = paginatedSkills as DashboardSkill[] | undefined; const myPackages = useQuery( api.packages.list, - selectedPublisherId - ? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"], limit: 100 } + activePublisherId + ? { ownerPublisherId: activePublisherId as Doc<"publishers">["_id"], limit: 100 } : me?._id ? { ownerUserId: me._id, limit: 100 } : "skip", ) as DashboardPackage[] | undefined; - useEffect(() => { - if (selectedPublisherId) return; - const personal = - publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0]; - if (personal?.publisher._id) { - setSelectedPublisherId(personal.publisher._id); - } - }, [publishers, selectedPublisherId]); - - if (me === undefined) { + if (isAuthLoading) { return ; } - if (me === null) { + if (!isAuthenticated || !me) { return ; } const skills = mySkills ?? []; const packages = myPackages ?? []; - const isLoading = skillsStatus === "LoadingFirstPage"; + const isLoading = + publishers === undefined || skillsStatus === "LoadingFirstPage" || myPackages === undefined; const ownerHandle = selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id; const isDashboardEmpty = !isLoading && skills.length === 0 && packages.length === 0; + if (isLoading) { + return ; + } + const publisherSelector = publishers && publishers.length > 1 ? (
Viewing as - ; + } + if (!isAuthenticated || !me) { return ( - ); + return ; } - if (!isAuthenticated) { + if (!isAuthenticated || !me) { return ( clearTimeout(handle); }, [userSearch]); + if (isAuthLoading) { + return ; + } + if (!staff) { return (
@@ -215,11 +220,7 @@ export function Management() { } if (!recentVersions || !reportedSkills || !duplicateCandidates) { - return ( -
- Loading management console… -
- ); + return ; } const reportQuery = reportSearchDebounced.trim().toLowerCase(); diff --git a/src/routes/plugins/publish.tsx b/src/routes/plugins/publish.tsx index 6839773f..c7d0b269 100644 --- a/src/routes/plugins/publish.tsx +++ b/src/routes/plugins/publish.tsx @@ -59,8 +59,8 @@ const PLUGIN_PUBLISHING_GUIDE_URL = "https://docs.openclaw.ai/clawhub/publishing export function PublishPluginRoute() { const search = useSearch({ from: "/plugins/publish" }); - const { isAuthenticated, isLoading: isAuthLoading } = useAuthStatus(); - const publishers = useQuery(api.publishers.listMine) as + const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus(); + const publishers = useQuery(api.publishers.listMine, me ? {} : "skip") as | Array | undefined; const generateUploadUrl = useMutation(api.uploads.generateUploadUrl); @@ -212,7 +212,7 @@ export function PublishPluginRoute() { return ; } - if (!isAuthenticated) { + if (!isAuthenticated || !me) { return (
diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 5011b483..724a06e7 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -27,6 +27,7 @@ import { EmptyState } from "../components/EmptyState"; import { copyText } from "../components/InstallCopyButton"; import { MarketplaceIcon } from "../components/MarketplaceIcon"; import { SignInPrompt } from "../components/SignInPrompt"; +import { SettingsSkeleton } from "../components/skeletons/ProtectedPageSkeletons"; import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; @@ -53,6 +54,7 @@ import { Textarea } from "../components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group"; import { getUserFacingConvexError } from "../lib/convexError"; import { useThemeMode } from "../lib/theme"; +import { useAuthStatus } from "../lib/useAuthStatus"; const settingsViews = ["account", "organizations", "tokens", "danger"] as const; type SettingsView = (typeof settingsViews)[number]; @@ -140,14 +142,14 @@ const themeToggleItemClass = "!h-20 min-w-0 flex-1 flex-col gap-2 !rounded-[var(--r-btn)] border border-[color:var(--line)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--ink-soft)] opacity-70 hover:border-[color:var(--border-ui-hover)] hover:bg-[color:var(--surface-muted)] hover:text-[color:var(--ink)] hover:opacity-100 data-[state=on]:border-[color:var(--accent)] data-[state=on]:!bg-[color:var(--surface-muted)] data-[state=on]:text-[color:var(--ink)] data-[state=on]:opacity-100 sm:!w-28 sm:flex-none"; export function Settings() { - const me = useQuery(api.users.me); + const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus(); const updateProfile = useMutation(api.users.updateProfile); const deleteAccount = useMutation(api.users.deleteAccount); const { mode: themeMode, setMode: setThemeMode } = useThemeMode(); const tokens = useQuery(api.tokens.listMine, me ? {} : "skip") as Array | undefined; const createToken = useMutation(api.tokens.create); const revokeToken = useMutation(api.tokens.revoke); - const publisherMemberships = useQuery(api.publishers.listMine) as + const publisherMemberships = useQuery(api.publishers.listMine, me ? {} : "skip") as | Array | undefined; const createOrg = useMutation(api.publishers.createOrg); @@ -188,7 +190,7 @@ export function Settings() { const revokedTokens = (tokens ?? []).filter((token) => token.revokedAt); const orgMembers = useQuery( api.publishers.listMembers, - activeView === "organizations" && selectedOrg + activeView === "organizations" && selectedOrg && selectedOrg.role !== "publisher" ? { publisherHandle: selectedOrg.publisher.handle } : "skip", ) as OrgMembersResult | null | undefined; @@ -218,7 +220,11 @@ export function Settings() { setSelectedOrgImage(selectedOrg.publisher.image ?? ""); }, [selectedOrg]); - if (!me) { + if (isAuthLoading) { + return ; + } + + if (!isAuthenticated || !me) { return ( ; + } + const accountAvatar = me.image ?? undefined; const accountInitial = (displayName || me.displayName || me.name || me.handle || "U") .charAt(0) diff --git a/src/routes/skills/publish.tsx b/src/routes/skills/publish.tsx index b82ce961..cb3b489e 100644 --- a/src/routes/skills/publish.tsx +++ b/src/routes/skills/publish.tsx @@ -146,7 +146,7 @@ export function Upload() { const [status, setStatus] = useState(null); const isSubmitting = status !== null; const [error, setError] = useState(null); - const publisherMemberships = useQuery(api.publishers.listMine) as + const publisherMemberships = useQuery(api.publishers.listMine, me ? {} : "skip") as | PublisherOwnerMembership[] | undefined; const [ownerHandle, setOwnerHandle] = useState(searchOwnerHandle ?? ""); @@ -548,7 +548,7 @@ export function Upload() { return ; } - if (!isAuthenticated) { + if (!isAuthenticated || !me) { return (
diff --git a/src/routes/stars.tsx b/src/routes/stars.tsx index 5391406a..a3e47bf4 100644 --- a/src/routes/stars.tsx +++ b/src/routes/stars.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { useConvexAuth, useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "convex/react"; import { ArrowDownUp, LayoutGrid, List, Star } from "lucide-react"; import { startTransition, useOptimistic } from "react"; import { toast } from "sonner"; @@ -7,6 +7,7 @@ import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { EmptyState } from "../components/EmptyState"; import { SignInPrompt } from "../components/SignInPrompt"; +import { StarsSkeleton } from "../components/skeletons/ProtectedPageSkeletons"; import { SkillCard } from "../components/SkillCard"; import { SkillListItem } from "../components/SkillListItem"; import { SkillStatsTripletLine } from "../components/SkillStats"; @@ -21,6 +22,7 @@ import { import { Separator } from "../components/ui/separator"; import { getSkillBadges } from "../lib/badges"; import type { PublicSkill } from "../lib/publicUser"; +import { useAuthStatus } from "../lib/useAuthStatus"; type StarsView = "grid" | "list"; type StarsSort = "starred" | "updated" | "stars"; @@ -41,8 +43,7 @@ export const Route = createFileRoute("/stars")({ }); export function Stars() { - const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); - const me = useQuery(api.users.me) as Doc<"users"> | null | undefined; + const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus(); const search = Route.useSearch(); const navigate = Route.useNavigate(); @@ -51,7 +52,7 @@ export function Stars() { const skillsQuery = useQuery( api.stars.listByUser, - me ? { userId: me._id, limit: STARRED_SKILLS_LIMIT } : "skip", + me ? { userId: me._id as Doc<"users">["_id"], limit: STARRED_SKILLS_LIMIT } : "skip", ) as PublicSkill[] | undefined; const toggleStar = useMutation(api.stars.toggle); @@ -87,25 +88,10 @@ export function Stars() { }; if (isAuthLoading) { - return ( -
-
- {Array.from({ length: 4 }, (_, i) => ( -
-
-
-
-
-
-
-
- ))} -
-
- ); + return ; } - if (!isAuthenticated) { + if (!isAuthenticated || !me) { return ( -
- {Array.from({ length: 4 }, (_, i) => ( -
-
-
-
-
-
-
-
- ))} -
-
- ); + return ; } return (