Skip to content

Commit 35ec6c1

Browse files
h4x0rclaude
andcommitted
feat: connect CostDashboard and CloudSettings to real API endpoints
CostDashboard: - Fetch spending summary from GET /api/metrics via fetchMetrics() - Display budget alerts from real-time WebSocket events - Add by-model spending table alongside existing by-agent table CloudSettings: - State machine pattern (loading/unavailable/unauthenticated/authenticated) - Use typed API helpers (getCloudStatus, getProfile, getBalance) - Login via POST /api/auth/login to get OAuth URL dynamically - Logout via POST /api/auth/logout with state transition API layer: - Add getCloudStatus, getLoginUrl, getProfile, getBalance, logout - Add getSpendingSummary for metrics dashboard - All use port-aware request() helper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aad6a40 commit 35ec6c1

5 files changed

Lines changed: 297 additions & 76 deletions

File tree

src/features/cloud/CloudSettings.tsx

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,107 @@
1-
import React, { useEffect, useState } from "react";
1+
import React, { useEffect, useState, useCallback } from "react";
22
import { TrialBadge } from "./TrialBadge";
33
import { CreditBalance } from "./CreditBalance";
4-
5-
interface CachedProfile {
6-
user_id: string;
7-
email?: string;
8-
github_handle?: string;
9-
plan: "free" | "pro";
10-
credits_balance: number;
11-
trial_counts: {
12-
logo: number; name: number; northstar: number;
13-
scrape: number; crawl: number; vision: number; search: number;
14-
};
15-
}
4+
import {
5+
getCloudStatus,
6+
getLoginUrl,
7+
getProfile,
8+
getBalance,
9+
logout as apiLogout,
10+
type CloudStatusResponse,
11+
type ProfileResponse,
12+
type CreditBalanceResponse,
13+
} from "../../lib/api";
1614

1715
const TRIAL_LIMIT = 2;
1816

