fix: stabilize auth loading states (#2447)

This commit is contained in:
Vyctor H. Brzezowski
2026-06-01 10:01:04 -07:00
committed by GitHub
parent 9fc2da4dc4
commit ded9ff4235
20 changed files with 433 additions and 147 deletions
+28
View File
@@ -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.
+13
View File
@@ -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(<ImportGitHub />);
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;
+6 -4
View File
@@ -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<typeof getFunctionName>[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<typeof getFunctionName>[0]) : "";
if (name === "publishers:listMine") {
return [
{
publisher: {
+1 -1
View File
@@ -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;
@@ -0,0 +1,163 @@
import { Container } from "../layout/Container";
import { Card, CardContent } from "../ui/card";
import { Skeleton } from "../ui/skeleton";
export function StarsSkeleton() {
return (
<main className="browse-page" aria-busy="true" aria-label="Loading starred skills">
<header className="stars-header">
<Skeleton className="h-9 w-48" />
</header>
<div className="skeleton-list">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="skeleton-row">
<Skeleton className="skeleton-icon" />
<div className="skeleton-row-body">
<Skeleton className="skeleton-bar skeleton-bar-lg" />
<Skeleton className="skeleton-bar skeleton-bar-sm" />
<Skeleton className="skeleton-bar skeleton-bar-xs" />
</div>
</div>
))}
</div>
</main>
);
}
export function SettingsSkeleton() {
return (
<main
className="border-b border-[color:var(--line)] bg-[color:var(--bg)]"
aria-busy="true"
aria-label="Loading settings"
>
<div className="mx-auto flex w-full flex-col gap-6 px-4 py-8 sm:px-6 sm:py-10 lg:px-6 [max-width:var(--page-max)]">
<header>
<Skeleton className="h-9 w-40" />
<Skeleton className="mt-3 h-5 w-[min(560px,90%)]" />
</header>
<Skeleton className="h-px w-full" />
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<aside className="lg:w-[272px] lg:shrink-0">
<div className="flex gap-2 overflow-hidden lg:flex-col lg:gap-2">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-10 w-32 lg:w-full" />
))}
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="settings-card flex flex-col gap-4">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Skeleton className="h-10 w-10" />
<div className="grid min-w-0 gap-2">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
</div>
<Skeleton className="hidden h-14 w-14 rounded-full sm:block" />
</div>
<Skeleton className="h-11 w-full" />
<Skeleton className="h-28 w-full" />
<div className="flex justify-end">
<Skeleton className="h-10 w-32 rounded-[var(--r-btn)]" />
</div>
</div>
<div className="settings-card flex flex-col gap-4">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
</div>
</main>
);
}
export function ImportGitHubSkeleton() {
return (
<main className="py-10" aria-busy="true" aria-label="Loading GitHub import">
<Container>
<header className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="grid gap-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-8 w-52" />
<Skeleton className="h-5 w-[min(420px,80vw)]" />
</div>
<Skeleton className="h-16 w-36" />
</header>
<div className="grid gap-5">
<Card>
<CardContent>
<div className="grid gap-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-11 w-full" />
<Skeleton className="h-10 w-28 rounded-[var(--r-btn)]" />
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="grid gap-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-32 w-full" />
</div>
</CardContent>
</Card>
</div>
</Container>
</main>
);
}
export function ManagementSkeleton() {
return (
<main className="section" aria-busy="true" aria-label="Loading management console">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-5 w-[min(520px,90%)]" />
{Array.from({ length: 3 }, (_section, index) => (
<Card key={index}>
<div className="grid gap-4">
<div className="management-controls">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-5 w-28" />
</div>
<div className="management-list">
{Array.from({ length: 3 }, (_item, row) => (
<div key={row} className="management-item">
<div className="management-item-main">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<div className="management-actions">
<Skeleton className="h-9 w-24 rounded-[var(--r-btn)]" />
</div>
</div>
))}
</div>
</div>
</Card>
))}
</main>
);
}
export function AuthFlowSkeleton({ title }: { title: string }) {
return (
<main className="py-10" aria-busy="true" aria-label={`Loading ${title}`}>
<Container size="narrow">
<Card>
<CardContent>
<div className="grid gap-4">
<Skeleton className="h-8 w-44" />
<Skeleton className="h-5 w-[min(360px,80%)]" />
<Skeleton className="h-10 w-36 rounded-[var(--r-btn)]" />
</div>
</CardContent>
</Card>
</Container>
</main>
);
}
+40 -5
View File
@@ -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(<Probe />);
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(<Probe />);
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(<Probe />);
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(<Probe />);
expect(
screen.getByText(JSON.stringify({ isAuthenticated: true, isLoading: false, me })),
).toBeTruthy();
});
});
+9 -2
View File
@@ -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,
};
}
+46 -36
View File
@@ -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 [];
});
+29 -6
View File
@@ -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 }) => <a href={to}>{children}</a>,
@@ -83,12 +89,18 @@ function mockSignedInSettings({
memberships?: Array<typeof orgMembership>;
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(<Settings />);
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);
});
+16 -11
View File
@@ -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<typeof import("@tanstack/react-router")>("@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];
});
+5
View File
@@ -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 <AuthFlowSkeleton title="CLI login" />;
}
if (!isAuthenticated || !me) {
return (
<SignInPrompt
+5
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { api } from "../../../convex/_generated/api";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
import { AuthFlowSkeleton } from "../../components/skeletons/ProtectedPageSkeletons";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { Input } from "../../components/ui/input";
@@ -49,6 +50,10 @@ export function CliDeviceAuth() {
}
};
if (isLoading) {
return <AuthFlowSkeleton title="CLI device login" />;
}
return (
<main className="py-10">
<Container size="narrow">
+24 -22
View File
@@ -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<string>("");
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 <DashboardSkeleton />;
}
if (me === null) {
if (!isAuthenticated || !me) {
return <SignInPrompt title="Sign in to access your dashboard." />;
}
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 <DashboardSkeleton />;
}
const publisherSelector =
publishers && publishers.length > 1 ? (
<div className="dashboard-publisher-select">
<span className="text-sm font-medium text-muted-foreground">Viewing as</span>
<Select value={selectedPublisherId} onValueChange={setSelectedPublisherId}>
<Select value={activePublisherId} onValueChange={setSelectedPublisherId}>
<SelectTrigger
aria-label="Dashboard publisher"
className="min-w-[220px] rounded-[var(--radius-sm)]"
+5
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef } from "react";
import { Container } from "../../components/layout/Container";
import { SignInButton } from "../../components/SignInButton";
import { SignInPrompt } from "../../components/SignInPrompt";
import { AuthFlowSkeleton } from "../../components/skeletons/ProtectedPageSkeletons";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { buildDocsAuthCallbackUrl, normalizeDocsReturnTo } from "../../lib/docsAuth";
@@ -58,6 +59,10 @@ export function DocsAuth({ autoSubmit = true }: DocsAuthProps = {}) {
);
}
if (isLoading) {
return <AuthFlowSkeleton title="docs login" />;
}
if (!isAuthenticated || !me) {
return (
<SignInPrompt
+3 -8
View File
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import { Container } from "../components/layout/Container";
import { SignInPrompt } from "../components/SignInPrompt";
import { ImportGitHubSkeleton } from "../components/skeletons/ProtectedPageSkeletons";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
@@ -218,16 +219,10 @@ export function ImportGitHub() {
};
if (isLoading) {
return (
<SignInPrompt
title="Loading..."
description="Checking your session before starting a GitHub import."
showAction={false}
/>
);
return <ImportGitHubSkeleton />;
}
if (!isAuthenticated) {
if (!isAuthenticated || !me) {
return (
<SignInPrompt
title="Sign in to import and publish skills"
+7 -6
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { ManagementSkeleton } from "../components/skeletons/ProtectedPageSkeletons";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
@@ -122,7 +123,7 @@ export const Route = createFileRoute("/management")({
});
export function Management() {
const { me } = useAuthStatus();
const { isLoading: isAuthLoading, me } = useAuthStatus();
const search = Route.useSearch();
const navigate = useNavigate();
const staff = isModerator(me);
@@ -206,6 +207,10 @@ export function Management() {
return () => clearTimeout(handle);
}, [userSearch]);
if (isAuthLoading) {
return <ManagementSkeleton />;
}
if (!staff) {
return (
<main className="section">
@@ -215,11 +220,7 @@ export function Management() {
}
if (!recentVersions || !reportedSkills || !duplicateCandidates) {
return (
<main className="section">
<Card>Loading management console</Card>
</main>
);
return <ManagementSkeleton />;
}
const reportQuery = reportSearchDebounced.trim().toLowerCase();
+3 -3
View File
@@ -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<PublisherOwnerMembership>
| undefined;
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
@@ -212,7 +212,7 @@ export function PublishPluginRoute() {
return <PublishFormSkeleton />;
}
if (!isAuthenticated) {
if (!isAuthenticated || !me) {
return (
<main className="py-10">
<Container size="narrow">
+20 -4
View File
@@ -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<ApiToken> | 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<PublisherMembership>
| 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 <SettingsSkeleton />;
}
if (!isAuthenticated || !me) {
return (
<SignInPrompt
title="Sign in to access settings"
@@ -227,6 +233,16 @@ export function Settings() {
);
}
const activeSectionLoading =
(activeView === "organizations" &&
(publisherMemberships === undefined ||
(selectedOrg && selectedOrg.role !== "publisher" && orgMembers === undefined))) ||
(activeView === "tokens" && tokens === undefined);
if (activeSectionLoading) {
return <SettingsSkeleton />;
}
const accountAvatar = me.image ?? undefined;
const accountInitial = (displayName || me.displayName || me.name || me.handle || "U")
.charAt(0)
+2 -2
View File
@@ -146,7 +146,7 @@ export function Upload() {
const [status, setStatus] = useState<string | null>(null);
const isSubmitting = status !== null;
const [error, setError] = useState<string | null>(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 <PublishFormSkeleton />;
}
if (!isAuthenticated) {
if (!isAuthenticated || !me) {
return (
<main className="py-10">
<Container size="narrow">
+8 -37
View File
@@ -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 (
<main className="browse-page">
<div className="skeleton-list">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-icon" />
<div className="skeleton-row-body">
<div className="skeleton-bar skeleton-bar-lg" />
<div className="skeleton-bar skeleton-bar-sm" />
<div className="skeleton-bar skeleton-bar-xs" />
</div>
</div>
))}
</div>
</main>
);
return <StarsSkeleton />;
}
if (!isAuthenticated) {
if (!isAuthenticated || !me) {
return (
<SignInPrompt
icon={Star}
@@ -116,22 +102,7 @@ export function Stars() {
}
if (skillsQuery === undefined) {
return (
<main className="browse-page">
<div className="skeleton-list">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-icon" />
<div className="skeleton-row-body">
<div className="skeleton-bar skeleton-bar-lg" />
<div className="skeleton-bar skeleton-bar-sm" />
<div className="skeleton-bar skeleton-bar-xs" />
</div>
</div>
))}
</div>
</main>
);
return <StarsSkeleton />;
}
return (