diff --git a/CLAUDE.md b/CLAUDE.md index aac1a8b7..b08b1e2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ super-multica/ │ ├── desktop/ ← Electron + Vite + React (`@multica/desktop`) — primary target │ ├── gateway/ ← NestJS WebSocket gateway (`@multica/gateway`) │ ├── server/ ← NestJS REST API server (`@multica/server`) -│ ├── web/ ← Next.js 16 web app (`@multica/web`, port 3001) +│ ├── web/ ← Next.js 16 web app (`@multica/web`, port 3000) │ └── mobile/ ← React Native mobile app (`@multica/mobile`) │ ├── packages/ diff --git a/README.md b/README.md index 6c68a07f..9ff98045 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,44 @@ pnpm install # Desktop app (recommended for local development) pnpm dev -# Gateway + Web app (for remote/mobile clients) +# Web app (for browser-based access) +pnpm dev:web # Start Web app on :3000 + +# Gateway (for remote/mobile clients) pnpm dev:gateway # Start Gateway on :3000 -pnpm dev:web # Start Web app on :3001 pnpm dev:all # Start both Gateway and Web app ``` The Desktop app runs a standalone Hub with embedded Agent Engine - no Gateway required for local use. +### Environment Configuration + +**Desktop** (`apps/desktop/.env.*`): + +| Variable | Description | +|----------|-------------| +| `MAIN_VITE_GATEWAY_URL` | WebSocket Gateway URL for remote device pairing | +| `MAIN_VITE_WEB_URL` | Web app URL for OAuth login redirect | + +**Web** (`apps/web/next.config.ts`): + +| Variable | Description | +|----------|-------------| +| `API_URL` | Backend API URL (default: `https://api-dev.copilothub.ai`) | + +**Build for different environments:** + +```bash +# Desktop +pnpm --filter @multica/desktop build # Production (.env.production) +pnpm --filter @multica/desktop build:staging # Staging (.env.staging) + +# Web (Vercel) +# Set API_URL in Vercel Dashboard → Settings → Environment Variables +``` + +See `apps/desktop/.env.example` and `apps/web/.env.example` for details. + ### Monorepo Development | Command | Purpose | diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example index dd98975b..18f56e21 100644 --- a/apps/desktop/.env.example +++ b/apps/desktop/.env.example @@ -3,11 +3,13 @@ # ============================================================================= # # Local Development: -# Just run `pnpm dev` - connects to dev gateway (multica-dev.copilothub.ai) -# GATEWAY_URL is hardcoded in root package.json, no .env file needed. +# Just run `pnpm dev` - no .env file needed (uses defaults) +# +# Staging Build: +# `pnpm build:staging` uses .env.staging # # Production Build: -# `pnpm build` uses .env.production automatically +# `pnpm build` uses .env.production # # Variable naming convention: # MAIN_VITE_* - Main process only (Node.js, full system access) @@ -20,15 +22,11 @@ # # MAIN_VITE_GATEWAY_URL - WebSocket Gateway # Hub connects to Gateway for remote device access (QR code pairing) -# Dev: multica-dev.copilothub.ai (hardcoded in root package.json) -# Prod: see .env.production # -# MAIN_VITE_MULTICA_URL - REST API Server -# HTTP requests for authentication, auto-updates, user data sync -# Dev: not yet used -# Prod: see .env.production +# MAIN_VITE_WEB_URL - Web App URL +# Desktop opens this URL for user login (OAuth flow) # # ============================================================================= MAIN_VITE_GATEWAY_URL=http://localhost:3000 -MAIN_VITE_MULTICA_URL=http://localhost:3001 +MAIN_VITE_WEB_URL=http://localhost:3000 diff --git a/apps/desktop/electron-builder.json5 b/apps/desktop/electron-builder.json5 index 0a7de5ff..d8fd3ce2 100644 --- a/apps/desktop/electron-builder.json5 +++ b/apps/desktop/electron-builder.json5 @@ -28,7 +28,13 @@ "NSMicrophoneUsageDescription": "Application requests access to the device's microphone.", "NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.", "NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder." - } + }, + "protocols": [ + { + "name": "Multica", + "schemes": ["multica"] + } + ] }, "dmg": { "artifactName": "${productName}-${version}-${arch}.${ext}" @@ -54,6 +60,12 @@ "uninstallDisplayName": "${productName}", "createDesktopShortcut": "always" }, + "protocols": [ + { + "name": "Multica", + "schemes": ["multica"] + } + ], "linux": { "target": [ "AppImage", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 27bb39c7..96a5e08a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,6 +10,8 @@ "dev": "electron-vite dev", "dev:onboarding": "electron-vite dev -- --force-onboarding", "build": "electron-vite build && electron-builder --publish never", + "build:staging": "electron-vite build --mode staging && electron-builder --publish never", + "build:production": "electron-vite build --mode production && electron-builder --publish never", "preview": "electron-vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, @@ -19,6 +21,7 @@ "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", + "@multica/utils": "workspace:*", "electron-updater": "^6.7.3", "lucide-react": "^0.563.0", "qrcode.react": "^4.2.0", diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 64552b79..58860c2b 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -1,10 +1,10 @@ /// // Environment variables loaded from .env files -// See: .env.example, .env.development, .env.staging, .env.production +// See: .env.example, .env.staging, .env.production interface ImportMetaEnv { readonly MAIN_VITE_GATEWAY_URL: string - readonly MAIN_VITE_MULTICA_URL: string + readonly MAIN_VITE_WEB_URL: string } interface ImportMeta { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a4428d31..f0780a5f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -47,7 +47,7 @@ process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => { import { app, BrowserWindow, shell, ipcMain } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' -import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js' +import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation, setAuthMainWindow, handleAuthDeepLink } from './ipc/index.js' import { appStateManager } from '@multica/core' import { createUpdater, AutoUpdater } from './updater/index.js' @@ -71,6 +71,50 @@ const forceOnboarding = process.argv.includes('--force-onboarding') let win: BrowserWindow | null let updater: AutoUpdater +// ============================================================================ +// Custom Protocol for Auth (multica://) +// ============================================================================ + +// Register custom protocol - must be called before app.whenReady() +if (process.defaultApp) { + // Development: need to pass the script path + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('multica', process.execPath, [path.resolve(process.argv[1])]) + } +} else { + // Production + app.setAsDefaultProtocolClient('multica') +} + +// Handle protocol URL on macOS (when app is already running) +app.on('open-url', (event, url) => { + event.preventDefault() + console.log('[Auth] Received open-url:', url) + if (url.startsWith('multica://')) { + handleAuthDeepLink(url) + } +}) + +// Handle second instance (Windows/Linux - when app is already running) +const gotTheLock = app.requestSingleInstanceLock() +if (!gotTheLock) { + app.quit() +} else { + app.on('second-instance', (_event, commandLine) => { + // Focus window + if (win) { + if (win.isMinimized()) win.restore() + win.focus() + } + // Handle protocol URL from command line (Windows) + const url = commandLine.find(arg => arg.startsWith('multica://')) + if (url) { + console.log('[Auth] Received second-instance URL:', url) + handleAuthDeepLink(url) + } + }) +} + function createWindow() { win = new BrowserWindow({ width: 1200, @@ -136,9 +180,10 @@ app.whenReady().then(async () => { createWindow() - // Set up device confirmation flow (requires both Hub and window) + // Set up device confirmation flow and auth (requires window) if (win) { setupDeviceConfirmation(win) + setAuthMainWindow(win) } // Initialize auto-updater diff --git a/apps/desktop/src/main/ipc/auth.ts b/apps/desktop/src/main/ipc/auth.ts new file mode 100644 index 00000000..9a0d8111 --- /dev/null +++ b/apps/desktop/src/main/ipc/auth.ts @@ -0,0 +1,355 @@ +/** + * Auth IPC Handlers + * + * Desktop login flow, based on CAP project: + * - Dev mode: Start local HTTP Server, Web redirects back after login + * - Prod mode: Use Deep Link (multica://), Web redirects via deep link + * + * Reference: https://github.com/CapSoftware/Cap + */ + +import http from "node:http"; +import { ipcMain, shell, BrowserWindow } from "electron"; +import { + existsSync, + readFileSync, + writeFileSync, + unlinkSync, + mkdirSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { DATA_DIR } from "@multica/utils"; +import type { AuthUser } from "@multica/types"; + +// ============================================================================ +// Types +// ============================================================================ + +export type { AuthUser }; + +export interface AuthData { + sid: string; + user: AuthUser; +} + +// ============================================================================ +// Storage - 认证数据持久化 +// ============================================================================ + +const AUTH_FILE_PATH = join(DATA_DIR, "auth.json"); + +function loadAuthData(): AuthData | null { + try { + if (!existsSync(AUTH_FILE_PATH)) { + return null; + } + const raw = readFileSync(AUTH_FILE_PATH, "utf8"); + const data = JSON.parse(raw) as AuthData; + + if (!data.sid || !data.user?.uid) { + return null; + } + + return data; + } catch (error) { + console.error("[Auth] Failed to load auth data:", error); + return null; + } +} + +function saveAuthData(sid: string, user: AuthUser): boolean { + try { + mkdirSync(dirname(AUTH_FILE_PATH), { recursive: true }); + + const data: AuthData = { sid, user }; + writeFileSync(AUTH_FILE_PATH, JSON.stringify(data, null, 2), "utf8"); + + console.log("[Auth] Auth data saved successfully"); + return true; + } catch (error) { + console.error("[Auth] Failed to save auth data:", error); + return false; + } +} + +function clearAuthData(): boolean { + try { + if (existsSync(AUTH_FILE_PATH)) { + unlinkSync(AUTH_FILE_PATH); + console.log("[Auth] Auth data cleared"); + } + return true; + } catch (error) { + console.error("[Auth] Failed to clear auth data:", error); + return false; + } +} + +// ============================================================================ +// Login - 登录流程 +// ============================================================================ + +let authServer: http.Server | null = null; +let mainWindowRef: BrowserWindow | null = null; + +/** + * 设置主窗口引用(用于发送 auth callback 和聚焦窗口) + */ +export function setMainWindow(win: BrowserWindow): void { + mainWindowRef = win; +} + +/** + * 登录成功后的回调 HTML 页面 + * 参考:Cap/apps/desktop/src/components/callback.template.ts + */ +const callbackHtml = ` + + + + + + Multica Auth + + + +
+

Sign in successful

+

Please return to Multica app

+
+ + +`; + +/** + * 开发模式:创建本地 HTTP Server 接收登录回调 + * 参考:Cap/apps/desktop/src/utils/auth.ts - createLocalServerSession + */ +async function createLocalServerSession(): Promise { + // 如果已有 server,先关闭 + if (authServer) { + authServer.close(); + authServer = null; + } + + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + console.log("[Auth] Local server received request:", req.url); + + try { + const url = new URL(req.url || "/", "http://localhost"); + + // 处理回调请求 + if (url.pathname === "/callback" || url.pathname === "/") { + const sid = url.searchParams.get("sid"); + const userJson = url.searchParams.get("user"); + + // 返回成功页面 + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store, no-cache, must-revalidate", + }); + res.end(callbackHtml); + + console.log("[Auth] Parsed params:", { sid, userJson }); + + if (sid && userJson) { + try { + // URLSearchParams already decodes, so just parse JSON directly + const user = JSON.parse(userJson) as AuthUser; + console.log("[Auth] Received auth callback:", { + sid: sid.substring(0, 8) + "...", + user: user.name, + }); + + // 保存认证数据 + saveAuthData(sid, user); + + // 通知渲染进程 + console.log("[Auth] mainWindowRef:", mainWindowRef ? "exists" : "null"); + if (mainWindowRef && !mainWindowRef.isDestroyed()) { + console.log("[Auth] Sending auth:callback to renderer..."); + mainWindowRef.webContents.send("auth:callback", { sid, user }); + console.log("[Auth] auth:callback sent!"); + // 聚焦窗口 + if (mainWindowRef.isMinimized()) mainWindowRef.restore(); + mainWindowRef.focus(); + } else { + console.log("[Auth] ERROR: mainWindowRef is null or destroyed!"); + } + } catch (parseError) { + console.error("[Auth] Failed to parse user data:", parseError); + } + } + + // 关闭 server + setTimeout(() => { + server.close(); + authServer = null; + }, 1000); + } else { + res.writeHead(404); + res.end("Not Found"); + } + } catch (error) { + console.error("[Auth] Error handling request:", error); + res.writeHead(500); + res.end("Internal Server Error"); + } + }); + + server.on("error", (err) => { + console.error("[Auth] Server error:", err); + reject(err); + }); + + // 监听随机端口 + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + console.log("[Auth] Local server started on port:", port); + authServer = server; + resolve(port); + } else { + reject(new Error("Failed to get server address")); + } + }); + }); +} + +/** + * 开始登录流程 + * 参考:Cap/apps/desktop/src/utils/auth.ts - createSignInMutation + */ +async function startLogin(): Promise { + const isDev = !!process.env.ELECTRON_RENDERER_URL; + const webUrl = + (import.meta as unknown as { env: Record }).env + .MAIN_VITE_WEB_URL || "http://localhost:3000"; + + console.log("[Auth] Starting login, isDev:", isDev, "webUrl:", webUrl); + + if (isDev) { + // 开发模式:启动本地 Server,Web 回调到这个 Server + try { + const port = await createLocalServerSession(); + const loginUrl = `${webUrl}/api/desktop/session?port=${port}&platform=web`; + console.log("[Auth] Opening login URL:", loginUrl); + shell.openExternal(loginUrl); + } catch (error) { + console.error("[Auth] Failed to start local server:", error); + } + } else { + // 生产模式:直接打开登录页,通过 deep link 回调 + const loginUrl = `${webUrl}/api/desktop/session?platform=desktop`; + console.log("[Auth] Opening login URL:", loginUrl); + shell.openExternal(loginUrl); + } +} + +/** + * 处理 Deep Link 回调(生产模式) + * 在 main/index.ts 的 app.on('open-url') 中调用 + */ +export function handleAuthDeepLink(url: string): void { + console.log("[Auth] Handling deep link:", url); + + try { + const parsedUrl = new URL(url); + + // multica://focus - just focus the window + if (parsedUrl.host === "focus" || parsedUrl.pathname === "//focus") { + console.log("[Auth] Focus request received"); + if (mainWindowRef && !mainWindowRef.isDestroyed()) { + if (mainWindowRef.isMinimized()) mainWindowRef.restore(); + mainWindowRef.focus(); + } + return; + } + + // multica://auth?sid=xxx&user=xxx + if ( + parsedUrl.host === "auth" || + parsedUrl.pathname === "//auth" || + parsedUrl.pathname === "/auth" + ) { + const sid = parsedUrl.searchParams.get("sid"); + const userJson = parsedUrl.searchParams.get("user"); + + if (sid && userJson) { + const user = JSON.parse(decodeURIComponent(userJson)) as AuthUser; + console.log("[Auth] Deep link auth received:", { + sid: sid.substring(0, 8) + "...", + user: user.name, + }); + + // 保存认证数据 + saveAuthData(sid, user); + + // 通知渲染进程 + if (mainWindowRef && !mainWindowRef.isDestroyed()) { + mainWindowRef.webContents.send("auth:callback", { sid, user }); + if (mainWindowRef.isMinimized()) mainWindowRef.restore(); + mainWindowRef.focus(); + } + } + } + } catch (error) { + console.error("[Auth] Failed to handle deep link:", error); + } +} + +// ============================================================================ +// IPC Handlers +// ============================================================================ + +export function registerAuthHandlers(): void { + // 加载认证数据 + ipcMain.handle("auth:load", () => { + return loadAuthData(); + }); + + // 保存认证数据 + ipcMain.handle("auth:save", (_, sid: string, user: AuthUser) => { + return saveAuthData(sid, user); + }); + + // 清除认证数据(登出) + ipcMain.handle("auth:clear", () => { + return clearAuthData(); + }); + + // 开始登录 + ipcMain.handle("auth:startLogin", () => { + return startLogin(); + }); +} diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index d599e343..8f6e2ecb 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -10,9 +10,11 @@ export { registerChannelsIpcHandlers } from './channels.js' export { registerCronIpcHandlers } from './cron.js' export { registerHeartbeatIpcHandlers } from './heartbeat.js' export { registerAppStateIpcHandlers } from './app-state.js' +export { registerAuthHandlers, setMainWindow as setAuthMainWindow, handleAuthDeepLink } from './auth.js' export { registerSubagentsIpcHandlers } from './subagents.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' +import { registerAuthHandlers } from './auth.js' import { registerSkillsIpcHandlers } from './skills.js' import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' import { registerProfileIpcHandlers } from './profile.js' @@ -37,6 +39,7 @@ export function registerAllIpcHandlers(): void { registerCronIpcHandlers() registerHeartbeatIpcHandlers() registerAppStateIpcHandlers() + registerAuthHandlers() registerSubagentsIpcHandlers() } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 800e91bf..405e3c86 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -138,6 +138,28 @@ const electronAPI = { setOnboardingCompleted: (completed: boolean): Promise => ipcRenderer.invoke('appState:setOnboardingCompleted', completed), }, + // Auth management + auth: { + /** Load auth data from local file */ + load: (): Promise<{ sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number } } | null> => + ipcRenderer.invoke('auth:load'), + /** Save auth data to local file */ + save: (sid: string, user: { uid: string; name: string; email?: string; icon?: string; vip?: number }): Promise => + ipcRenderer.invoke('auth:save', sid, user), + /** Clear auth data (logout) */ + clear: (): Promise => ipcRenderer.invoke('auth:clear'), + /** Start login flow (opens browser) */ + startLogin: (): Promise => ipcRenderer.invoke('auth:startLogin'), + /** Listen for auth callback */ + onAuthCallback: (callback: (data: { sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number } }) => void) => { + ipcRenderer.on('auth:callback', (_event, data) => callback(data)) + }, + /** Remove auth callback listener */ + offAuthCallback: () => { + ipcRenderer.removeAllListeners('auth:callback') + }, + }, + // Hub management hub: { init: () => ipcRenderer.invoke('hub:init'), diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 33177e2d..68a23c62 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -12,6 +12,7 @@ import ToolsPage from './pages/agent/tools' import ClientsPage from './pages/clients' import CronsPage from './pages/crons' import OnboardingPage from './pages/onboarding' +import LoginPage from './pages/login' import { useOnboardingStore } from './stores/onboarding' import { useHubStore } from './stores/hub' import { useProviderStore } from './stores/provider' @@ -19,6 +20,23 @@ import { useChannelsStore } from './stores/channels' import { useSkillsStore } from './stores/skills' import { useToolsStore } from './stores/tools' import { useCronJobsStore } from './stores/cron-jobs' +import { useAuthStore, setupAuthCallbackListener } from './stores/auth' + +// Auth guard - redirects to login if not authenticated +function AuthGuard({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + const isLoading = useAuthStore((s) => s.isLoading) + + if (isLoading) { + return
+ } + + if (!isAuthenticated) { + return + } + + return <>{children} +} function OnboardingGuard({ children }: { children: React.ReactNode }) { const completed = useOnboardingStore((s) => s.completed) @@ -27,13 +45,25 @@ function OnboardingGuard({ children }: { children: React.ReactNode }) { } const router = createHashRouter([ + { + path: '/login', + element: , + }, { path: '/onboarding', - element: , + element: ( + + + + ), }, { path: '/', - element: , + element: ( + + + + ), children: [ { index: true, @@ -58,19 +88,28 @@ export default function App() { const setCompleted = useOnboardingStore((s) => s.setCompleted) useEffect(() => { - async function hydrateOnboardingState() { + let cleanupAuth: (() => void) | undefined + + async function hydrateState() { try { + // Load auth state first + await useAuthStore.getState().loadAuth() + + // Setup auth callback listener + cleanupAuth = setupAuthCallbackListener() + + // Load onboarding state const completed = await window.electronAPI.appState.getOnboardingCompleted() setCompleted(completed) } catch (err) { - console.error('[App] Failed to load onboarding state:', err) + console.error('[App] Failed to hydrate state:', err) setCompleted(false) } finally { setIsHydrated(true) } } - hydrateOnboardingState() + hydrateState() useHubStore.getState().init() useProviderStore.getState().fetch() @@ -78,6 +117,10 @@ export default function App() { useSkillsStore.getState().fetch() useToolsStore.getState().fetch() useCronJobsStore.getState().fetch() + + return () => { + cleanupAuth?.() + } }, [setCompleted]) if (!isHydrated) { diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index 5129b0e2..1651e447 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -14,11 +14,20 @@ import { ChevronLeft, ChevronRight, ChevronDown, + ChevronsUpDown, Bot, + LogOut, } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@multica/ui/components/ui/dropdown-menu' import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarHeader, SidebarInset, @@ -36,6 +45,7 @@ import { cn } from '@multica/ui/lib/utils' import { ModeToggle } from '../components/mode-toggle' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' import { UpdateNotification } from '../components/update-notification' +import { useAuthStore } from '../stores/auth' const mainNavItems = [ { path: '/', label: 'Home', icon: Home, exact: true }, @@ -139,7 +149,14 @@ function MainHeader() { export default function Layout() { const location = useLocation() + const navigate = useNavigate() const isAgentActive = location.pathname.startsWith('/agent') + const { user, clearAuth } = useAuthStore() + + const handleLogout = async () => { + await clearAuth() + navigate('/login') + } return (
@@ -236,6 +253,33 @@ export default function Layout() { + + + + + + } + > +
+ {user?.name?.charAt(0)?.toUpperCase() || '?'} +
+
+ {user?.name || 'User'} + {user?.email || ''} +
+ +
+ + + + Log out + + +
+
+
+
diff --git a/apps/desktop/src/renderer/src/pages/login.tsx b/apps/desktop/src/renderer/src/pages/login.tsx new file mode 100644 index 00000000..a1ac0b6a --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/login.tsx @@ -0,0 +1,59 @@ +/** + * Login Page - Shown when user is not authenticated + */ + +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@multica/ui/components/ui/button' +import { Loading } from '@multica/ui/components/ui/loading' +import { MulticaIcon } from '@multica/ui/components/multica-icon' +import { useAuthStore } from '../stores/auth' + +export default function LoginPage() { + const navigate = useNavigate() + const { startLogin, isLoading, isAuthenticated } = useAuthStore() + + // Redirect to home when authenticated + useEffect(() => { + if (isAuthenticated) { + console.log('[LoginPage] Authenticated, redirecting to home...') + navigate('/', { replace: true }) + } + }, [isAuthenticated, navigate]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+ {/* Brand */} +
+ +

Multica

+
+ + {/* Tagline */} +

+ An AI assistant that gets things done — pulling data, running analysis, + and taking action. Talk to it like a team member. +

+ + {/* Sign In */} + + + {/* Helper */} +

+ Opens browser for authentication +

+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/stores/auth.ts b/apps/desktop/src/renderer/src/stores/auth.ts new file mode 100644 index 00000000..2dd54121 --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/auth.ts @@ -0,0 +1,125 @@ +/** + * Auth Store - manages user authentication state + */ + +import { create } from 'zustand' +import { toast } from '@multica/ui/components/ui/sonner' +import type { AuthUser } from '@multica/types' + +export type { AuthUser } + +interface AuthState { + // State + sid: string | null + user: AuthUser | null + isAuthenticated: boolean + isLoading: boolean + + // Actions + loadAuth: () => Promise + saveAuth: (sid: string, user: AuthUser) => Promise + clearAuth: () => Promise + startLogin: () => void +} + +export const useAuthStore = create((set) => ({ + // Initial state + sid: null, + user: null, + isAuthenticated: false, + isLoading: true, + + // Load auth data from local file + loadAuth: async () => { + set({ isLoading: true }) + try { + const data = await window.electronAPI.auth.load() + if (data?.sid && data?.user) { + set({ + sid: data.sid, + user: data.user, + isAuthenticated: true, + isLoading: false, + }) + console.log('[AuthStore] Auth loaded:', data.user.name) + } else { + set({ + sid: null, + user: null, + isAuthenticated: false, + isLoading: false, + }) + console.log('[AuthStore] No auth data found') + } + } catch (error) { + console.error('[AuthStore] Failed to load auth:', error) + set({ + sid: null, + user: null, + isAuthenticated: false, + isLoading: false, + }) + } + }, + + // Save auth data to local file + saveAuth: async (sid: string, user: AuthUser) => { + try { + const success = await window.electronAPI.auth.save(sid, user) + if (success) { + set({ + sid, + user, + isAuthenticated: true, + }) + console.log('[AuthStore] Auth saved:', user.name) + } + } catch (error) { + console.error('[AuthStore] Failed to save auth:', error) + } + }, + + // Clear auth data (logout) + clearAuth: async () => { + try { + await window.electronAPI.auth.clear() + set({ + sid: null, + user: null, + isAuthenticated: false, + }) + toast('Signed out') + console.log('[AuthStore] Auth cleared') + } catch (error) { + console.error('[AuthStore] Failed to clear auth:', error) + } + }, + + // Start login flow (opens browser) + startLogin: () => { + console.log('[AuthStore] Starting login...') + window.electronAPI.auth.startLogin() + }, +})) + +/** + * Setup auth callback listener + * Call this once in App.tsx, returns cleanup function + */ +export function setupAuthCallbackListener(): () => void { + window.electronAPI.auth.onAuthCallback(async (data) => { + console.log('[AuthStore] Received auth callback:', data) + if (data.sid && data.user) { + useAuthStore.setState({ + sid: data.sid, + user: data.user, + isAuthenticated: true, + }) + toast.success(`Welcome back, ${data.user.name}`) + } + }) + + return () => { + window.electronAPI.auth.offAuthCallback() + } +} diff --git a/apps/web/app/api/desktop/session/route.ts b/apps/web/app/api/desktop/session/route.ts new file mode 100644 index 00000000..339d97d4 --- /dev/null +++ b/apps/web/app/api/desktop/session/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Desktop Session API + * + * 参考:Cap/apps/web/app/api/desktop/[...route]/session.ts + * + * 流程: + * 1. Desktop 打开这个 URL(带 port 或 platform 参数) + * 2. 直接重定向到 /login?next=当前URL + * 3. 用户登录后,login 页面会重定向到 Desktop 回调 + * + * 注意:Web 端不做任何 SID 缓存,每次都要重新登录 + */ +export async function GET(request: NextRequest) { + // Build current URL for next parameter + const currentUrl = request.nextUrl.toString(); + + // Always redirect to login page - no caching, always require fresh login + const loginUrl = new URL('/login', request.nextUrl.origin); + loginUrl.searchParams.set('next', currentUrl); + return NextResponse.redirect(loginUrl); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 660b3dbc..d7704893 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,6 +4,7 @@ import "@multica/ui/globals.css"; import { ThemeProvider } from "@multica/ui/components/theme-provider"; import { Toaster } from "@multica/ui/components/ui/sonner"; import { ServiceWorkerRegister } from "./sw-register"; +import { Providers } from "./providers"; const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); @@ -41,14 +42,16 @@ export default function RootLayout({ - -
{children}
-
+ + +
{children}
+
+
diff --git a/apps/web/app/login/login-form.tsx b/apps/web/app/login/login-form.tsx new file mode 100644 index 00000000..98bebd35 --- /dev/null +++ b/apps/web/app/login/login-form.tsx @@ -0,0 +1,329 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useSearchParams, useRouter } from 'next/navigation' +import { useGoogleLogin } from '@react-oauth/google' +import { Button } from '@multica/ui/components/ui/button' +import { Input } from '@multica/ui/components/ui/input' +import { Label } from '@multica/ui/components/ui/label' +import { MulticaIcon } from '@multica/ui/components/multica-icon' +import { LoginAuthType, UserInfo } from '@/lib/interface' +import { saveSession, isAuthenticated } from '@/lib/auth' +import { userLogin } from '@/service/user' + +type LoginStep = 'email' | 'code' + +function GoogleIcon() { + return ( + + + + + + + ) +} + +export function LoginForm() { + const searchParams = useSearchParams() + const router = useRouter() + const next = searchParams.get('next') + + // Redirect if already authenticated (and not desktop flow) + useEffect(() => { + if (!next && isAuthenticated()) { + router.replace('/') + } + }, [next, router]) + + // Form state + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [step, setStep] = useState('email') + + // Loading state + const [isSendingCode, setIsSendingCode] = useState(false) + const [isLoggingIn, setIsLoggingIn] = useState(false) + const [error, setError] = useState(null) + + // Countdown state + const [countdown, setCountdown] = useState(0) + + // Google login + const googleLogin = useGoogleLogin({ + onSuccess: async (tokenResponse) => { + setError(null) + setIsLoggingIn(true) + try { + const res = await userLogin({ + authType: LoginAuthType.Google, + googleToken: tokenResponse.access_token, + }) + + const { sid, user, account } = res + + if (!sid) { + throw new Error('No session ID returned') + } + + handleLoginSuccess(sid, { + ...user, + email: account?.email, + }) + } catch (err) { + setError('Google login failed. Please try again.') + console.error(err) + } finally { + setIsLoggingIn(false) + } + }, + onError: () => { + setError('Google login failed. Please try again.') + }, + }) + + // Countdown timer + useEffect(() => { + if (countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000) + return () => clearTimeout(timer) + } + }, [countdown]) + + // Handle login success + const handleLoginSuccess = (sid: string, user: UserInfo) => { + // Save session to cookie for web app + saveSession(sid, user) + + if (next) { + // Desktop flow - parse next URL to get port, then redirect directly to callback + try { + const nextUrl = new URL(next, window.location.origin) + const port = nextUrl.searchParams.get('port') + const platform = nextUrl.searchParams.get('platform') || 'web' + + const params = new URLSearchParams({ + sid, + user: JSON.stringify(user), + }) + + if (platform === 'web' && port) { + // Dev mode: redirect to local server + window.location.href = `http://127.0.0.1:${port}/callback?${params}` + } else { + // Production mode: redirect to deep link + window.location.href = `multica://auth?${params}` + } + } catch { + // Fallback: just go to next URL + window.location.href = next + } + } else { + // No next parameter - normal web login, go to home + window.location.href = '/' + } + } + + // Send verification code + const handleSendCode = async () => { + if (!email || !email.includes('@')) { + setError('Please enter a valid email address') + return + } + + setError(null) + setIsSendingCode(true) + + try { + await userLogin({ + authType: LoginAuthType.SendCode, + email, + }) + setStep('code') + setCountdown(60) + } catch (err) { + setError('Failed to send verification code') + console.error(err) + } finally { + setIsSendingCode(false) + } + } + + // Verify code and login + const handleLogin = async () => { + if (!code || code.length < 4) { + setError('Please enter the verification code') + return + } + + setError(null) + setIsLoggingIn(true) + + try { + const res = await userLogin({ + authType: LoginAuthType.VerifyCode, + email, + verificationCode: code, + }) + + const { sid, user, account } = res + + if (!sid) { + throw new Error('No session ID returned') + } + + handleLoginSuccess(sid, { + ...user, + email: account?.email || email, + }) + } catch (err) { + setError('Invalid or expired verification code') + console.error(err) + } finally { + setIsLoggingIn(false) + } + } + + return ( +
+ {/* Logo and Header */} +
+
+ + Multica +
+
+

Sign in

+

+ Enter your email to continue +

+
+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Login form */} +
+ {/* Email input */} +
+ + setEmail(e.target.value)} + disabled={step === 'code'} + onKeyDown={(e) => { + if (e.key === 'Enter' && step === 'email') { + handleSendCode() + } + }} + /> +
+ + {/* Verification code input (shown in step 2) */} + {step === 'code' && ( +
+ +
+ setCode(e.target.value)} + maxLength={6} + className="font-mono tracking-widest" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleLogin() + } + }} + /> + +
+
+ )} + + {/* Action buttons */} + {step === 'email' ? ( + + ) : ( +
+ + +
+ )} +
+ + {/* Divider */} +
+
+ +
+
+ or +
+
+ + {/* Google Login */} + +
+ ) +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 00000000..c343e257 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,12 @@ +import { LoginForm } from './login-form' + +// Disable static prerendering - LoginForm uses useSearchParams +export const dynamic = 'force-dynamic' + +export default function LoginPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 50d9c193..dada2a25 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,5 +1,10 @@ import ChatPage from "@/components/pages/chat-page"; +import { AuthGuard } from "@/components/auth-guard"; export default function Page() { - return ; + return ( + + + + ); } diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx new file mode 100644 index 00000000..c8a9530b --- /dev/null +++ b/apps/web/app/providers.tsx @@ -0,0 +1,15 @@ +'use client' + +import { GoogleOAuthProvider } from '@react-oauth/google' + +// Google OAuth Client ID +// TODO: Move to environment variable +const GOOGLE_CLIENT_ID = '69015293368-pg96qdahu57g8nb0oi1abv2j3qbqrshq.apps.googleusercontent.com' + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/components/auth-guard.tsx b/apps/web/components/auth-guard.tsx new file mode 100644 index 00000000..a7e00773 --- /dev/null +++ b/apps/web/components/auth-guard.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useLayoutEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { isAuthenticated } from '@/lib/auth' + +interface AuthGuardProps { + children: React.ReactNode +} + +export function AuthGuard({ children }: AuthGuardProps) { + const router = useRouter() + // Initialize state synchronously to avoid cascading renders + const [authState] = useState(() => { + if (typeof window === 'undefined') return { checking: true, authed: false } + const authed = isAuthenticated() + return { checking: false, authed } + }) + + useLayoutEffect(() => { + if (!authState.checking && !authState.authed) { + router.replace('/login') + } + }, [authState, router]) + + if (authState.checking) { + return ( +
+
+
+ ) + } + + if (!authState.authed) { + return null + } + + return <>{children} +} diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts new file mode 100644 index 00000000..d288021f --- /dev/null +++ b/apps/web/lib/auth.ts @@ -0,0 +1,60 @@ +/** + * Client-side auth utilities + * Stores session in cookie for API authentication + */ + +import type { UserInfo } from './interface' + +const SID_COOKIE_NAME = 'multica_sid' +const USER_COOKIE_NAME = 'multica_user' + +// Cookie helpers +function setCookie(name: string, value: string, days = 30) { + const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString() + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax` +} + +function getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)) + return match ? decodeURIComponent(match[2]) : null +} + +function deleteCookie(name: string) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` +} + +// Auth functions +export function saveSession(sid: string, user: UserInfo) { + setCookie(SID_COOKIE_NAME, sid) + setCookie(USER_COOKIE_NAME, JSON.stringify(user)) +} + +export function getSession(): { sid: string; user: UserInfo } | null { + if (typeof window === 'undefined') return null + + const sid = getCookie(SID_COOKIE_NAME) + const userJson = getCookie(USER_COOKIE_NAME) + + if (!sid || !userJson) return null + + try { + const user = JSON.parse(userJson) as UserInfo + return { sid, user } + } catch { + return null + } +} + +export function getSid(): string | null { + if (typeof window === 'undefined') return null + return getCookie(SID_COOKIE_NAME) +} + +export function clearSession() { + deleteCookie(SID_COOKIE_NAME) + deleteCookie(USER_COOKIE_NAME) +} + +export function isAuthenticated(): boolean { + return !!getSid() +} diff --git a/apps/web/lib/constant.ts b/apps/web/lib/constant.ts new file mode 100644 index 00000000..692db158 --- /dev/null +++ b/apps/web/lib/constant.ts @@ -0,0 +1,2 @@ +// API Host +export const API_HOST = process.env.NEXT_PUBLIC_API_HOST || ''; diff --git a/apps/web/lib/device.ts b/apps/web/lib/device.ts new file mode 100644 index 00000000..a19ceabc --- /dev/null +++ b/apps/web/lib/device.ts @@ -0,0 +1,36 @@ +const DEVICE_ID = 'MULTICA_DEVICE_ID'; + +// SHA-256 hash function (using Web Crypto API) +async function sha256(text: string): Promise { + const buffer = new TextEncoder().encode(text); + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +// Generate Device-Id header +export async function generateDeviceIdHeader(deviceId: string): Promise { + // First hash, take first 32 chars + const hash1 = await sha256(deviceId); + const hashedDeviceId = hash1.slice(0, 32); + + // Second hash, take first 8 chars + const hash2 = await sha256(hashedDeviceId); + const finalDeviceId = hash2.slice(0, 8) + hashedDeviceId; + + return finalDeviceId; +} + +// Get or create Device ID +export function getOrCreateDeviceId(): string { + if (typeof window === 'undefined') return ''; + + let deviceId = localStorage.getItem(DEVICE_ID); + + if (!deviceId) { + deviceId = crypto.randomUUID(); + localStorage.setItem(DEVICE_ID, deviceId); + } + + return deviceId; +} diff --git a/apps/web/lib/interface.ts b/apps/web/lib/interface.ts new file mode 100644 index 00000000..4dd42ded --- /dev/null +++ b/apps/web/lib/interface.ts @@ -0,0 +1,37 @@ +// User info type +export interface UserInfo { + uid: string; + name: string; + email?: string; + icon: string; + vip: number; +} + +// Login auth type (from SceneSpeak) +export enum LoginAuthType { + SendCode = 1, // 发送验证码 + Google = 3, // Google AccessToken + VerifyCode = 8, // 验证码登录 +} + +// Login response type +export interface LoginResponse { + authType: number; + sid: string; + pToken: string; + message: string; + account: { + uid: string; + type: number; + email: string; + googleId?: string; + createdTime: number; + newAccount: boolean; + }; + user: UserInfo & { + status: number; + extra?: unknown; + createdTime: number; + customerId?: string; + }; +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 17a23e4c..dfa5bd7f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { transpilePackages: ["@multica/ui", "@multica/store", "@multica/hooks", "@multica/sdk"], + rewrites: async () => [ + { + source: "/api/:path*", + destination: `${process.env.API_URL || "https://api-dev.copilothub.ai"}/api/:path*`, + }, + ], headers: async () => [ { source: "/sw.js", diff --git a/apps/web/package.json b/apps/web/package.json index 1141dec3..9a10a8b6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3001 --experimental-https", + "dev": "next dev --port 3000", "build": "next build", "start": "next start", "lint": "eslint" @@ -13,6 +13,7 @@ "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", + "@react-oauth/google": "^0.12.1", "next": "16.1.6", "next-themes": "^0.4.6", "react": "catalog:", diff --git a/apps/web/service/request.ts b/apps/web/service/request.ts new file mode 100644 index 00000000..e4ac3921 --- /dev/null +++ b/apps/web/service/request.ts @@ -0,0 +1,74 @@ +import { API_HOST } from '@/lib/constant'; +import { generateDeviceIdHeader, getOrCreateDeviceId } from '@/lib/device'; +import { getSid } from '@/lib/auth'; + +// Fetch request wrapper +export async function request(url: string, options: RequestInit = {}): Promise { + // Get or generate Device ID + let deviceIdHeader = ''; + let sid: string | null = null; + if (typeof window !== 'undefined') { + const deviceId = getOrCreateDeviceId(); + deviceIdHeader = await generateDeviceIdHeader(deviceId); + sid = getSid(); + } + + const config: RequestInit = { + ...options, + headers: { + 'Content-Type': 'application/json', + 'os-type': '3', + ...(deviceIdHeader && { 'Device-Id': deviceIdHeader }), + ...(sid && { 'Authorization': `Bearer ${sid}` }), + ...options.headers, + }, + }; + + const response = await fetch(`${API_HOST}${url}`, config); + + let data: T; + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + data = await response.json(); + } else { + const text = await response.text(); + data = { message: text || response.statusText } as T; + } + + if (!response.ok) { + console.error('API Error:', { + status: response.status, + url, + data, + }); + throw new Error( + (data as { errMsg?: string; message?: string })?.errMsg || + (data as { message?: string })?.message || + `Request failed with status ${response.status}` + ); + } + + return data; +} + +// GET request +export function get(url: string, params?: Record) { + const filteredParams = params + ? Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== undefined && v !== null) + ) + : undefined; + const queryString = + filteredParams && Object.keys(filteredParams).length > 0 + ? `?${new URLSearchParams(filteredParams as Record).toString()}` + : ''; + return request(url + queryString, { method: 'GET' }); +} + +// POST request +export function post(url: string, data?: unknown) { + return request(url, { + method: 'POST', + body: JSON.stringify(data), + }); +} diff --git a/apps/web/service/user.ts b/apps/web/service/user.ts new file mode 100644 index 00000000..c028dbd9 --- /dev/null +++ b/apps/web/service/user.ts @@ -0,0 +1,12 @@ +import type { LoginResponse } from '@/lib/interface'; +import { post } from './request'; + +// User login +export const userLogin = async (params: { + authType: number; + googleToken?: string; + email?: string; + verificationCode?: string; +}) => { + return post('/api/v1/auth/login', params); +}; diff --git a/docs/auth/desktop-integration.md b/docs/auth/desktop-integration.md new file mode 100644 index 00000000..7c776ad5 --- /dev/null +++ b/docs/auth/desktop-integration.md @@ -0,0 +1,75 @@ +# Desktop 登录集成 + +## 登录流程 + +``` +Desktop 点击登录 + ↓ +启动本地 HTTP 服务器 (随机端口,如 54321) + ↓ +打开浏览器 → http://localhost:3000/api/desktop/session?port=54321&platform=web + ↓ +Web 重定向 → /login?next=... + ↓ +用户登录,调用 /api/v1/auth/login (代理到 api-dev.copilothub.ai) + ↓ +登录成功,回调 → http://127.0.0.1:54321/callback?sid=xxx&user=xxx + ↓ +Desktop 保存到 ~/.super-multica/auth.json +``` + +## 前端逻辑 + +### Web 端 + +- 端口:**3000** +- 登录 API:`/api/v1/auth/login`(通过 Next.js rewrites 代理到后端) +- 登录成功后回调:`http://127.0.0.1:{port}/callback?sid=xxx&user=xxx` + +### Desktop 端 + +- 点击登录 → 启动本地服务器 → 打开浏览器 +- 收到回调 → 保存到本地文件 + +## 存储 + +**路径:** `~/.super-multica/auth.json` + +Desktop 登录成功后,SID 和用户信息存储在本地文件: + +```json +{ + "sid": "session-id-from-backend", + "user": { + "uid": "user-id", + "name": "User Name", + "email": "user@example.com" + } +} +``` + +后续请求可从此文件读取 `sid` 进行认证。 + +## 退出登录 + +**后端只需要返回错误,前端会自动处理退出。** + +前端收到认证错误后: +1. 调用 `auth:clear` 清除本地数据 +2. 跳转到登录页 + +## 本地调试 + +```bash +# 1. 启动 Web(Next.js rewrites 自动代理 /api/* 到 api-dev.copilothub.ai) +pnpm dev:web + +# 2. 启动 Desktop +pnpm dev:desktop +``` + +本地调试时,Next.js rewrites(配置在 `apps/web/next.config.ts`)自动将 `/api/*` 请求代理到 `api-dev.copilothub.ai`。 + +## 参考 + +- **Cap** - https://github.com/CapSoftware/Cap diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b311a805..07623ea7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -190,3 +190,15 @@ export interface ExecApprovalRequest { riskReasons: string[] expiresAtMs: number } + +// ============================================================================ +// Auth Types +// ============================================================================ + +export interface AuthUser { + uid: string + name: string + email?: string + icon?: string + vip?: number +} diff --git a/packages/ui/src/components/multica-icon.tsx b/packages/ui/src/components/multica-icon.tsx index ac2401ef..52b0fe82 100644 --- a/packages/ui/src/components/multica-icon.tsx +++ b/packages/ui/src/components/multica-icon.tsx @@ -14,8 +14,18 @@ interface MulticaIconProps extends React.ComponentProps<"span"> { * If true, show a border around the icon. */ bordered?: boolean; + /** + * Size of the bordered icon: "sm" (default), "md", "lg" + */ + size?: "sm" | "md" | "lg"; } +const borderedSizes = { + sm: { wrapper: "p-1.5", icon: "size-3.5" }, + md: { wrapper: "p-2", icon: "size-4" }, + lg: { wrapper: "p-2.5", icon: "size-5" }, +}; + /** * Pure CSS 8-pointed asterisk icon matching the Multica logo. * Uses currentColor so it adapts to light/dark themes automatically. @@ -26,6 +36,7 @@ export function MulticaIcon({ animate = false, noSpin = false, bordered = false, + size = "sm", ...props }: MulticaIconProps) { const [entranceDone, setEntranceDone] = useState(!animate); @@ -36,11 +47,22 @@ export function MulticaIcon({ return () => clearTimeout(timer); }, [animate]); + const clipPath = `polygon( + 45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%, + 81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%, + 100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%, + 55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%, + 18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%, + 0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9% + )`; + if (bordered) { + const sizeConfig = borderedSizes[size]; return (