Merge pull request #156 from multica-ai/feat/desktop-auth
feat: add desktop authentication with Web login
This commit is contained in:
commit
736df03a4f
33 changed files with 1567 additions and 65 deletions
|
|
@ -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/
|
||||
|
|
|
|||
34
README.md
34
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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
apps/desktop/src/main/electron-env.d.ts
vendored
4
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -1,10 +1,10 @@
|
|||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
355
apps/desktop/src/main/ipc/auth.ts
Normal file
355
apps/desktop/src/main/ipc/auth.ts
Normal file
|
|
@ -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 = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multica Auth</title>
|
||||
<style>
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
text-align: center;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.container {
|
||||
padding: 30px;
|
||||
max-width: 400px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #12161F;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Sign in successful</h1>
|
||||
<p>Please return to Multica app</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
/**
|
||||
* 开发模式:创建本地 HTTP Server 接收登录回调
|
||||
* 参考:Cap/apps/desktop/src/utils/auth.ts - createLocalServerSession
|
||||
*/
|
||||
async function createLocalServerSession(): Promise<number> {
|
||||
// 如果已有 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<void> {
|
||||
const isDev = !!process.env.ELECTRON_RENDERER_URL;
|
||||
const webUrl =
|
||||
(import.meta as unknown as { env: Record<string, string> }).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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,28 @@ const electronAPI = {
|
|||
setOnboardingCompleted: (completed: boolean): Promise<void> => 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<boolean> =>
|
||||
ipcRenderer.invoke('auth:save', sid, user),
|
||||
/** Clear auth data (logout) */
|
||||
clear: (): Promise<boolean> => ipcRenderer.invoke('auth:clear'),
|
||||
/** Start login flow (opens browser) */
|
||||
startLogin: (): Promise<void> => 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'),
|
||||
|
|
|
|||
|
|
@ -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 <div className="flex h-screen items-center justify-center bg-background" />
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
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: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/onboarding',
|
||||
element: <OnboardingPage />,
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<OnboardingPage />
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<Layout />
|
||||
</AuthGuard>
|
||||
),
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
|
|
@ -236,6 +253,33 @@ export default function Layout() {
|
|||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<SidebarMenuButton size="lg" />}
|
||||
>
|
||||
<div className="size-8 rounded-lg bg-muted flex items-center justify-center text-sm font-medium">
|
||||
{user?.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user?.name || 'User'}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user?.email || ''}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top">
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="size-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset className="overflow-hidden">
|
||||
|
|
|
|||
59
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
59
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<Loading className="size-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-background p-8 animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-sm flex flex-col items-center text-center space-y-6">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MulticaIcon animate className="size-5 text-muted-foreground/70" />
|
||||
<h1 className="text-2xl tracking-wide font-brand">Multica</h1>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
An AI assistant that gets things done — pulling data, running analysis,
|
||||
and taking action. Talk to it like a team member.
|
||||
</p>
|
||||
|
||||
{/* Sign In */}
|
||||
<Button onClick={startLogin} size="lg" className="px-8">
|
||||
Sign In to Continue
|
||||
</Button>
|
||||
|
||||
{/* Helper */}
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Opens browser for authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
apps/desktop/src/renderer/src/stores/auth.ts
Normal file
125
apps/desktop/src/renderer/src/stores/auth.ts
Normal file
|
|
@ -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<void>
|
||||
saveAuth: (sid: string, user: AuthUser) => Promise<void>
|
||||
clearAuth: () => Promise<void>
|
||||
startLogin: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((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()
|
||||
}
|
||||
}
|
||||
23
apps/web/app/api/desktop/session/route.ts
Normal file
23
apps/web/app/api/desktop/session/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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({
|
|||
<body
|
||||
className={`${geistMono.variable} ${playfair.variable} font-sans antialiased h-dvh flex flex-col`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className="h-dvh overflow-hidden">{children}</div>
|
||||
</ThemeProvider>
|
||||
<Providers>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className="h-dvh overflow-hidden">{children}</div>
|
||||
</ThemeProvider>
|
||||
</Providers>
|
||||
<Toaster />
|
||||
<ServiceWorkerRegister />
|
||||
</body>
|
||||
|
|
|
|||
329
apps/web/app/login/login-form.tsx
Normal file
329
apps/web/app/login/login-form.tsx
Normal file
|
|
@ -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 (
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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<LoginStep>('email')
|
||||
|
||||
// Loading state
|
||||
const [isSendingCode, setIsSendingCode] = useState(false)
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
{/* Logo and Header */}
|
||||
<div className="flex flex-col items-center text-center space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MulticaIcon bordered noSpin size="md" />
|
||||
<span className="text-base font-brand">Multica</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
Enter your email to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login form */}
|
||||
<div className="space-y-4">
|
||||
{/* Email input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={step === 'code'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && step === 'email') {
|
||||
handleSendCode()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Verification code input (shown in step 2) */}
|
||||
{step === 'code' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Verification Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="code"
|
||||
type="text"
|
||||
placeholder="Enter code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={6}
|
||||
className="font-mono tracking-widest"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleLogin()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSendCode}
|
||||
disabled={countdown > 0 || isSendingCode}
|
||||
className="shrink-0 tabular-nums"
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : 'Resend'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{step === 'email' ? (
|
||||
<Button
|
||||
onClick={handleSendCode}
|
||||
disabled={isSendingCode || !email}
|
||||
className="w-full"
|
||||
>
|
||||
{isSendingCode ? 'Sending...' : 'Continue'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoggingIn || !code}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoggingIn ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setStep('email')
|
||||
setCode('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full text-muted-foreground"
|
||||
size="sm"
|
||||
>
|
||||
Use a different email
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Login */}
|
||||
<Button
|
||||
onClick={() => googleLogin()}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
apps/web/app/login/page.tsx
Normal file
12
apps/web/app/login/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-8">
|
||||
<LoginForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import ChatPage from "@/components/pages/chat-page";
|
||||
import { AuthGuard } from "@/components/auth-guard";
|
||||
|
||||
export default function Page() {
|
||||
return <ChatPage />;
|
||||
return (
|
||||
<AuthGuard>
|
||||
<ChatPage />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
15
apps/web/app/providers.tsx
Normal file
15
apps/web/app/providers.tsx
Normal file
|
|
@ -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 (
|
||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||
{children}
|
||||
</GoogleOAuthProvider>
|
||||
)
|
||||
}
|
||||
39
apps/web/components/auth-guard.tsx
Normal file
39
apps/web/components/auth-guard.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!authState.authed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
60
apps/web/lib/auth.ts
Normal file
60
apps/web/lib/auth.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
2
apps/web/lib/constant.ts
Normal file
2
apps/web/lib/constant.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// API Host
|
||||
export const API_HOST = process.env.NEXT_PUBLIC_API_HOST || '';
|
||||
36
apps/web/lib/device.ts
Normal file
36
apps/web/lib/device.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const DEVICE_ID = 'MULTICA_DEVICE_ID';
|
||||
|
||||
// SHA-256 hash function (using Web Crypto API)
|
||||
async function sha256(text: string): Promise<string> {
|
||||
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<string> {
|
||||
// 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;
|
||||
}
|
||||
37
apps/web/lib/interface.ts
Normal file
37
apps/web/lib/interface.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
74
apps/web/service/request.ts
Normal file
74
apps/web/service/request.ts
Normal file
|
|
@ -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<T = unknown>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
// 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<T = unknown>(url: string, params?: Record<string, string | number | boolean>) {
|
||||
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<string, string>).toString()}`
|
||||
: '';
|
||||
return request<T>(url + queryString, { method: 'GET' });
|
||||
}
|
||||
|
||||
// POST request
|
||||
export function post<T = unknown>(url: string, data?: unknown) {
|
||||
return request<T>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
12
apps/web/service/user.ts
Normal file
12
apps/web/service/user.ts
Normal file
|
|
@ -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<LoginResponse>('/api/v1/auth/login', params);
|
||||
};
|
||||
75
docs/auth/desktop-integration.md
Normal file
75
docs/auth/desktop-integration.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center p-1.5 border border-border rounded-md",
|
||||
"inline-flex items-center justify-center border border-border rounded-md",
|
||||
sizeConfig.wrapper,
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
|
@ -48,23 +70,15 @@ export function MulticaIcon({
|
|||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block size-3.5",
|
||||
"block",
|
||||
sizeConfig.icon,
|
||||
!entranceDone && "animate-entrance-spin",
|
||||
entranceDone && !noSpin && "hover:animate-spin"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="block size-full bg-current"
|
||||
style={{
|
||||
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%
|
||||
)`,
|
||||
}}
|
||||
style={{ clipPath }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -84,16 +98,7 @@ export function MulticaIcon({
|
|||
>
|
||||
<span
|
||||
className="block size-full bg-current"
|
||||
style={{
|
||||
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%
|
||||
)`,
|
||||
}}
|
||||
style={{ clipPath }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
|
|
@ -242,6 +242,9 @@ importers:
|
|||
'@multica/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
'@multica/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
electron-updater:
|
||||
specifier: ^6.7.3
|
||||
version: 6.7.3
|
||||
|
|
@ -540,6 +543,9 @@ importers:
|
|||
'@multica/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
'@react-oauth/google':
|
||||
specifier: ^0.12.1
|
||||
version: 0.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -3219,6 +3225,12 @@ packages:
|
|||
'@react-navigation/routers@7.5.3':
|
||||
resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==}
|
||||
|
||||
'@react-oauth/google@0.12.2':
|
||||
resolution: {integrity: sha512-d1GVm2uD4E44EJft2RbKtp8Z1fp/gK8Lb6KHgs3pHlM0PxCXGLaq8LLYQYENnN4xPWO1gkL4apBtlPKzpLvZwg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
|
|
@ -13218,6 +13230,11 @@ snapshots:
|
|||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
||||
'@react-oauth/google@0.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@rn-primitives/portal@1.3.0(@types/react@19.2.13)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0))':
|
||||
|
|
@ -15945,9 +15962,9 @@ snapshots:
|
|||
'@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-expo: 1.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@2.6.1))
|
||||
globals: 16.5.0
|
||||
|
|
@ -15962,8 +15979,8 @@ snapshots:
|
|||
'@next/eslint-plugin-next': 16.1.6
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
|
|
@ -15985,7 +16002,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
|
|
@ -15996,18 +16013,18 @@ snapshots:
|
|||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -16020,7 +16037,7 @@ snapshots:
|
|||
- supports-color
|
||||
- typescript
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -16031,7 +16048,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue