diff --git a/src/App.tsx b/src/App.tsx index 67386d6f..f1056c83 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -197,6 +197,10 @@ const App = () => { path="/music-copyright" element={} /> + } + /> } diff --git a/src/components/partners/ClientId.tsx b/src/components/partners/ClientId.tsx new file mode 100644 index 00000000..4da935e8 --- /dev/null +++ b/src/components/partners/ClientId.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +/** + * Renders a partner's OAuth client id, with a copy affordance. Until RenderDragon + * registers the OAuth client, the id is null, so we say so explicitly instead of + * showing a bare em dash that looks like a rendering bug. + */ +export function ClientId({ + clientId, + className, +}: { + clientId: string | null | undefined; + className?: string; +}) { + const [copied, setCopied] = useState(false); + + if (!clientId) { + return ( + + + Pending registration + + ); + } + + const copy = async () => { + try { + await navigator.clipboard.writeText(clientId); + setCopied(true); + toast.success("Client id copied"); + window.setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Could not copy the client id"); + } + }; + + return ( + + + {clientId} + + + + ); +} diff --git a/src/components/partners/PartnerAppForm.tsx b/src/components/partners/PartnerAppForm.tsx index 9d5dfe69..c2bb1887 100644 --- a/src/components/partners/PartnerAppForm.tsx +++ b/src/components/partners/PartnerAppForm.tsx @@ -11,6 +11,8 @@ import { toast } from "sonner"; import { PARTNER_PERMISSIONS, type PartnerPermissionId } from "@/lib/partners/permissions"; import { updatePartnerApp, type PartnerApp } from "@/lib/partners/client"; import { RedirectUriField } from "@/components/partners/RedirectUriField"; +import { ClientId } from "@/components/partners/ClientId"; +import { ImageUpload } from "@/components/profile/ImageUpload"; export function PartnerAppForm({ app, onSaved }: { app: PartnerApp; onSaved: () => void }) { const [name, setName] = useState(app.name); @@ -54,13 +56,33 @@ export function PartnerAppForm({ app, onSaved }: { app: PartnerApp; onSaved: ()
- - setLogoUrl(event.target.value)} - placeholder="https://…/logo.png" - /> + +
+ {logoUrl ? ( + + ) : ( +
+ {(name.trim() || "A").charAt(0).toUpperCase()} +
+ )} +
+ setLogoUrl(url)} + /> +

+ Shown on the consent screen. PNG, JPG or GIF up to 5 MB. +

+
+
@@ -124,9 +146,16 @@ export function PartnerAppForm({ app, onSaved }: { app: PartnerApp; onSaved: ()
-
+
Client id
-
{app.supabase_client_id ?? "—"}
+
+ +
+ {!app.supabase_client_id && ( +

+ We register your OAuth client once your redirect URIs are set, then your client id appears here. +

+ )}
Client type
diff --git a/src/components/partners/PartnerKeys.tsx b/src/components/partners/PartnerKeys.tsx index 43c79e8d..dda3ed58 100644 --- a/src/components/partners/PartnerKeys.tsx +++ b/src/components/partners/PartnerKeys.tsx @@ -99,7 +99,7 @@ export function PartnerKeys({

) : ( -
+
diff --git a/src/components/partners/PartnerOnboarding.tsx b/src/components/partners/PartnerOnboarding.tsx index efeec98b..cdac0c14 100644 --- a/src/components/partners/PartnerOnboarding.tsx +++ b/src/components/partners/PartnerOnboarding.tsx @@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge"; import Logo from "@/components/Logo"; import { ConsentFieldList } from "@/components/partners/ConsentFieldList"; import { RedirectUriField } from "@/components/partners/RedirectUriField"; +import { ClientId } from "@/components/partners/ClientId"; import { ImageUpload } from "@/components/profile/ImageUpload"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -79,6 +80,84 @@ const STEPS: Step[] = [ }, ]; +interface OnboardingDraft { + active: number; + name: string; + description: string; + logoUrl: string; + redirectUris: string[]; + permissions: PartnerPermissionId[]; + issued: IssuedPartnerKey | null; +} + +const draftKey = (userId: string | undefined, appId: string) => + `partner-onboarding-draft:${userId ?? "anon"}:${appId}`; + +/** + * The wizard keeps its draft in sessionStorage so a remount (token refresh on + * tab focus, StrictMode, an HMR reload) doesn't wipe half-filled fields or send + * the partner back to step one. + */ +function readDraft(app: PartnerApp, userId: string | undefined): OnboardingDraft { + const defaults: OnboardingDraft = { + active: 0, + name: app.name, + description: app.description ?? "", + logoUrl: app.logo_url ?? "", + redirectUris: app.redirect_uris ?? [], + permissions: app.permissions.length ? app.permissions : ["profile"], + issued: null, + }; + + try { + const raw = window.sessionStorage.getItem(draftKey(userId, app.id)); + if (!raw) return defaults; + const parsed = JSON.parse(raw) as Partial; + + const permissions = Array.isArray(parsed.permissions) + ? parsed.permissions.filter((id): id is PartnerPermissionId => + PARTNER_PERMISSIONS.some((permission) => permission.id === id), + ) + : defaults.permissions; + + return { + active: + typeof parsed.active === "number" + ? Math.min(Math.max(Math.trunc(parsed.active), 0), STEPS.length - 1) + : defaults.active, + name: typeof parsed.name === "string" ? parsed.name : defaults.name, + description: typeof parsed.description === "string" ? parsed.description : defaults.description, + logoUrl: typeof parsed.logoUrl === "string" ? parsed.logoUrl : defaults.logoUrl, + redirectUris: Array.isArray(parsed.redirectUris) + ? parsed.redirectUris.filter((uri): uri is string => typeof uri === "string") + : defaults.redirectUris, + permissions: permissions.length ? permissions : defaults.permissions, + issued: + parsed.issued && typeof parsed.issued.key_id === "string" && typeof parsed.issued.secret === "string" + ? (parsed.issued as IssuedPartnerKey) + : null, + }; + } catch { + return defaults; + } +} + +function writeDraft(userId: string | undefined, appId: string, draft: OnboardingDraft) { + try { + window.sessionStorage.setItem(draftKey(userId, appId), JSON.stringify(draft)); + } catch { + // Private mode / storage disabled — the wizard still works, it just won't survive a remount. + } +} + +function clearDraft(userId: string | undefined, appId: string) { + try { + window.sessionStorage.removeItem(draftKey(userId, appId)); + } catch { + // ignore + } +} + export function PartnerOnboarding({ app, org, @@ -91,18 +170,17 @@ export function PartnerOnboarding({ const { user, signOut } = useAuth(); const { profile } = useProfile(); - const [active, setActive] = useState(0); - const [name, setName] = useState(app.name); - const [description, setDescription] = useState(app.description ?? ""); - const [logoUrl, setLogoUrl] = useState(app.logo_url ?? ""); - const [redirectUris, setRedirectUris] = useState(app.redirect_uris ?? []); - const [permissions, setPermissions] = useState( - app.permissions.length ? app.permissions : ["profile"], - ); + const [initialDraft] = useState(() => readDraft(app, user?.id)); + const [active, setActive] = useState(initialDraft.active); + const [name, setName] = useState(initialDraft.name); + const [description, setDescription] = useState(initialDraft.description); + const [logoUrl, setLogoUrl] = useState(initialDraft.logoUrl); + const [redirectUris, setRedirectUris] = useState(initialDraft.redirectUris); + const [permissions, setPermissions] = useState(initialDraft.permissions); const [saving, setSaving] = useState(false); const [issuing, setIssuing] = useState(false); - const [issued, setIssued] = useState(null); + const [issued, setIssued] = useState(initialDraft.issued); const titleRef = useRef(null); const checkRef = useRef(null); @@ -118,6 +196,18 @@ export function PartnerOnboarding({ ); }; + useEffect(() => { + writeDraft(user?.id, app.id, { + active, + name, + description, + logoUrl, + redirectUris, + permissions, + issued, + }); + }, [user?.id, app.id, active, name, description, logoUrl, redirectUris, permissions, issued]); + // transitions.dev — text-states-swap on the step title. useEffect(() => { const el = titleRef.current; @@ -167,6 +257,7 @@ export function PartnerOnboarding({ setSaving(true); try { await completePartnerOnboarding(app.id); + clearDraft(user?.id, app.id); onComplete(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not skip setup."); @@ -185,6 +276,7 @@ export function PartnerOnboarding({ permissions, }); await completePartnerOnboarding(app.id); + clearDraft(user?.id, app.id); onComplete(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not finish setup."); @@ -360,35 +452,33 @@ export function PartnerOnboarding({ onChange={(event) => setName(event.target.value)} placeholder="Acme Tools" /> -
-
+ + + Endpoint + Calls + Errors + p95 + + + + {stats.endpoints.map((endpoint) => ( + + + {endpoint.endpoint} + + + {endpoint.calls.toLocaleString()} + + + {endpoint.errors.toLocaleString()} + + + {formatLatency(endpoint.p95LatencyMs)} + + + ))} + +
+ )} + + + + + + Response statuses + HTTP status codes returned by your API calls. + + + {stats.statuses.length === 0 ? ( +

No status data yet.

+ ) : ( + + + + Status + Calls + Share + + + + {stats.statuses.map((entry) => ( + + + + + + {entry.calls.toLocaleString()} + + + {formatPercent(entry.calls / stats.calls)} + + + ))} + +
+ )} +
+
+
+ + + + Daily breakdown + Per day over the last 30 days (UTC). + + + + + + Day + Calls + Errors + Error rate + p95 latency + + + + {[...daily].reverse().map((point) => ( + + {point.label} + + {point.calls.toLocaleString()} + + + {point.errors.toLocaleString()} + + + {point.calls > 0 ? formatPercent(point.errors / point.calls) : "—"} + + + {formatLatency(point.p95LatencyMs)} + + + ))} + +
+
+
+ + + + Recent API activity + The latest requests recorded for your organization. + + + {stats.recentEvents.length === 0 ? ( +

No events recorded yet.

+ ) : ( + + + + Time + Type + Endpoint + Status + Latency + + + + {stats.recentEvents.map((event, index) => ( + + + {formatEventTime(event.ts)} + + {event.type} + + {event.endpoint ?? "—"} + + + + + + {formatLatency(event.latency_ms ?? 0)} + + + ))} + +
+ )} +
+
+ + )}
); } diff --git a/src/lib/partners/client.ts b/src/lib/partners/client.ts index 68536e4c..b64440af 100644 --- a/src/lib/partners/client.ts +++ b/src/lib/partners/client.ts @@ -193,17 +193,57 @@ export async function listPartnerUsage(orgId: string, days = 30): Promise a - b); + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; } +const isErrorStatus = (status: number | null) => status !== null && status >= 400; + export function summarizeUsage( consents: PartnerConsent[], keys: PartnerApiKey[], @@ -221,27 +261,99 @@ export function summarizeUsage( const activeKeys = keys.filter((key) => key.status === "active").length; const calls = events.length; - const errors = events.filter((event) => event.status !== null && event.status >= 400).length; + const errors = events.filter((event) => isErrorStatus(event.status)).length; const errorRate = calls > 0 ? errors / calls : 0; + const p95LatencyMs = percentile95( + events + .map((event) => event.latency_ms) + .filter((value): value is number => typeof value === "number"), + ); - const latencies = events - .map((event) => event.latency_ms) - .filter((value): value is number => typeof value === "number") - .sort((a, b) => a - b); - const p95LatencyMs = latencies.length > 0 ? latencies[Math.floor(latencies.length * 0.95)] : 0; - - const buckets = new Map(); + const buckets = new Map(); + const userBuckets = new Map(); for (let i = days - 1; i >= 0; i -= 1) { const date = new Date(now - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - buckets.set(date, 0); + buckets.set(date, { calls: 0, errors: 0, latencies: [] }); + userBuckets.set(date, { signIns: 0, revocations: 0 }); } + + for (const consent of consents) { + const granted = userBuckets.get(consent.granted_at.slice(0, 10)); + if (granted) granted.signIns += 1; + if (consent.revoked_at) { + const revoked = userBuckets.get(consent.revoked_at.slice(0, 10)); + if (revoked) revoked.revocations += 1; + } + } + + const endpointMap = new Map(); + const statusMap = new Map(); + for (const event of events) { - const date = event.ts.slice(0, 10); - if (buckets.has(date)) buckets.set(date, (buckets.get(date) ?? 0) + 1); + const bucket = buckets.get(event.ts.slice(0, 10)); + if (bucket) { + bucket.calls += 1; + if (isErrorStatus(event.status)) bucket.errors += 1; + if (typeof event.latency_ms === "number") bucket.latencies.push(event.latency_ms); + } + + const endpoint = event.endpoint?.trim() || "Unattributed"; + const endpointEntry = endpointMap.get(endpoint) ?? { calls: 0, errors: 0, latencies: [] }; + endpointEntry.calls += 1; + if (isErrorStatus(event.status)) endpointEntry.errors += 1; + if (typeof event.latency_ms === "number") endpointEntry.latencies.push(event.latency_ms); + endpointMap.set(endpoint, endpointEntry); + + statusMap.set(event.status, (statusMap.get(event.status) ?? 0) + 1); } - const dailyCalls = [...buckets.entries()].map(([date, count]) => ({ date, calls: count })); - return { totalUsers, activeUsers, revokedUsers, activeKeys, calls, errorRate, p95LatencyMs, dailyCalls }; + const daily: PartnerDailyStats[] = [...buckets.entries()].map(([date, bucket]) => ({ + date, + calls: bucket.calls, + errors: bucket.errors, + p95LatencyMs: percentile95(bucket.latencies), + })); + + const userDaily: PartnerUserDayStats[] = [...userBuckets.entries()].map(([date, bucket]) => ({ + date, + signIns: bucket.signIns, + revocations: bucket.revocations, + })); + + const endpoints: PartnerEndpointStats[] = [...endpointMap.entries()] + .map(([endpoint, entry]) => ({ + endpoint, + calls: entry.calls, + errors: entry.errors, + errorRate: entry.calls > 0 ? entry.errors / entry.calls : 0, + p95LatencyMs: percentile95(entry.latencies), + })) + .sort((a, b) => b.calls - a.calls) + .slice(0, 10); + + const statuses: PartnerStatusStats[] = [...statusMap.entries()] + .map(([status, count]) => ({ status, calls: count })) + .sort((a, b) => b.calls - a.calls); + + const recentEvents = [...events] + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, 15); + + return { + totalUsers, + activeUsers, + revokedUsers, + activeKeys, + calls, + errors, + errorRate, + p95LatencyMs, + daily, + userDaily, + endpoints, + statuses, + recentEvents, + }; } export interface PublicPartnerApp { diff --git a/src/pages/PartnerDashboard.tsx b/src/pages/PartnerDashboard.tsx index 4dd4926c..9a886e88 100644 --- a/src/pages/PartnerDashboard.tsx +++ b/src/pages/PartnerDashboard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Helmet } from "react-helmet-async"; import { Link } from "react-router-dom"; import { IconLoader2, IconArrowRight, IconKey } from "@tabler/icons-react"; @@ -14,6 +14,7 @@ import { PartnerUsers } from "@/components/partners/PartnerUsers"; import { PartnerKeys } from "@/components/partners/PartnerKeys"; import { PartnerStatsPanel } from "@/components/partners/PartnerStatsPanel"; import { PartnerOnboarding } from "@/components/partners/PartnerOnboarding"; +import { ClientId } from "@/components/partners/ClientId"; import { getPartnerContext, listPartnerConsents, @@ -33,9 +34,14 @@ const EMPTY_STATS: PartnerStats = { revokedUsers: 0, activeKeys: 0, calls: 0, + errors: 0, errorRate: 0, p95LatencyMs: 0, - dailyCalls: [], + daily: [], + userDaily: [], + endpoints: [], + statuses: [], + recentEvents: [], }; export default function PartnerDashboard() { @@ -46,9 +52,12 @@ export default function PartnerDashboard() { const [keys, setKeys] = useState([]); const [stats, setStats] = useState(EMPTY_STATS); const [authOpen, setAuthOpen] = useState(false); + const hasLoadedRef = useRef(false); const load = useCallback(async () => { - setLoading(true); + // Only show the full-page loader on the first fetch. Refreshes (e.g. after a + // token refresh on tab focus) must not unmount the onboarding wizard. + if (!hasLoadedRef.current) setLoading(true); try { const ctx = await getPartnerContext(); setContext(ctx); @@ -63,14 +72,23 @@ export default function PartnerDashboard() { setStats(summarizeUsage(consentRows, keyRows, usage)); } } finally { + hasLoadedRef.current = true; setLoading(false); } }, []); + const userId = user?.id; + useEffect(() => { - if (!authLoading && user) void load(); - if (!authLoading && !user) setLoading(false); - }, [authLoading, user, load]); + if (authLoading) return; + if (userId) { + // Depend on the id, not the whole user object: Supabase replaces the user + // object on TOKEN_REFRESHED (tab focus), which would otherwise re-run this. + void load(); + } else { + setLoading(false); + } + }, [authLoading, userId, load]); // First-time setup takes over the whole screen — no dashboard chrome. if (!authLoading && !loading && user && context?.app && !context.app.onboarded_at) { @@ -178,7 +196,7 @@ export default function PartnerDashboard() { Stats - +
@@ -187,8 +205,14 @@ export default function PartnerDashboard() {

{context.app.description || "No description yet."}

+ {!context.app.supabase_client_id && ( +

+ Your OAuth client hasn't been registered yet. Add at least one redirect URI in the + App tab and we'll issue the client id. +

+ )}
- + } /> - + - + - + - + @@ -247,11 +271,11 @@ function Metric({ label, value }: { label: string; value: string }) { ); } -function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { +function Row({ label, value }: { label: string; value: React.ReactNode }) { return (
-
{label}
-
{value}
+
{label}
+
{value}
); }