Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
- run: npm pack --dry-run

platform:
name: Platform
name: Platform + Auth E2E
runs-on: ubuntu-latest
defaults:
run:
Expand Down
49 changes: 40 additions & 9 deletions web/app/dashboard/WorkspaceClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useDashboardSession } from './DashboardSessionProvider';

type ProviderId = 'youtube';
type Section = DashboardSection;
type SourceState = 'idle' | 'live' | 'degraded';
type PlatformHealthState = 'checking' | 'healthy' | 'unavailable';
type EntityType = 'video' | 'channel' | 'playlist';
type SourceDataOption = 'transcript' | 'comments' | 'channel';
type Thumbnail = { url: string; width?: number; height?: number };
Expand Down Expand Up @@ -120,6 +120,8 @@ type AiTrendPlan = {
type Usage = DashboardUsage;

const REQUEST_TIMEOUT_MS = 15_000;
const PLATFORM_HEALTH_TIMEOUT_MS = 5_000;
const PLATFORM_HEALTH_INTERVAL_MS = 5 * 60_000;
const YOUTUBE_API = '/v1/providers/youtube';
const SOURCE_DATA_OPTIONS: Record<SourceDataOption, { shortLabel: string; description: string }> = {
transcript: { shortLabel: 'Transcript', description: 'Complete timestamped spoken text' },
Expand Down Expand Up @@ -200,7 +202,7 @@ export default function WorkspaceClient({ initialSection = 'trends', emailConsen
const [showSignIn, setShowSignIn] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const [operationLabel, setOperationLabel] = useState('');
const [sourceState, setSourceState] = useState<SourceState>('idle');
const [platformHealth, setPlatformHealth] = useState<PlatformHealthState>('checking');
const [selectedProject, setSelectedProject] = useState<ProjectDetail | null>(null);
const [projectLoading, setProjectLoading] = useState(false);
const [projectError, setProjectError] = useState('');
Expand Down Expand Up @@ -300,6 +302,37 @@ export default function WorkspaceClient({ initialSection = 'trends', emailConsen
operationController.current?.abort(); projectController.current?.abort();
}, []);

useEffect(() => {
let cancelled = false;
let checking = false;
let controller: AbortController | undefined;
const checkPlatformHealth = async () => {
if (checking) return;
checking = true;
controller = new AbortController();
try {
const health = await api<{ status?: string }>('/health', { cache: 'no-store', signal: controller.signal }, PLATFORM_HEALTH_TIMEOUT_MS);
if (!cancelled) setPlatformHealth(health.status === 'ok' ? 'healthy' : 'unavailable');
} catch (cause) {
if (!cancelled && !isAbortError(cause)) setPlatformHealth('unavailable');
} finally {
checking = false;
}
};
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') void checkPlatformHealth();
};
void checkPlatformHealth();
const interval = window.setInterval(() => void checkPlatformHealth(), PLATFORM_HEALTH_INTERVAL_MS);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
cancelled = true;
controller?.abort();
window.clearInterval(interval);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);

useEffect(() => {
const legacy = monitors.filter((monitor) =>
isYouTubeChannelId(monitor.target) &&
Expand Down Expand Up @@ -350,9 +383,8 @@ export default function WorkspaceClient({ initialSection = 'trends', emailConsen
const params = new URLSearchParams({ q: resolved.query ?? query, type: 'video' });
const data = await api<{ results: SearchItem[] }>(`${YOUTUBE_API}/search?${params}`, { signal: controller.signal });
setItems(data.results.filter((item) => item.type === 'video').map((item) => ({ ...item, provider: 'youtube' })));
setSourceState('live');
} catch (cause) {
if (!isAbortError(cause)) { setError(cause instanceof Error ? cause.message : 'Search failed.'); setSourceState('degraded'); }
if (!isAbortError(cause)) setError(cause instanceof Error ? cause.message : 'Search failed.');
} finally { finishOperation(controller); }
};

Expand Down Expand Up @@ -386,8 +418,8 @@ export default function WorkspaceClient({ initialSection = 'trends', emailConsen
}
}));
}
setInspector(next); setSourceState('live');
} catch (cause) { if (!isAbortError(cause)) { setError(cause instanceof Error ? cause.message : 'Could not open this source.'); setSourceState('degraded'); } }
setInspector(next);
} catch (cause) { if (!isAbortError(cause)) setError(cause instanceof Error ? cause.message : 'Could not open this source.'); }
finally { finishOperation(controller); }
};

Expand Down Expand Up @@ -530,16 +562,15 @@ export default function WorkspaceClient({ initialSection = 'trends', emailConsen
<header className='topbar'>
<div><span className='topbar-context'>Research workspace</span><h1>{section === 'trends' ? 'Trend Lab' : section === 'discover' ? 'Sources' : section === 'projects' ? 'Projects' : section === 'monitors' ? 'Monitors' : 'Settings'}</h1></div>
<div className='topbar-actions'>
<span className={`sync-state ${sourceState}`} role='status' aria-live='polite'><i />{sourceState === 'live' ? 'Sources live' : sourceState === 'idle' ? 'Ready to search' : 'Sources limited'}</span>
<span className={`sync-state ${platformHealth}`} role='status' aria-live='polite'><i />{platformHealth === 'healthy' ? 'Platform online' : platformHealth === 'checking' ? 'Checking platform' : 'Platform unavailable'}</span>
{usage && <span className='credit-balance'>{usage.creditBalance} credits</span>}
<NotificationMenu
notifications={notifications}
enabled={notificationPreferences.inApp}
onOpen={(notification) => void openNotification(notification)}
onMarkAll={() => void markAllNotificationsRead()}
onSettings={() => navigateTo('settings')}
/>
{usage && <span className='credit-balance'>{usage.creditBalance} credits</span>}
<Link className='signin-button' href='/dashboard/developer'>API keys</Link>
</div>
</header>

Expand Down
94 changes: 59 additions & 35 deletions web/app/dashboard/developer/DeveloperSettingsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ type ManagedApiKey = {

export default function DeveloperSettingsClient() {
const router = useRouter();
const { user, signOut } = useDashboardSession();
const { user, demoEnabled, signOut } = useDashboardSession();
const localPreview = !user && demoEnabled;
const displayUser = user ?? (demoEnabled ? {
id: 'local-preview',
name: 'Local preview',
email: 'local@video2ctx.dev',
} : null);
const [keys, setKeys] = useState<ManagedApiKey[]>([]);
const [projects, setProjects] = useState<DashboardProject[]>([]);
const [credits, setCredits] = useState<number>();
Expand Down Expand Up @@ -48,16 +54,20 @@ export default function DeveloperSettingsClient() {
}, []);

useEffect(() => {
if (!user) return;
void Promise.all([refresh(), refreshSidebar()]).catch((cause) => setError(cause instanceof Error ? cause.message : 'Could not load API keys.'));
}, [refresh, refreshSidebar, user]);
if (user) {
void Promise.all([refresh(), refreshSidebar()]).catch((cause) => setError(cause instanceof Error ? cause.message : 'Could not load API keys.'));
return;
}
if (demoEnabled) void refreshSidebar().catch(() => undefined);
}, [demoEnabled, refresh, refreshSidebar, user]);

const navigateToDashboard = (section: DashboardSection) => {
router.push(`/dashboard?section=${section}`);
};

const createKey = async (event: FormEvent) => {
event.preventDefault();
if (localPreview) return;
const keyName = name.trim();
if (!keyName) return;
setLoading(true); setError(''); setCreatedSecret('');
Expand All @@ -75,6 +85,7 @@ export default function DeveloperSettingsClient() {
};

const revoke = async (key: ManagedApiKey) => {
if (localPreview) return;
if (!window.confirm(`Revoke “${key.name ?? key.start ?? 'API key'}”? Requests using it will stop immediately.`)) return;
setLoading(true); setError('');
try {
Expand All @@ -92,7 +103,7 @@ export default function DeveloperSettingsClient() {
await navigator.clipboard.writeText(createdSecret);
};

if (!user) {
if (!displayUser) {
return <main className='developer-page developer-gate'>
<Link href='/dashboard'>← Dashboard</Link>
<p className='panel-label'>Developer access</p>
Expand All @@ -110,56 +121,69 @@ export default function DeveloperSettingsClient() {
onNewProject={() => navigateToDashboard('projects')}
onOpenProject={() => navigateToDashboard('projects')}
onSignIn={() => router.push('/dashboard')}
accountName={user.name ?? user.email}
accountName={displayUser.name ?? displayUser.email}
credits={credits}
onSignOut={() => void signOut()}
/>
<div className='workspace-main'>
<header className='topbar'>
<div><span className='topbar-context'>Research workspace</span><h1>API keys</h1></div>
<div className='topbar-actions'>{credits !== undefined && <span className='credit-balance'>{credits} credits</span>}<Link className='signin-button' href='/dashboard'><span>Dashboard</span><b aria-hidden='true'>←</b></Link></div>
<div className='topbar-actions'>{localPreview && <span className='developer-preview-badge'>Local preview</span>}{credits !== undefined && <span className='credit-balance'>{credits} credits</span>}</div>
</header>

<section className='developer-page developer-page-embedded'>
<section className='developer-page developer-page-embedded' aria-labelledby='developer-title'>
<header className='developer-header'>
<div><p className='panel-label'>Developer access</p><h1>Personal API keys</h1><p>Use permanent keys for your own scripts and integrations. They act as you for product data and workspace operations, and metered requests spend credits from {user.email}.</p></div>
<a href='/api/platform/docs' target='_blank' rel='noreferrer'>Open API reference ↗</a>
<div>
<p className='panel-label'>Developer access</p>
<h1 id='developer-title'>Connect your own tools.</h1>
<p>Create permanent API keys for scripts and integrations. Requests use the plan and credit balance attached to {displayUser.email}.</p>
</div>
<a href='/api/platform/docs' target='_blank' rel='noreferrer'>API reference <span aria-hidden='true'>↗</span></a>
</header>

<section className='developer-warning' role='note'>
<strong>Permanent until revoked</strong>
<p>Store keys in a secret manager, never in browser code or source control. The full value is shown only once. Keys cannot manage billing, connections, other keys, or your account.</p>
</section>

<section className='developer-card'>
<h2>Use a key</h2>
<p>Send it as a Bearer token. The older <code>X-API-Key</code> header remains supported for existing integrations.</p>
<code>Authorization: Bearer aty_…</code>
</section>
<section className='developer-workbench' aria-labelledby='create-key-title'>
<div className='developer-create'>
<p className='developer-section-label'>Create a key</p>
<h2 id='create-key-title'>Name this integration</h2>
<p>A descriptive name makes it easier to identify and revoke the right credential later.</p>
<form onSubmit={createKey} className='developer-key-form' aria-describedby={localPreview ? 'developer-preview-note' : undefined}>
<label htmlFor='api-key-name'>Key name</label>
<div><input id='api-key-name' maxLength={32} required value={name} onChange={(event) => setName(event.target.value)} placeholder='Production integration' /><button disabled={localPreview || loading || !name.trim()} title={localPreview ? 'Sign in to create a real API key' : undefined}>{loading ? 'Creating…' : 'Create key'}</button></div>
</form>
{localPreview && <p className='developer-preview-note' id='developer-preview-note'>Preview mode shows the complete layout without creating credentials. Sign in to manage real keys.</p>}
{createdSecret && <div className='developer-secret' role='status'>
<strong>Copy this key now</strong>
<p>The full value will not be shown again.</p>
<code>{createdSecret}</code>
<button onClick={() => void copySecret()}>Copy key</button>
</div>}
{error && <p className='alert error' role='alert'>{error}</p>}
</div>

<section className='developer-card'>
<h2>Create a key</h2>
<form onSubmit={createKey} className='developer-key-form'>
<label htmlFor='api-key-name'>Key name</label>
<div><input id='api-key-name' maxLength={32} required value={name} onChange={(event) => setName(event.target.value)} placeholder='Production integration' /><button disabled={loading || !name.trim()}>Create key</button></div>
</form>
{createdSecret && <div className='developer-secret' role='status'>
<strong>Copy this key now</strong>
<code>{createdSecret}</code>
<button onClick={() => void copySecret()}>Copy key</button>
</div>}
{error && <p className='alert error' role='alert'>{error}</p>}
<aside className='developer-guide' aria-labelledby='use-key-title'>
<p className='developer-section-label'>Authentication</p>
<h2 id='use-key-title'>Use it as a Bearer token</h2>
<p>Send the key in the authorization header. <code>X-API-Key</code> remains supported for existing integrations.</p>
<code className='developer-code-sample'>Authorization: Bearer aty_…</code>
<div className='developer-warning' role='note'>
<strong>Permanent until revoked</strong>
<p>Store keys in a secret manager, never in browser code or source control. Keys cannot manage billing, connections, other keys, or your account.</p>
</div>
</aside>
</section>

<section className='developer-card'>
<h2>Active keys</h2>
<section className='developer-keys' aria-labelledby='active-keys-title'>
<header>
<div><p className='panel-label'>Credentials</p><h2 id='active-keys-title'>Active keys</h2></div>
<span>{keys.length} {keys.length === 1 ? 'key' : 'keys'}</span>
</header>
<div className='developer-key-list'>
{keys.map((key) => <article key={key.id}>
<div><strong>{key.name ?? 'Unnamed key'}</strong><code>{key.start ?? key.prefix ?? 'aty_…'}</code></div>
<dl><div><dt>Created</dt><dd>{formatDate(key.createdAt)}</dd></div><div><dt>Last used</dt><dd>{key.lastRequest ? formatDate(key.lastRequest) : 'Never'}</dd></div><div><dt>Expiry</dt><dd>Never</dd></div></dl>
<button disabled={loading} onClick={() => void revoke(key)}>Revoke</button>
</article>)}
{!keys.length && <p className='developer-empty'>No API keys yet.</p>}
{!keys.length && <div className='developer-empty'><strong>{localPreview ? 'No keys shown in preview' : 'No API keys yet'}</strong><p>{localPreview ? 'A signed-in session will show its active credentials here.' : 'Create your first key above when you are ready to connect an integration.'}</p></div>}
</div>
</section>
</section>
Expand Down
4 changes: 2 additions & 2 deletions web/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { headers } from 'next/headers';
import { DashboardSessionProvider } from './DashboardSessionProvider';
import { fetchServerSession, isLocalDashboardRequest } from '../../lib/server-session';
import { fetchServerSession, isLocalDashboardDemoEnabled } from '../../lib/server-session';

export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const requestHeaders = await headers();
const demoEnabled = isLocalDashboardRequest(requestHeaders);
const demoEnabled = isLocalDashboardDemoEnabled(requestHeaders);
let session = null;

try {
Expand Down
Loading
Loading