mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix: stabilize auth loading states (#2447)
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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];
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user