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>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user