Files
Arcrun/landing/app/dashboard/page.tsx
T
Leo 4516cdee4b feat: add landing page + builtins Worker + BETA_TEST guide + README
- landing/: Next.js 15 app for arcrun.dev (dashboard, integrations,
  API docs, login). Deploys via Cloudflare Pages — CI scan skips
  this via pages_build_output_dir marker.
- builtins/: minimal Hono Worker at arcrun-builtins (/init for
  one-shot component registry seeding). initComponents logic is
  flagged stale in src/index.ts for future rewrite.
- BETA_TEST.md: pre-launch validation playbook.
- README.md: updated to match current arcrun.dev / acr CLI flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 17:52:41 +08:00

235 lines
9.1 KiB
TypeScript

'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type User = {
email: string;
display_name: string;
avatar_url?: string;
api_key: string;
provider: string;
created_at: string;
};
export default function DashboardPage() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const [rotating, setRotating] = useState(false);
const [revoking, setRevoking] = useState(false);
const [error, setError] = useState('');
const [showKey, setShowKey] = useState(false);
const fetchUser = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/me`, { credentials: 'include' });
if (res.status === 401) {
window.location.href = '/login?redirect=/dashboard';
return;
}
if (!res.ok) throw new Error('Failed to fetch user');
const data = await res.json() as User;
setUser(data);
} catch {
setError('無法載入用戶資訊,請重新整理。');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
const copyKey = async () => {
if (!user) return;
await navigator.clipboard.writeText(user.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const rotateKey = async () => {
if (!confirm('確定要 Rotate API Key 嗎?舊 Key 的 workflow credentials 不會自動遷移。')) return;
setRotating(true);
setError('');
try {
const res = await fetch(`${API_BASE}/me/api-key/rotate`, {
method: 'PUT',
credentials: 'include',
});
if (!res.ok) throw new Error('rotate failed');
const data = await res.json() as { api_key: string; message: string };
setUser(prev => prev ? { ...prev, api_key: data.api_key } : null);
setShowKey(true);
} catch {
setError('Rotate 失敗,請稍後重試。');
} finally {
setRotating(false);
}
};
const revokeKey = async () => {
if (!confirm('確定要 Revoke API Key 嗎?所有使用此 Key 的服務將立即失效。')) return;
setRevoking(true);
setError('');
try {
const res = await fetch(`${API_BASE}/me/api-key`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error('revoke failed');
window.location.href = '/login?revoked=1';
} catch {
setError('Revoke 失敗,請稍後重試。');
} finally {
setRevoking(false);
}
};
const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
window.location.href = '/';
};
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="text-[#444] text-sm animate-pulse">載入中...</div>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex flex-col items-center justify-center gap-4">
<p className="text-[#666]">{error || '請先登入。'}</p>
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 text-sm">前往登入</Link>
</div>
);
}
const maskedKey = showKey ? user.api_key : user.api_key.slice(0, 8) + '••••••••••••••••••••••••';
return (
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
{/* Nav */}
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
arcrun
</Link>
<div className="flex items-center gap-4">
{user.avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatar_url} alt="" width={28} height={28} className="rounded-full" />
)}
<span className="text-[#666] text-sm">{user.email}</span>
<button onClick={logout} className="text-[#555] hover:text-[#888] text-sm transition-colors cursor-pointer">
登出
</button>
</div>
</nav>
<main className="max-w-2xl mx-auto px-6 py-12">
<h1 className="text-2xl font-bold text-white mb-1">歡迎,{user.display_name}</h1>
<p className="text-[#555] text-sm mb-10">
登入方式:{user.provider} · 帳號建立於 {new Date(user.created_at).toLocaleDateString('zh-TW')}
</p>
{error && (
<div className="bg-red-950/50 border border-red-900/50 text-red-400 text-sm px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* API Key Card */}
<div className="bg-[#111] border border-[#222] rounded-2xl p-6 mb-6">
<h2 className="text-white font-semibold mb-4">您的 API Key</h2>
<div className="flex items-center gap-2 mb-4">
<div className="flex-1 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-3 font-mono text-sm text-[#cdd6f4] overflow-hidden text-ellipsis whitespace-nowrap">
{maskedKey}
</div>
<button
onClick={() => setShowKey(v => !v)}
className="px-3 py-3 text-[#555] hover:text-[#aaa] text-xs border border-[#2a2a2a] rounded-lg transition-colors cursor-pointer whitespace-nowrap"
title={showKey ? '隱藏' : '顯示'}
>
{showKey ? '隱藏' : '顯示'}
</button>
<button
onClick={copyKey}
className="px-4 py-3 bg-[#1e1e2e] hover:bg-[#2a2a3e] text-indigo-400 text-xs border border-indigo-900/30 rounded-lg transition-colors cursor-pointer whitespace-nowrap"
>
{copied ? '已複製!' : '複製'}
</button>
</div>
<div className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg p-4 mb-6 text-xs font-mono text-[#666] space-y-1">
<div className="text-[#444] mb-2"># 使用方式</div>
<div>Authorization: Bearer {user.api_key.slice(0, 8)}...</div>
<div># </div>
<div>X-Arcrun-API-Key: {user.api_key.slice(0, 8)}...</div>
</div>
<div className="flex gap-3 flex-wrap">
<button
onClick={rotateKey}
disabled={rotating}
className="flex-1 border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{rotating ? 'Rotating...' : 'Rotate Key'}
</button>
<button
onClick={revokeKey}
disabled={revoking}
className="flex-1 border border-red-900/50 hover:border-red-700/50 text-red-500 hover:text-red-400 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{revoking ? 'Revoking...' : 'Revoke Key'}
</button>
</div>
</div>
{/* Quick Start */}
<div className="bg-[#111] border border-[#222] rounded-2xl p-6">
<h2 className="text-white font-semibold mb-4">快速開始</h2>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<span className="text-indigo-500 font-mono mt-0.5">1.</span>
<div>
<div className="text-[#aaa]">安裝 CLI</div>
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">npm install -g arcrun</pre>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-indigo-500 font-mono mt-0.5">2.</span>
<div>
<div className="text-[#aaa]">初始化(已有 API Key 可直接輸入)</div>
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">acr init</pre>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-indigo-500 font-mono mt-0.5">3.</span>
<div>
<div className="text-[#aaa]">設定服務認證</div>
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">acr auth-recipe scaffold notion</pre>
</div>
</div>
</div>
</div>
<div className="flex gap-4 mt-6 text-sm">
<Link href="/integrations" className="text-indigo-400 hover:text-indigo-300 transition-colors">
查看 20 個支援服務
</Link>
<Link href="/api-docs" className="text-indigo-400 hover:text-indigo-300 transition-colors">
API 文件
</Link>
</div>
</main>
</div>
);
}