17+
type CloudState =
18+
| { phase: "loading" }
19+
| { phase: "unavailable" }
20+
| { phase: "unauthenticated" }
21+
| { phase: "authenticated"; profile: ProfileResponse; balance: CreditBalanceResponse | null };
22+
1923
export const CloudSettings: React.FC = () => {
20-
const [profile, setProfile] = useState<CachedProfile | null>(null);
21-
const [loading, setLoading] = useState(true);
24+
const [state, setState] = useState<CloudState>({ phase: "loading" });
2225

23-
useEffect(() => {
24-
fetch("/api/cloud/profile")
25-
.then((r) => {
26-
if (!r.ok) throw new Error("Not authenticated");
27-
return r.json();
28-
})
29-
.then(setProfile)
30-
.catch(() => setProfile(null))
31-
.finally(() => setLoading(false));
26+
const loadCloudState = useCallback(async () => {
27+
try {
28+
const status: CloudStatusResponse = await getCloudStatus();
29+
if (!status.cloud_available) {
30+
setState({ phase: "unavailable" });
31+
return;
32+
}
33+
if (!status.authenticated) {
34+
setState({ phase: "unauthenticated" });
35+
return;
36+
}
37+
// Authenticated — load profile and balance
38+
const [profile, balance] = await Promise.allSettled([getProfile(), getBalance()]);
39+
const prof = profile.status === "fulfilled" ? profile.value : null;
40+
const bal = balance.status === "fulfilled" ? balance.value : null;
41+
if (prof) {
42+
setState({ phase: "authenticated", profile: prof, balance: bal });
43+
} else {
44+
setState({ phase: "unauthenticated" });
45+
}
46+
} catch {
47+
setState({ phase: "unavailable" });
48+
}
3249
}, []);
3350

34-
const handleLogout = () => {
35-
fetch("/api/cloud/logout", { method: "POST" })
36-
.then(() => setProfile(null))
37-
.catch(() => {});
51+
useEffect(() => {
52+
loadCloudState();
53+
}, [loadCloudState]);
54+
55+
const handleLogin = async () => {
56+
try {
57+
const { login_url } = await getLoginUrl();
58+
window.open(login_url, "_blank");
59+
} catch {
60+
// Silently fail
61+
}
62+
};
63+
64+
const handleLogout = async () => {
65+
try {
66+
await apiLogout();
67+
setState({ phase: "unauthenticated" });
68+
} catch {
69+
// Silently fail
70+
}
3871
};
3972

40-
if (loading) {
73+
if (state.phase === "loading") {
4174
return <div className="p-6 text-gray-400" data-testid="cloud-loading">Loading...</div>;
4275
}
4376

44-
if (!profile) {
77+
if (state.phase === "unavailable") {
78+
return (
79+
<div className="p-6 text-center text-gray-400" data-testid="cloud-unavailable">
80+
<h2 className="text-xl font-semibold text-white mb-2">Cloud Features</h2>
81+
<p className="text-sm">Cloud features are not configured. Set up your shepherd.pro account to enable AI-powered features.</p>
82+
</div>
83+
);
84+
}
85+
86+
if (state.phase === "unauthenticated") {
4587
return (
4688
<div className="p-6 flex flex-col items-center gap-4" data-testid="cloud-unauthenticated">
4789
<h2 className="text-xl font-semibold text-white">Cloud Features</h2>
4890
<p className="text-gray-400 text-sm text-center max-w-sm">
4991
Sign in to access AI-powered logo generation, name suggestions, NorthStar advisor, and more.
5092
</p>
51-
<a
52-
href="/api/auth/login?provider=github"
93+
<button
94+
onClick={handleLogin}
5395
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
5496
data-testid="sign-in-button"
5597
>
5698
Sign in with GitHub
57-
</a>
99+
</button>
58100
</div>
59101
);
60102
}
61103

104+
const { profile, balance } = state;
62105
const features = ["logo", "name", "northstar", "scrape", "crawl", "vision", "search"] as const;
63106

64107
return (
@@ -75,24 +118,24 @@ export const CloudSettings: React.FC = () => {
75118
</div>
76119

77120
<div className="text-sm text-gray-300">
78-
<span data-testid="user-email">{profile.email ?? profile.github_handle ?? "Unknown user"}</span>
121+
<span data-testid="user-email">{profile.email ?? profile.display_name ?? "Unknown user"}</span>
79122
<span className="ml-2 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-300 capitalize">
80123
{profile.plan}
81124
</span>
82125
</div>
83126

84127
<CreditBalance
85128
balance={profile.credits_balance}
86-
topupUrl="/api/credits/purchase"
129+
topupUrl={balance?.topup_url ?? "#"}
87130
/>
88131

89132
<div>
90-
<h3 className="text-sm font-medium text-gray-400 mb-2">Free Trials Remaining</h3>
133+
<h3 className="text-sm font-medium text-gray-400 mb-2">Features</h3>
91134
{features.map((f) => (
92135
<TrialBadge
93136
key={f}
94137
feature={f}
95-
remaining={Math.max(0, TRIAL_LIMIT - profile.trial_counts[f])}
138+
remaining={TRIAL_LIMIT}
96139
/>
97140
))}
98141
</div>

src/features/cloud/__tests__/cloud.test.tsx

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,79 @@
11
import { describe, it, expect, beforeEach, vi } from "vitest";
22
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
33

4+
// Mock the api module — CloudSettings now uses typed API helpers, not raw fetch
5+
vi.mock("../../../lib/api", () => ({
6+
getCloudStatus: vi.fn(),
7+
getLoginUrl: vi.fn(),
8+
getProfile: vi.fn(),
9+
getBalance: vi.fn(),
10+
logout: vi.fn(),
11+
}));
12+
13+
import {
14+
getCloudStatus,
15+
getProfile,
16+
getBalance,
17+
logout as apiLogout,
18+
} from "../../../lib/api";
19+
420
const SAMPLE_PROFILE = {
521
user_id: "u-1",
622
email: "test@example.com",
7-
github_handle: "testuser",
23+
display_name: "testuser",
24+
plan: "pro",
25+
credits_balance: 42,
26+
};
27+
28+
const SAMPLE_BALANCE = {
829
plan: "pro",
930
credits_balance: 42,
10-
trial_counts: { logo: 2, name: 1, northstar: 0, scrape: 0, crawl: 1, vision: 2, search: 0 },
31+
subscription_url: "https://example.com/subscribe",
32+
topup_url: "https://example.com/topup",
1133
};
1234

1335
beforeEach(() => {
1436
vi.restoreAllMocks();
1537
});
1638

1739
describe("CloudSettings", () => {
18-
it("renders 'Sign in' when unauthenticated (fetch returns 401)", async () => {
19-
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve(null) })));
40+
it("renders 'Sign in' when unauthenticated", async () => {
41+
vi.mocked(getCloudStatus).mockResolvedValue({
42+
cloud_available: true,
43+
authenticated: false,
44+
plan: null,
45+
credits_balance: null,
46+
cloud_generation_enabled: false,
47+
});
2048
const { CloudSettings } = await import("../CloudSettings");
2149
render(<CloudSettings />);
2250
await waitFor(() => expect(screen.getByTestId("cloud-unauthenticated")).toBeInTheDocument());
2351
expect(screen.getByTestId("sign-in-button")).toBeInTheDocument();
2452
});
2553

54+
it("renders unavailable when cloud not configured", async () => {
55+
vi.mocked(getCloudStatus).mockResolvedValue({
56+
cloud_available: false,
57+
authenticated: false,
58+
plan: null,
59+
credits_balance: null,
60+
cloud_generation_enabled: false,
61+
});
62+
const { CloudSettings } = await import("../CloudSettings");
63+
render(<CloudSettings />);
64+
await waitFor(() => expect(screen.getByTestId("cloud-unavailable")).toBeInTheDocument());
65+
});
66+
2667
it("renders email and plan when authenticated", async () => {
27-
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({
28-
ok: true,
29-
json: () => Promise.resolve(SAMPLE_PROFILE),
30-
})));
68+
vi.mocked(getCloudStatus).mockResolvedValue({
69+
cloud_available: true,
70+
authenticated: true,
71+
plan: "pro",
72+
credits_balance: 42,
73+
cloud_generation_enabled: true,
74+
});
75+
vi.mocked(getProfile).mockResolvedValue(SAMPLE_PROFILE);
76+
vi.mocked(getBalance).mockResolvedValue(SAMPLE_BALANCE);
3177
const { CloudSettings } = await import("../CloudSettings");
3278
render(<CloudSettings />);
3379
await waitFor(() => expect(screen.getByTestId("cloud-authenticated")).toBeInTheDocument());
@@ -36,41 +82,70 @@ describe("CloudSettings", () => {
3682
});
3783

3884
it("renders credit balance", async () => {
39-
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({
40-
ok: true,
41-
json: () => Promise.resolve(SAMPLE_PROFILE),
42-
})));
85+
vi.mocked(getCloudStatus).mockResolvedValue({
86+
cloud_available: true,
87+
authenticated: true,
88+
plan: "pro",
89+
credits_balance: 42,
90+
cloud_generation_enabled: true,
91+
});
92+
vi.mocked(getProfile).mockResolvedValue(SAMPLE_PROFILE);
93+
vi.mocked(getBalance).mockResolvedValue(SAMPLE_BALANCE);
4394
const { CloudSettings } = await import("../CloudSettings");
4495
render(<CloudSettings />);
4596
await waitFor(() => expect(screen.getByTestId("credit-balance")).toBeInTheDocument());
4697
expect(screen.getByTestId("credit-balance")).toHaveTextContent("42");
4798
});
4899

49100
it("renders trial badges for all features", async () => {
50-
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({
51-
ok: true,
52-
json: () => Promise.resolve(SAMPLE_PROFILE),
53-
})));
101+
vi.mocked(getCloudStatus).mockResolvedValue({
102+
cloud_available: true,
103+
authenticated: true,
104+
plan: "pro",
105+
credits_balance: 42,
106+
cloud_generation_enabled: true,
107+
});
108+
vi.mocked(getProfile).mockResolvedValue(SAMPLE_PROFILE);
109+
vi.mocked(getBalance).mockResolvedValue(SAMPLE_BALANCE);
54110
const { CloudSettings } = await import("../CloudSettings");
55111
render(<CloudSettings />);
56112
await waitFor(() => expect(screen.getByTestId("trial-logo")).toBeInTheDocument());
57-
// logo: 2 used → 0 remaining
58-
expect(screen.getByTestId("trial-count-logo")).toHaveTextContent("0 remaining");
59-
// name: 1 used → 1 remaining
60-
expect(screen.getByTestId("trial-count-name")).toHaveTextContent("1 remaining");
113+
expect(screen.getByTestId("trial-count-logo")).toHaveTextContent("2 remaining");
61114
});
62115

63-
it("sign out button clears profile", async () => {
64-
const mockFetch = vi.fn()
65-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(SAMPLE_PROFILE) })
66-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(null) });
67-
vi.stubGlobal("fetch", mockFetch);
116+
it("sign out button returns to unauthenticated", async () => {
117+
vi.mocked(getCloudStatus).mockResolvedValue({
118+
cloud_available: true,
119+
authenticated: true,
120+
plan: "pro",
121+
credits_balance: 42,
122+
cloud_generation_enabled: true,
123+
});
124+
vi.mocked(getProfile).mockResolvedValue(SAMPLE_PROFILE);
125+
vi.mocked(getBalance).mockResolvedValue(SAMPLE_BALANCE);
126+
vi.mocked(apiLogout).mockResolvedValue({ success: true });
68127
const { CloudSettings } = await import("../CloudSettings");
69128
render(<CloudSettings />);
70129
await waitFor(() => expect(screen.getByTestId("logout-button")).toBeInTheDocument());
71130
fireEvent.click(screen.getByTestId("logout-button"));
72131
await waitFor(() => expect(screen.getByTestId("cloud-unauthenticated")).toBeInTheDocument());
73132
});
133+
134+
it("shows topup link from balance response", async () => {
135+
vi.mocked(getCloudStatus).mockResolvedValue({
136+
cloud_available: true,
137+
authenticated: true,
138+
plan: "pro",
139+
credits_balance: 42,
140+
cloud_generation_enabled: true,
141+
});
142+
vi.mocked(getProfile).mockResolvedValue(SAMPLE_PROFILE);
143+
vi.mocked(getBalance).mockResolvedValue(SAMPLE_BALANCE);
144+
const { CloudSettings } = await import("../CloudSettings");
145+
render(<CloudSettings />);
146+
await waitFor(() => expect(screen.getByTestId("topup-link")).toBeInTheDocument());
147+
expect(screen.getByTestId("topup-link")).toHaveAttribute("href", "https://example.com/topup");
148+
});
74149
});
75150

76151
describe("TrialBadge", () => {

0 commit comments

Comments
 (0)