pi sills
This commit is contained in:
@@ -2,26 +2,50 @@
|
|||||||
|
|
||||||
Guide for agentic coding assistants working in the Officer monorepo.
|
Guide for agentic coding assistants working in the Officer monorepo.
|
||||||
|
|
||||||
|
## What Is Officer
|
||||||
|
|
||||||
|
Officer is an **AI-powered intranet server** for small and medium businesses. It's a self-hosted platform that gives each team member a personal AI assistant, file storage, terminal, code editor, workspaces, and project management — all under centralized admin control.
|
||||||
|
|
||||||
|
**Think of it as**: a self-hosted, AI-native company intranet where every employee gets their own workspace with shared organizational resources and automation.
|
||||||
|
|
||||||
|
### Multi-User Architecture
|
||||||
|
|
||||||
|
- **Role hierarchy**: Member → Admin → Owner → Super Admin
|
||||||
|
- **Bootstrap flow**: First user registers as Super Admin, then invites the team
|
||||||
|
- **Per-user isolation**: Files, sessions, settings, workspaces, and tasks are scoped per user under `$DATA_PATH/{email}/`
|
||||||
|
- **Shared org resources**: Global tasks/skills/processes, server-level settings (SMTP, AI providers, TTS/STT/OCR), pluggable applications and resources
|
||||||
|
- **Multi-scope resolution**: Tasks, skills, and processes resolve user → global → native (built-in), enabling org-wide shared automation
|
||||||
|
|
||||||
|
### Core Capabilities
|
||||||
|
|
||||||
|
1. **AI Chat** — Multi-provider (Claude, OpenCode, Pi-Mono) with sessions, attachments, speech-to-text, slash commands
|
||||||
|
2. **File Browser** — Full filesystem access per user (upload, mkdir, copy, move, delete)
|
||||||
|
3. **File Viewer** — Preview video, images, code, text, markdown
|
||||||
|
4. **Terminal** — WebSocket-based PTY terminal with Docker sandboxing
|
||||||
|
5. **Code Editor** — Monaco-based IDE with file tabs
|
||||||
|
6. **Projects** — Project management with per-project workspace layouts, git init
|
||||||
|
7. **Workspaces** — Customizable panel-based layouts (split, resize, swap, drag)
|
||||||
|
8. **Automation/Skills/Tasks/Processes** — Markdown-based capability definitions with YAML frontmatter
|
||||||
|
9. **Dev Server** — Start/stop project dev servers with auto-port discovery and live proxy
|
||||||
|
10. **Dashboard Widgets** — Clock, weather, pomodoro, daily goals, quick notes
|
||||||
|
11. **Settings** — User preferences, server config, resource management
|
||||||
|
|
||||||
## Quick Start Commands
|
## Quick Start Commands
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun dev # Dashboard + API server (port 5000)
|
bun dev # Dashboard + API server (port 5000)
|
||||||
bun dev:tracking # Tracking server (port 5001)
|
bun dev:emailer # Emailer workspace
|
||||||
bun dev:experiments # Experiments server
|
|
||||||
bun dev:emailer # Emailer workspace
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building & Database
|
### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run prebuild # Run prebuild tasks
|
bun run prebuild # Run prebuild tasks
|
||||||
|
bun run build:web # Build web app
|
||||||
bun run build:dashboard # Build dashboard
|
bun run build:dashboard # Build dashboard
|
||||||
bun run build:editor # Build editor (app + extension + runtime)
|
bun run build:editor # Build editor (app + extension + runtime)
|
||||||
bun run build:runtime # Build all runtime scripts
|
|
||||||
bun run db:gen && db:push # Generate & push officer_db migrations
|
|
||||||
bun run db:gen:stats && db:push:stats # Generate & push statistics_db
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
@@ -29,11 +53,82 @@ bun run db:gen:stats && db:push:stats # Generate & push statistics_db
|
|||||||
```bash
|
```bash
|
||||||
bun format # Format all files (Prettier, required before commit)
|
bun format # Format all files (Prettier, required before commit)
|
||||||
bun format:check # Check formatting without writing
|
bun format:check # Check formatting without writing
|
||||||
bunx tsgo # TypeScript type checking (comprehensive, slow)
|
bunx tsgo # TypeScript type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** No automated tests configured yet. Always run `bun format` before committing.
|
**Note:** No automated tests configured yet. Always run `bun format` before committing.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── apps/
|
||||||
|
│ └── officer-web/ # Main web UI (React 19)
|
||||||
|
│ ├── Screens/
|
||||||
|
│ │ ├── Authentication/ # Login, verify, reset password
|
||||||
|
│ │ └── Dashboard/ # All main screens (Home, Files, Chat, Terminal, Projects, etc.)
|
||||||
|
│ ├── state/ # App-specific state hooks
|
||||||
|
│ ├── lib/ # Utilities
|
||||||
|
│ └── locales/ # i18n translations
|
||||||
|
│
|
||||||
|
├── servers/
|
||||||
|
│ ├── api/ # REST API (Hono, port 5000)
|
||||||
|
│ │ ├── auth/ # Authentication (JWT + WebAuthn passkeys)
|
||||||
|
│ │ ├── users/ # User management (invite, CRUD)
|
||||||
|
│ │ ├── sessions/ # Multi-provider chat sessions
|
||||||
|
│ │ ├── workspaces/ # Workspace & project state
|
||||||
|
│ │ ├── tasks/ # Task definitions (CRUD + chat)
|
||||||
|
│ │ ├── skills/ # Skill definitions (CRUD + chat)
|
||||||
|
│ │ ├── processes/ # Process definitions (CRUD + chat)
|
||||||
|
│ │ ├── file-browser/ # Filesystem access (multi-root)
|
||||||
|
│ │ ├── terminal/ # WebSocket PTY terminal
|
||||||
|
│ │ ├── dev-server/ # Project dev server management
|
||||||
|
│ │ ├── pi/ # AI agent integration
|
||||||
|
│ │ ├── scrape/ # Web scraping (Playwright)
|
||||||
|
│ │ ├── upload/ # File uploads
|
||||||
|
│ │ ├── settings/ # User settings & state
|
||||||
|
│ │ ├── server-settings/ # Server-wide config (SMTP, AI, TTS, etc.)
|
||||||
|
│ │ ├── dock/ # Dock configuration
|
||||||
|
│ │ ├── plans/ # Markdown plans
|
||||||
|
│ │ ├── task-logs/ # Task execution logs
|
||||||
|
│ │ └── landing-page-data/# Registration status
|
||||||
|
│ └── _middlewares/ # Auth, rate limiting, CORS, body parsing
|
||||||
|
│
|
||||||
|
├── databases/
|
||||||
|
│ └── officer_db/ # JSON file-based auth store (users, passkeys, tokens)
|
||||||
|
│
|
||||||
|
└── workspaces/ # 13 shared packages
|
||||||
|
├── types/ # Central type re-exports
|
||||||
|
├── definitions/ # Constants, enums (roles, statuses, devices)
|
||||||
|
├── config/ # URL configs, env vars
|
||||||
|
├── helpers/ # cn(), formatters, slug, debounce, queue
|
||||||
|
├── hooks/ # 90+ hooks (useClient, useForm, useAuth, etc.)
|
||||||
|
├── state/ # React Query state hooks (useSettings, useChatSessions, etc.)
|
||||||
|
├── components/ # 89 components (shadcn/ui base + custom)
|
||||||
|
├── officerdev/ # Core workspace/panel framework + 11 built-in apps
|
||||||
|
├── i18n/ # Internationalization
|
||||||
|
├── injector/ # DOM manipulation for visual editing
|
||||||
|
├── widgets/ # Dashboard widgets (clock, weather, pomodoro, etc.)
|
||||||
|
├── emailer/ # React-email templates + SMTP
|
||||||
|
└── sounds/ # Audio feedback library
|
||||||
|
```
|
||||||
|
|
||||||
|
## Path Aliases
|
||||||
|
|
||||||
|
- `@/` → `src/apps/officer-web/`
|
||||||
|
- `@@/` → `src/servers/`
|
||||||
|
- `@/components/*` → `src/workspaces/components/*`
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Bun
|
||||||
|
- **Language**: TypeScript 5.9 (strict mode, verbatimModuleSyntax)
|
||||||
|
- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui
|
||||||
|
- **Backend**: Hono framework, JWT auth, WebAuthn passkeys
|
||||||
|
- **Storage**: JSON file-based (auth store + user data), no traditional DB for most data
|
||||||
|
- **Build**: Vite
|
||||||
|
- **AI**: Claude Agent SDK, multi-provider support
|
||||||
|
|
||||||
## Code Style Guide
|
## Code Style Guide
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
@@ -48,9 +143,15 @@ import { useExperiment } from 'hooks/use-experiment';
|
|||||||
import { formatDate } from '../helpers';
|
import { formatDate } from '../helpers';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Type-only imports required (verbatimModuleSyntax):
|
||||||
|
```ts
|
||||||
|
import { ActionModals, type ActionModalsTypes } from './ActionModals';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
```
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
- Strict mode always - no `any`
|
- Strict mode always — no `any`
|
||||||
- Prefer `type` over `interface`
|
- Prefer `type` over `interface`
|
||||||
- Colocate prop types with components as named exports
|
- Colocate prop types with components as named exports
|
||||||
- Early returns for null/undefined guards
|
- Early returns for null/undefined guards
|
||||||
@@ -61,12 +162,11 @@ import { formatDate } from '../helpers';
|
|||||||
- Arrow functions for simple/one-liners
|
- Arrow functions for simple/one-liners
|
||||||
- Regular functions for complex multi-line logic
|
- Regular functions for complex multi-line logic
|
||||||
- Named exports only (never default exports)
|
- Named exports only (never default exports)
|
||||||
|
- Extract params type when signature gets long (no multiline params)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export const formatDate = (ts: number) => new Date(ts).toLocaleDateString();
|
export const formatDate = (ts: number) => new Date(ts).toLocaleDateString();
|
||||||
export function calculateStats(data: DataPoint[]) {
|
export function calculateStats(data: DataPoint[]) { /* ... */ }
|
||||||
/* ... */
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### React Components
|
### React Components
|
||||||
@@ -82,23 +182,13 @@ export const Card = ({ title, onClick }: CardProps) => <div onClick={onClick}>{t
|
|||||||
|
|
||||||
### State Management
|
### State Management
|
||||||
|
|
||||||
- **Manager pattern** for complex hooks - return object with state + methods
|
- **React Query** for server state
|
||||||
- **Colocation** - all feature state in one hook
|
- **useGlobal()** for UI state (backed by query cache, no Context needed)
|
||||||
- **Derived state** - compute in hook, not in components
|
- **useWorkspacesState()** for persistent workspace layouts (server-synced)
|
||||||
|
- **useQueryState()** for URL-synced state
|
||||||
```ts
|
- **usePanelChannel()** for inter-panel pub/sub communication
|
||||||
export const useExperimentManager = (id: number) => {
|
- **Manager pattern** for complex hooks — return object with state + methods
|
||||||
const [exp, setExp] = useState<Experiment | null>(null);
|
- **Derived state** — compute in hook, not in components
|
||||||
const isActive = exp?.status === 'running';
|
|
||||||
return {
|
|
||||||
exp,
|
|
||||||
isActive,
|
|
||||||
update: (data) => {
|
|
||||||
/* ... */
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
@@ -118,40 +208,13 @@ export const useExperimentManager = (id: number) => {
|
|||||||
|
|
||||||
- Use try/catch for async operations
|
- Use try/catch for async operations
|
||||||
- Include context in error messages (IDs, resource names)
|
- Include context in error messages (IDs, resource names)
|
||||||
- Log with `console.error` and re-throw appropriately
|
- Always async/await (never .then() chains)
|
||||||
- Handle database connection errors explicitly
|
|
||||||
|
|
||||||
### Async/Await
|
|
||||||
|
|
||||||
- Always use async/await (never .then() chains)
|
|
||||||
- Minimize nesting
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `src/apps/dashboard/` - Admin UI (React 19)
|
|
||||||
- `src/apps/editor/` - Visual editor
|
|
||||||
- `src/servers/api/` - REST API (Hono, port 5000)
|
|
||||||
- `src/servers/tracking/` - Event collection (port 5001)
|
|
||||||
- `src/databases/` - PostgreSQL schemas (3 DBs: officer_db, statistics_db, ephemeral_db)
|
|
||||||
- `src/workspaces/` - Shared: components, hooks, helpers, types
|
|
||||||
|
|
||||||
## Path Aliases
|
|
||||||
|
|
||||||
- `@/` → `src/apps/dashboard/`
|
|
||||||
- `@@/` → `src/servers/`
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Runtime**: Bun
|
|
||||||
- **Language**: TypeScript 5.9 (strict mode)
|
|
||||||
- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui
|
|
||||||
- **Backend**: Hono, PostgreSQL, Drizzle ORM
|
|
||||||
- **Build**: Vite
|
|
||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
- **Database-first** approach - schema → API → UI
|
- **Database-first** approach — schema → API → UI
|
||||||
- **Self-documenting code** - clear naming, minimal comments
|
- **Self-documenting code** — clear naming, minimal comments
|
||||||
- **Explicit over implicit** - no magic
|
- **Explicit over implicit** — no magic
|
||||||
- **Workspace dependencies** - use `workspace:*`
|
- **Workspace dependencies** — use `workspace:*`
|
||||||
- **Environment variables** - use `.env`, access directly in code
|
- **Multi-user aware** — always consider user isolation and role-based access when adding features
|
||||||
|
- **File-based storage** — user data lives under `$DATA_PATH/{email}/`, respect the per-user boundary
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: mlx.audio
|
name: mlxaudio
|
||||||
description: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon.
|
description: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: whisper.cpp
|
name: whisper-cpp
|
||||||
description: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file.
|
description: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file.
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ export function App() {
|
|||||||
<Route path="/settings/system" element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />} />
|
<Route path="/settings/system" element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />} />
|
||||||
<Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} />
|
<Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} />
|
||||||
<Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} />
|
<Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} />
|
||||||
|
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
||||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useGlobal } from 'hooks/useGlobal';
|
|||||||
|
|
||||||
const initialState: LoginFormState = {
|
const initialState: LoginFormState = {
|
||||||
// email: 'pastilhas@pastilhas.dev',
|
// email: 'pastilhas@pastilhas.dev',
|
||||||
// password: '1234567890',
|
// password: '',
|
||||||
};
|
};
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
|
locked
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
if (!id) navigate('/chat', { replace: true });
|
if (!id) navigate('/chat', { replace: true });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const FilesScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView workspace={workspace} ephemeral={ephemeral} />
|
<WorkspaceView workspace={workspace} locked ephemeral={ephemeral} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev';
|
import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev';
|
||||||
import { WorkspaceView, useFileViewerPanels } from 'officerdev';
|
import { WorkspaceView, useFileViewerPanels } from 'officerdev';
|
||||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
|
import { useSettings } from 'state/useSettings';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
const HomeHeader = () => {
|
const HomeHeader = () => {
|
||||||
|
const { settings, saveSettings } = useSettings();
|
||||||
|
|
||||||
|
const completeOnboarding = () => {
|
||||||
|
saveSettings({ ...settings, onboarding: { complete: true } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-6 text-center">
|
<div className="flex h-full items-center justify-center p-6 text-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -11,6 +19,9 @@ const HomeHeader = () => {
|
|||||||
<p className="mt-2 text-sm opacity-70">
|
<p className="mt-2 text-sm opacity-70">
|
||||||
Please follow the video instructions below in order to get familiar with all that is possible.
|
Please follow the video instructions below in order to get familiar with all that is possible.
|
||||||
</p>
|
</p>
|
||||||
|
<Button className="mt-6" onClick={completeOnboarding}>
|
||||||
|
I'm ready
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -25,10 +36,26 @@ const defaultSort: DefaultFileSort = { field: 'type', direction: 'desc' };
|
|||||||
export const HomeScreen = () => {
|
export const HomeScreen = () => {
|
||||||
const workspace = useWorkspacesState<LayoutNode>('screens/home', defaultLayout);
|
const workspace = useWorkspacesState<LayoutNode>('screens/home', defaultLayout);
|
||||||
const ephemeral = useFileViewerPanels();
|
const ephemeral = useFileViewerPanels();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
if (settings.onboarding.complete) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<WorkspaceView workspace={workspace} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView workspace={workspace} initialFilePath="/Onboarding" defaultFileSort={defaultSort} components={components} ephemeral={ephemeral} />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
initialFilePath="/Onboarding"
|
||||||
|
defaultFileSort={defaultSort}
|
||||||
|
components={components}
|
||||||
|
ephemeral={ephemeral}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { User, Users, LogOut, Settings, Package, Sun, Moon } from 'lucide-react';
|
import { User, Users, LogOut, Settings, Package, Puzzle, Sun, Moon } from 'lucide-react';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||||
@@ -59,6 +59,12 @@ export function UserMenu() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
|
<Link to="/settings/integrations">
|
||||||
|
<Puzzle className="mr-2 h-4 w-4" />
|
||||||
|
Integrations
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
{user?.role === 'Super Admin' && (
|
{user?.role === 'Super Admin' && (
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/users">
|
<Link to="/settings/users">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const ProjectListScreen = () => {
|
|||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
|
locked
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
if (!id) setSelected(null);
|
if (!id) setSelected(null);
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
type GoogleStatus = {
|
||||||
|
connected: boolean;
|
||||||
|
email: string | null;
|
||||||
|
configured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GoogleAccount = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, configured: false });
|
||||||
|
|
||||||
|
const fetchStatus = () => {
|
||||||
|
client
|
||||||
|
.get<GoogleStatus>('/integrations/google/status')
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const result = params.get('google');
|
||||||
|
if (result === 'success') {
|
||||||
|
toast.success('Google account connected');
|
||||||
|
} else if (result === 'error') {
|
||||||
|
toast.error('Failed to connect Google account');
|
||||||
|
}
|
||||||
|
if (result) {
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
token: client.token ?? '',
|
||||||
|
origin: window.location.origin,
|
||||||
|
});
|
||||||
|
window.location.href = `/api/integrations/google/authorize?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
await client.delete('/integrations/google/connection');
|
||||||
|
setStatus({ ...status, connected: false, email: null });
|
||||||
|
toast.success('Google account disconnected');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to disconnect Google account');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
if (!status.configured) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||||
|
Google integration has not been configured yet. Ask your administrator to set up Google OAuth credentials in
|
||||||
|
the Enterprise settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.connected) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
||||||
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
Officer has access to your Google Calendar, Gmail, and other enabled services.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className="w-full h-11 cursor-pointer"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||||
|
Connect your Google account to give Officer access to your Calendar, Gmail, and other Google services.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConnect}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||||
|
>
|
||||||
|
Connect Google Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
type GoogleOAuthSettings = {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCOPES = [
|
||||||
|
{ scope: 'gmail.readonly', description: 'Read emails' },
|
||||||
|
{ scope: 'calendar.readonly', description: 'Read calendar events' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
|
||||||
|
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||||
|
Step-by-step setup guide
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Go to the{' '}
|
||||||
|
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||||
|
New Project
|
||||||
|
</a>{' '}
|
||||||
|
page. Give it a name (e.g. "Officer") and click <strong>Create</strong>.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Go to{' '}
|
||||||
|
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||||
|
API Library
|
||||||
|
</a>
|
||||||
|
. Search for and enable each of these:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
<li><strong>Gmail API</strong></li>
|
||||||
|
<li><strong>Google Calendar API</strong></li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Go to{' '}
|
||||||
|
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||||
|
OAuth Branding
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
<li>Set <strong>App name</strong> to your organization name or "Officer"</li>
|
||||||
|
<li>Set <strong>User support email</strong> to your admin email</li>
|
||||||
|
<li>Add your admin email under <strong>Developer contact information</strong></li>
|
||||||
|
<li>Click <strong>Save</strong></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
Go to{' '}
|
||||||
|
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||||
|
OAuth Audience
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
<li>
|
||||||
|
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
In the left sidebar, click{' '}
|
||||||
|
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||||
|
Data Access
|
||||||
|
</a>
|
||||||
|
, then click <strong>Add or remove scopes</strong>. Search for and add:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
{SCOPES.map((s) => (
|
||||||
|
<li key={s.scope}>
|
||||||
|
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
|
||||||
|
{s.scope}
|
||||||
|
</code>{' '}
|
||||||
|
— {s.description}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
|
||||||
|
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
|
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
|
||||||
|
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
|
||||||
|
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
In the left sidebar, click{' '}
|
||||||
|
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||||
|
Clients
|
||||||
|
</a>
|
||||||
|
, then click <strong>Create OAuth client</strong>.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||||
|
<li>Application type: <strong>Web application</strong></li>
|
||||||
|
<li>Name: anything (e.g. "Officer")</li>
|
||||||
|
<li>
|
||||||
|
Authorized redirect URIs: add{' '}
|
||||||
|
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
|
||||||
|
{redirectUri}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>Create</strong></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
|
||||||
|
<p className="mt-1">
|
||||||
|
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type VerifyStatus = { valid: boolean; error: string | null } | null;
|
||||||
|
|
||||||
|
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
|
||||||
|
if (isVerifying) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" />
|
||||||
|
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">Verifying credentials...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||||
|
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
|
||||||
|
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GoogleOAuthConfig = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [clientId, setClientId] = useState('');
|
||||||
|
const [clientSecret, setClientSecret] = useState('');
|
||||||
|
const [verifyStatus, setVerifyStatus] = useState<VerifyStatus>(null);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
|
||||||
|
const verify = () => {
|
||||||
|
setIsVerifying(true);
|
||||||
|
client
|
||||||
|
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
|
||||||
|
.then(setVerifyStatus)
|
||||||
|
.catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' }))
|
||||||
|
.finally(() => setIsVerifying(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client
|
||||||
|
.get<GoogleOAuthSettings | null>('/integrations/google/config')
|
||||||
|
.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
setClientId(data.clientId);
|
||||||
|
setClientSecret(data.clientSecret);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Verify on load if credentials exist
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && clientId && clientSecret) verify();
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
|
||||||
|
toast.success('Google OAuth configuration saved');
|
||||||
|
verify();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save Google OAuth configuration');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
const redirectUri = `${window.location.origin}/api/integrations/google/callback`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<CredentialStatus status={verifyStatus} isVerifying={isVerifying} />
|
||||||
|
|
||||||
|
<SetupGuide redirectUri={redirectUri} />
|
||||||
|
|
||||||
|
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="text"
|
||||||
|
value={clientId}
|
||||||
|
onChange={(ev) => setClientId(ev.target.value)}
|
||||||
|
placeholder="123456789.apps.googleusercontent.com"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Client Secret</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="password"
|
||||||
|
value={clientSecret}
|
||||||
|
onChange={(ev) => setClientSecret(ev.target.value)}
|
||||||
|
placeholder="GOCSPX-..."
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Redirect URI</span>
|
||||||
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
Add this URI to your Google OAuth client's authorized redirect URIs
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
|
||||||
|
type="text"
|
||||||
|
value={redirectUri}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !clientId.trim() || !clientSecret.trim()}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Puzzle, KeyRound, UserCircle } from 'lucide-react';
|
||||||
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
|
||||||
|
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||||
|
import { GoogleAccount } from './GoogleAccount';
|
||||||
|
|
||||||
|
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
||||||
|
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
||||||
|
|
||||||
|
const enterpriseSections: SettingsSection[] = [
|
||||||
|
{ key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: <GoogleOAuthConfig /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const personalSections: SettingsSection[] = [
|
||||||
|
{ key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: <GoogleAccount /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const IntegrationsSidebar = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isSuperAdmin = user?.role === 'Super Admin';
|
||||||
|
const [tab, setTab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
|
||||||
|
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="p-3 pb-2">
|
||||||
|
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||||
|
<Puzzle className="h-4 w-4" />
|
||||||
|
Integrations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
|
||||||
|
Enterprise
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
|
||||||
|
Personal
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SettingsSidebar globalKey={GLOBAL_KEY} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const IntegrationsContent = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isSuperAdmin = user?.role === 'Super Admin';
|
||||||
|
const [tab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
|
||||||
|
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
||||||
|
|
||||||
|
return <SettingsContent globalKey={GLOBAL_KEY} sections={sections} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'integrations-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'integrations-left', appType: null }, size: 20 },
|
||||||
|
{ node: { type: 'panel', id: 'integrations-right', appType: null }, size: 80 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IntegrationsSettings = () => {
|
||||||
|
const panelComponents: PanelComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
'integrations-left': IntegrationsSidebar,
|
||||||
|
'integrations-right': IntegrationsContent,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,6 +24,7 @@ type SettingsSidebarProps = {
|
|||||||
label: string;
|
label: string;
|
||||||
sections: SettingsSection[];
|
sections: SettingsSection[];
|
||||||
groups?: SettingsSectionGroup[];
|
groups?: SettingsSectionGroup[];
|
||||||
|
hideHeader?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SectionButton = ({
|
const SectionButton = ({
|
||||||
@@ -50,7 +51,7 @@ const SectionButton = ({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups }: SettingsSidebarProps) => {
|
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
|
||||||
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
|
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
|
||||||
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, allSections[0]?.key ?? null);
|
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, allSections[0]?.key ?? null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -61,12 +62,14 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto">
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
<div className="p-3 pb-2">
|
{!hideHeader && (
|
||||||
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
<div className="p-3 pb-2">
|
||||||
<Icon className="h-4 w-4" />
|
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||||
{label}
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="px-3 pb-2">
|
<div className="px-3 pb-2">
|
||||||
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './ProfileSettings';
|
|||||||
export * from './SystemSettings';
|
export * from './SystemSettings';
|
||||||
export * from './ResourceSettings';
|
export * from './ResourceSettings';
|
||||||
export * from './UserSettings';
|
export * from './UserSettings';
|
||||||
|
export * from './IntegrationsSettings';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const TerminalScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView workspace={workspace} />
|
<WorkspaceView workspace={workspace} locked />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const WorkspacesScreen = () => {
|
|||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
|
locked
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
if (!id) setSelected(null);
|
if (!id) setSelected(null);
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ dockRouter.get('/', async (ctx) => {
|
|||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const data = await file.json();
|
try {
|
||||||
return ctx.json(data);
|
return ctx.json(await file.json());
|
||||||
|
} catch {
|
||||||
|
// corrupted file — treat as missing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.json(null);
|
return ctx.json(null);
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { DATA_PATH } from '@@/data-path';
|
||||||
|
import { CustomError } from '../../custom-errors';
|
||||||
|
|
||||||
|
const configDir = `${homedir()}/.config/officer.dev`;
|
||||||
|
const googleConfigPath = join(configDir, 'google-oauth.json');
|
||||||
|
|
||||||
|
const GOOGLE_SCOPES = [
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
'https://www.googleapis.com/auth/calendar.readonly',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
export const readGoogleConfig = async () => {
|
||||||
|
try {
|
||||||
|
return await Bun.file(googleConfigPath).json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserGoogleFile = (email: string) => join(DATA_PATH, email, 'integrations', 'google.json');
|
||||||
|
|
||||||
|
const readUserGoogle = async (email: string) => {
|
||||||
|
try {
|
||||||
|
return await Bun.file(getUserGoogleFile(email)).json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeUserGoogle = async (email: string, data: Record<string, unknown>) => {
|
||||||
|
const filePath = getUserGoogleFile(email);
|
||||||
|
await ensureDir(filePath);
|
||||||
|
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const integrationsRouter = createRouter();
|
||||||
|
|
||||||
|
integrationsRouter.get('/', async (ctx) => {
|
||||||
|
return ctx.json([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Enterprise: Google OAuth config (Super Admin only) ---
|
||||||
|
|
||||||
|
integrationsRouter.get('/google/config', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
|
||||||
|
return ctx.json(await readGoogleConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
integrationsRouter.put('/google/config', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
|
||||||
|
|
||||||
|
const body = ctx.get('body') as { clientId?: string; clientSecret?: string };
|
||||||
|
const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
|
||||||
|
|
||||||
|
await ensureDir(googleConfigPath);
|
||||||
|
await Bun.write(googleConfigPath, JSON.stringify(config, null, 2));
|
||||||
|
return ctx.json(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
integrationsRouter.get('/google/verify', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
|
||||||
|
|
||||||
|
const config = await readGoogleConfig();
|
||||||
|
if (!config?.clientId || !config?.clientSecret) {
|
||||||
|
return ctx.json({ valid: false, error: 'Missing credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a dummy token exchange — valid credentials return "invalid_grant",
|
||||||
|
// invalid credentials return "invalid_client"
|
||||||
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
code: 'invalid_code',
|
||||||
|
redirect_uri: 'https://localhost',
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch';
|
||||||
|
|
||||||
|
return ctx.json({ valid, error: valid ? null : body.error_description ?? body.error });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Personal: Google account connection status ---
|
||||||
|
|
||||||
|
integrationsRouter.get('/google/status', async (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const config = await readGoogleConfig();
|
||||||
|
const connection = await readUserGoogle(email);
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
configured: !!(config?.clientId && config?.clientSecret),
|
||||||
|
connected: !!connection?.accessToken,
|
||||||
|
email: connection?.email ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
integrationsRouter.delete('/google/connection', async (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const filePath = getUserGoogleFile(email);
|
||||||
|
|
||||||
|
const file = Bun.file(filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await Bun.write(filePath, '{}');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- OAuth flow: authorize (protected — user must be logged in) ---
|
||||||
|
|
||||||
|
integrationsRouter.get('/google/authorize', async (ctx) => {
|
||||||
|
const config = await readGoogleConfig();
|
||||||
|
if (!config?.clientId || !config?.clientSecret) {
|
||||||
|
throw new CustomError('Google OAuth not configured', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const origin = ctx.req.query('origin');
|
||||||
|
if (!origin) throw new CustomError('Missing origin parameter', 400);
|
||||||
|
const redirectUri = `${origin}/api/integrations/google/callback`;
|
||||||
|
|
||||||
|
const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url');
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: GOOGLE_SCOPES.join(' '),
|
||||||
|
access_type: 'offline',
|
||||||
|
prompt: 'consent',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctx.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- OAuth callback (public — called by Google, exported for hono.ts) ---
|
||||||
|
|
||||||
|
export const googleCallbackHandler = async (ctx: any) => {
|
||||||
|
const code = ctx.req.query('code');
|
||||||
|
const stateParam = ctx.req.query('state');
|
||||||
|
const error = ctx.req.query('error');
|
||||||
|
|
||||||
|
if (error || !code || !stateParam) {
|
||||||
|
return ctx.redirect('/settings/integrations?google=error');
|
||||||
|
}
|
||||||
|
|
||||||
|
let email: string;
|
||||||
|
let redirectUri: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(Buffer.from(stateParam, 'base64url').toString());
|
||||||
|
email = parsed.email;
|
||||||
|
redirectUri = parsed.redirectUri;
|
||||||
|
} catch {
|
||||||
|
return ctx.redirect('/settings/integrations?google=error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readGoogleConfig();
|
||||||
|
if (!config?.clientId || !config?.clientSecret) {
|
||||||
|
return ctx.redirect('/settings/integrations?google=error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
console.error('Google token exchange failed:', await tokenResponse.text());
|
||||||
|
return ctx.redirect('/settings/integrations?google=error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
|
// Fetch the user's Google email
|
||||||
|
const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let googleEmail = email;
|
||||||
|
if (userinfoResponse.ok) {
|
||||||
|
const userinfo = await userinfoResponse.json();
|
||||||
|
googleEmail = userinfo.email ?? email;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeUserGoogle(email, {
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||||
|
email: googleEmail,
|
||||||
|
scope: tokens.scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctx.redirect('/settings/integrations?google=success');
|
||||||
|
};
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
import { join, relative } from "path";
|
import { join, relative } from "path";
|
||||||
|
import { readdirSync, existsSync, mkdirSync } from "node:fs";
|
||||||
import type { Subprocess } from "bun";
|
import type { Subprocess } from "bun";
|
||||||
import type { PiEvent, MessageCost } from "./types";
|
import type { PiEvent, MessageCost } from "./types";
|
||||||
import { readApiKeys } from "../server-settings/pi-mono";
|
import { readApiKeys } from "../server-settings/pi-mono";
|
||||||
import { PI_CONFIG_DIR } from "../../data-path";
|
import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir } from "../../data-path";
|
||||||
import { ensureDockerContainer } from "../terminal/websocket";
|
import { ensureDockerContainer } from "../terminal/websocket";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
export type PiEventHandler = (event: PiEvent) => void;
|
export type PiEventHandler = (event: PiEvent) => void;
|
||||||
|
|
||||||
|
function collectSkillFlags(email: string): string[] {
|
||||||
|
const flags: string[] = [];
|
||||||
|
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!existsSync(dir)) continue;
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const skillFile = join(dir, entry.name, 'SKILL.md');
|
||||||
|
if (existsSync(skillFile)) {
|
||||||
|
flags.push('--skill', join(dir, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
type SandboxOptions = {
|
type SandboxOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -18,6 +38,7 @@ type SandboxOptions = {
|
|||||||
export async function spawnPi(
|
export async function spawnPi(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
model: string,
|
model: string,
|
||||||
|
email: string,
|
||||||
onEvent: PiEventHandler,
|
onEvent: PiEventHandler,
|
||||||
sandbox?: SandboxOptions,
|
sandbox?: SandboxOptions,
|
||||||
): Promise<Subprocess> {
|
): Promise<Subprocess> {
|
||||||
@@ -59,9 +80,14 @@ export async function spawnPi(
|
|||||||
logger.info('Spawned Pi in container', { containerId, model });
|
logger.info('Spawned Pi in container', { containerId, model });
|
||||||
} else {
|
} else {
|
||||||
const storedKeys = await readApiKeys();
|
const storedKeys = await readApiKeys();
|
||||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
const skillFlags = collectSkillFlags(email);
|
||||||
|
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags];
|
||||||
if (model) args.push('--model', model);
|
if (model) args.push('--model', model);
|
||||||
|
|
||||||
|
if (!existsSync(cwd)) {
|
||||||
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
proc = Bun.spawn(args, {
|
proc = Bun.spawn(args, {
|
||||||
cwd,
|
cwd,
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
@@ -69,6 +95,8 @@ export async function spawnPi(
|
|||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info('Spawned Pi locally', { model, skills: skillFlags.filter((f) => f !== '--skill').length });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read stdout JSON event stream (runs in background)
|
// Read stdout JSON event stream (runs in background)
|
||||||
|
|||||||
@@ -36,23 +36,25 @@ type WSData = {
|
|||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
const resolveRoot = (email: string, root?: string) => {
|
const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => {
|
||||||
if (!root || root === 'home') return getHomeDir(email);
|
const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email);
|
||||||
if (root === '~') return homedir();
|
if (!cwd || cwd === '~') return root;
|
||||||
if (root === 'officer.dev') return resolve(process.cwd(), '..');
|
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||||
return getHomeDir(email);
|
if (cwd.startsWith('/')) return join(root, cwd.slice(1));
|
||||||
|
return root;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveCwd = (home: string, cwd?: string) => {
|
const resolveHostCwd = (cwdRoot?: string, cwd?: string) => {
|
||||||
if (!cwd || cwd === '~') return home;
|
if (cwdRoot === 'officer.dev') return resolve(process.cwd(), '..');
|
||||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
const root = homedir();
|
||||||
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
|
if (!cwd || cwd === '~') return root;
|
||||||
return home;
|
if (cwd.startsWith('/')) return cwd;
|
||||||
|
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||||
|
return join(root, cwd);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
|
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
|
||||||
const root = resolveRoot(email, cwdRoot);
|
return resolveHostCwd(cwdRoot, cwd);
|
||||||
return resolveCwd(root, cwd);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const wsToSessionMap = new WeakMap<any, string>();
|
const wsToSessionMap = new WeakMap<any, string>();
|
||||||
@@ -271,11 +273,11 @@ async function handleChat(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const homeDir = getHomeDir(email);
|
const homeDir = getHomeDir(email);
|
||||||
const rootDir = resolveRoot(email, msg.cwdRoot);
|
|
||||||
const cwd = resolveCwd(rootDir, msg.cwd);
|
|
||||||
const groupSlug = msg.groupSlug || null;
|
|
||||||
|
|
||||||
const sandboxed = msg.sandboxed ?? false;
|
const sandboxed = msg.sandboxed ?? false;
|
||||||
|
const cwd = sandboxed
|
||||||
|
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
|
||||||
|
: resolveHostCwd(msg.cwdRoot, msg.cwd);
|
||||||
|
const groupSlug = msg.groupSlug || null;
|
||||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
|
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
|
||||||
session.sandboxed = sandboxed;
|
session.sandboxed = sandboxed;
|
||||||
session.userId = userId;
|
session.userId = userId;
|
||||||
@@ -286,7 +288,7 @@ async function handleChat(
|
|||||||
if (!session.piProcess) {
|
if (!session.piProcess) {
|
||||||
try {
|
try {
|
||||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
||||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
|
session.piProcess = await piBridge.spawnPi(cwd, model, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
|
||||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||||
@@ -355,7 +357,7 @@ async function handleResume(
|
|||||||
const homeDir = getHomeDir(email);
|
const homeDir = getHomeDir(email);
|
||||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
||||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
||||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
|
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, email, onEvent, sandbox);
|
||||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||||
|
|||||||
@@ -34,19 +34,22 @@ serverSettingsRouter.route('/tts', ttsRouter);
|
|||||||
serverSettingsRouter.route('/stt', sttRouter);
|
serverSettingsRouter.route('/stt', sttRouter);
|
||||||
serverSettingsRouter.route('/ocr', ocrRouter);
|
serverSettingsRouter.route('/ocr', ocrRouter);
|
||||||
|
|
||||||
|
const readSettings = async () => {
|
||||||
|
try { return await Bun.file(settingsPath).json(); } catch { return {}; }
|
||||||
|
};
|
||||||
|
|
||||||
serverSettingsRouter.get('/settings', async (ctx) => {
|
serverSettingsRouter.get('/settings', async (ctx) => {
|
||||||
const settings = await Bun.file(settingsPath).json();
|
return ctx.json(await readSettings());
|
||||||
return ctx.json(settings);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
|
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
|
||||||
const settings = await Bun.file(settingsPath).json();
|
const settings = await readSettings();
|
||||||
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
||||||
});
|
});
|
||||||
|
|
||||||
serverSettingsRouter.put('/', async (ctx) => {
|
serverSettingsRouter.put('/', async (ctx) => {
|
||||||
const body = await ctx.req.json();
|
const body = await ctx.req.json();
|
||||||
const settings = await Bun.file(settingsPath).json();
|
const settings = await readSettings();
|
||||||
const updated = { ...settings, ...body };
|
const updated = { ...settings, ...body };
|
||||||
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
|
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
|
||||||
return ctx.json(updated);
|
return ctx.json(updated);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
|
|||||||
if (provider === 'claude') {
|
if (provider === 'claude') {
|
||||||
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
|
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
|
||||||
if (!(await file.exists())) return ctx.json([]);
|
if (!(await file.exists())) return ctx.json([]);
|
||||||
return ctx.json(await file.json());
|
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'opencode') {
|
if (provider === 'opencode') {
|
||||||
@@ -44,7 +44,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
|
|||||||
if (provider === 'pi-mono') {
|
if (provider === 'pi-mono') {
|
||||||
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
|
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
|
||||||
if (!(await file.exists())) return ctx.json([]);
|
if (!(await file.exists())) return ctx.json([]);
|
||||||
return ctx.json(await file.json());
|
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.json({ error: 'invalid provider' }, 400);
|
return ctx.json({ error: 'invalid provider' }, 400);
|
||||||
@@ -78,7 +78,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
|||||||
const dir = getSessionDir(email, id);
|
const dir = getSessionDir(email, id);
|
||||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||||
const meta = await metaFile.json();
|
let meta: Record<string, unknown>;
|
||||||
|
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
|
||||||
meta.title = body.title.slice(0, 200);
|
meta.title = body.title.slice(0, 200);
|
||||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
@@ -88,7 +89,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
|||||||
const dir = getOpencodeSessionDir(email, id);
|
const dir = getOpencodeSessionDir(email, id);
|
||||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||||
const meta = await metaFile.json();
|
let meta: Record<string, unknown>;
|
||||||
|
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
|
||||||
meta.title = body.title.slice(0, 200);
|
meta.title = body.title.slice(0, 200);
|
||||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||||
|
|
||||||
@@ -106,7 +108,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
|||||||
const dir = getPiMonoSessionDir(email, id);
|
const dir = getPiMonoSessionDir(email, id);
|
||||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||||
const meta = await metaFile.json();
|
let meta: Record<string, unknown>;
|
||||||
|
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
|
||||||
meta.title = body.title.slice(0, 200);
|
meta.title = body.title.slice(0, 200);
|
||||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ settingsRouter.get('/settings', async (ctx) => {
|
|||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const data = await file.json();
|
try {
|
||||||
return ctx.json(data);
|
return ctx.json(await file.json());
|
||||||
|
} catch {
|
||||||
|
// corrupted — fall through to defaults
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDir(filePath);
|
await ensureDir(filePath);
|
||||||
@@ -54,8 +57,11 @@ settingsRouter.get('/state', async (ctx) => {
|
|||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const data = await file.json();
|
try {
|
||||||
return ctx.json(data);
|
return ctx.json(await file.json());
|
||||||
|
} catch {
|
||||||
|
// corrupted — fall through to empty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDir(filePath);
|
await ensureDir(filePath);
|
||||||
@@ -72,7 +78,7 @@ settingsRouter.patch('/state', async (ctx) => {
|
|||||||
|
|
||||||
let existing: Record<string, unknown> = {};
|
let existing: Record<string, unknown> = {};
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
existing = await file.json();
|
try { existing = await file.json(); } catch { /* corrupted — start fresh */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = { ...existing, ...body };
|
const merged = { ...existing, ...body };
|
||||||
|
|||||||
@@ -72,9 +72,13 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function readJsonFile(path: string): Promise<unknown | null> {
|
export async function readJsonFile(path: string): Promise<unknown | null> {
|
||||||
const file = Bun.file(path);
|
try {
|
||||||
if (await file.exists()) return file.json();
|
const file = Bun.file(path);
|
||||||
return null;
|
if (!(await file.exists())) return null;
|
||||||
|
return await file.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeJsonFile(path: string, data: unknown) {
|
export async function writeJsonFile(path: string, data: unknown) {
|
||||||
@@ -86,7 +90,8 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
|
|||||||
const file = Bun.file(stateFile);
|
const file = Bun.file(stateFile);
|
||||||
if (!(await file.exists())) return;
|
if (!(await file.exists())) return;
|
||||||
|
|
||||||
const state = (await file.json()) as Record<string, unknown>;
|
let state: Record<string, unknown>;
|
||||||
|
try { state = (await file.json()) as Record<string, unknown>; } catch { return; }
|
||||||
const wsKeys = Object.keys(state).filter(
|
const wsKeys = Object.keys(state).filter(
|
||||||
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
|
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
|||||||
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
||||||
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
||||||
import { initAuthStore } from 'officerdb';
|
import { initAuthStore } from 'officerdb';
|
||||||
|
import { syncSeedSkills } from './sync-skills';
|
||||||
|
|
||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
||||||
@@ -69,6 +70,7 @@ function seedPiConfig(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seedPiConfig();
|
seedPiConfig();
|
||||||
|
syncSeedSkills();
|
||||||
|
|
||||||
await syncLocalProvidersToPiConfig().catch(err => {
|
await syncLocalProvidersToPiConfig().catch(err => {
|
||||||
console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
|
console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { router as fileBrowserRouter } from './api/file-browser/router';
|
|||||||
import { piRestRouter } from './api/pi/rest';
|
import { piRestRouter } from './api/pi/rest';
|
||||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||||
import { dockRouter } from './api/dock/dock';
|
import { dockRouter } from './api/dock/dock';
|
||||||
|
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser } from './_middlewares';
|
import { userMiddleware, bodyParser } from './_middlewares';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ honoServer.route('/api/auth', authRouter);
|
|||||||
honoServer.route('/api/server-settings', serverSettingsRouter);
|
honoServer.route('/api/server-settings', serverSettingsRouter);
|
||||||
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
||||||
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
||||||
|
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||||
|
|
||||||
const protectedRouter = createRouter();
|
const protectedRouter = createRouter();
|
||||||
protectedRouter.use(bodyParser());
|
protectedRouter.use(bodyParser());
|
||||||
@@ -61,6 +63,7 @@ protectedRouter.route('/task-logs', taskLogsRouter);
|
|||||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||||
protectedRouter.route('/dev-server', devServerRouter);
|
protectedRouter.route('/dev-server', devServerRouter);
|
||||||
protectedRouter.route('/dock', dockRouter);
|
protectedRouter.route('/dock', dockRouter);
|
||||||
|
protectedRouter.route('/integrations', integrationsRouter);
|
||||||
protectedRouter.route('/', piRestRouter);
|
protectedRouter.route('/', piRestRouter);
|
||||||
|
|
||||||
honoServer.route('/api', protectedRouter);
|
honoServer.route('/api', protectedRouter);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { SEED_PATH, DATA_PATH } from './data-path';
|
||||||
|
|
||||||
|
const SEED_SKILLS_DIR = join(SEED_PATH, 'skills');
|
||||||
|
const GLOBAL_SKILLS_DIR = join(DATA_PATH, 'skills');
|
||||||
|
|
||||||
|
export function syncSeedSkills(): void {
|
||||||
|
if (!existsSync(SEED_SKILLS_DIR)) return;
|
||||||
|
|
||||||
|
mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const seedEntries = readdirSync(SEED_SKILLS_DIR, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of seedEntries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
|
const seedSkillDir = join(SEED_SKILLS_DIR, entry.name);
|
||||||
|
const skillFile = join(seedSkillDir, 'SKILL.md');
|
||||||
|
if (!existsSync(skillFile)) continue;
|
||||||
|
|
||||||
|
const targetDir = join(GLOBAL_SKILLS_DIR, entry.name);
|
||||||
|
|
||||||
|
if (existsSync(targetDir)) {
|
||||||
|
// Skill already exists in DATA_PATH — skip to preserve user edits
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cpSync(seedSkillDir, targetDir, { recursive: true });
|
||||||
|
console.log(`[skills] Synced seed skill: ${entry.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
let blobUrl: string | null = null;
|
||||||
const onLoaded = () => {
|
const onLoaded = () => {
|
||||||
setDuration(v.duration);
|
setDuration(v.duration);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
@@ -35,13 +36,33 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
const onPlay = () => setPlaying(true);
|
const onPlay = () => setPlaying(true);
|
||||||
const onPause = () => setPlaying(false);
|
const onPause = () => setPlaying(false);
|
||||||
const onEnded = () => setPlaying(false);
|
const onEnded = () => setPlaying(false);
|
||||||
|
let fetching = false;
|
||||||
|
let fetchDone = false;
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
if (fallbackSrc && v.src !== fallbackSrc) {
|
if (fetching) return;
|
||||||
v.src = fallbackSrc;
|
if (fetchDone) {
|
||||||
v.load();
|
|
||||||
} else {
|
|
||||||
setError(true);
|
setError(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
fetching = true;
|
||||||
|
const fetchUrl = fallbackSrc || src;
|
||||||
|
fetch(fetchUrl)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
return res.blob();
|
||||||
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
fetching = false;
|
||||||
|
fetchDone = true;
|
||||||
|
blobUrl = URL.createObjectURL(blob);
|
||||||
|
v.src = blobUrl;
|
||||||
|
v.load();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
fetching = false;
|
||||||
|
fetchDone = true;
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
v.addEventListener('loadedmetadata', onLoaded);
|
v.addEventListener('loadedmetadata', onLoaded);
|
||||||
v.addEventListener('timeupdate', onTime);
|
v.addEventListener('timeupdate', onTime);
|
||||||
@@ -56,6 +77,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
v.removeEventListener('pause', onPause);
|
v.removeEventListener('pause', onPause);
|
||||||
v.removeEventListener('ended', onEnded);
|
v.removeEventListener('ended', onEnded);
|
||||||
v.removeEventListener('error', onError);
|
v.removeEventListener('error', onError);
|
||||||
|
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -125,7 +147,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
if (playing) setShowControls(false);
|
if (playing) setShowControls(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} />
|
<video ref={videoRef} src={src} preload="metadata" playsInline className="max-w-full max-h-full" onClick={togglePlay} />
|
||||||
|
|
||||||
{loaded && !playing && (
|
{loaded && !playing && (
|
||||||
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
|
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type PanelSlotProps = {
|
|||||||
registry: AppRegistry;
|
registry: AppRegistry;
|
||||||
components?: PanelComponents;
|
components?: PanelComponents;
|
||||||
interactive: boolean;
|
interactive: boolean;
|
||||||
|
locked: boolean;
|
||||||
noHeader: boolean;
|
noHeader: boolean;
|
||||||
isLastPanel: boolean;
|
isLastPanel: boolean;
|
||||||
onSetApp: (panelId: string, appType: string | null) => void;
|
onSetApp: (panelId: string, appType: string | null) => void;
|
||||||
@@ -164,6 +165,46 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MaximizeButton = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||||
|
const isMaximized = maximizedPanelId === panelId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}
|
||||||
|
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||||
|
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||||
|
>
|
||||||
|
{isMaximized ? (
|
||||||
|
<Minus className="h-2 w-2 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||||
|
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: React.ReactNode }) => {
|
||||||
|
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||||
|
const isMaximized = maximizedPanelId === panelId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
|
||||||
|
{isMaximized ? 'Restore' : 'Maximize'}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
|
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
|
||||||
// const DragHandle = ({ panelId }: { panelId: string }) => {
|
// const DragHandle = ({ panelId }: { panelId: string }) => {
|
||||||
// const { setDragSourceId, dragSourceId } = useWorkspace();
|
// const { setDragSourceId, dragSourceId } = useWorkspace();
|
||||||
@@ -182,7 +223,7 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
|
|||||||
// return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />;
|
// return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />;
|
||||||
// };
|
// };
|
||||||
|
|
||||||
export const PanelSlot = ({ panel, registry, components, interactive, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
export const PanelSlot = ({ panel, registry, components, interactive, locked, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||||
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
|
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
|
||||||
const isMaximized = maximizedPanelId === panel.id;
|
const isMaximized = maximizedPanelId === panel.id;
|
||||||
|
|
||||||
@@ -199,14 +240,18 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
|
|||||||
const onClose = panelEntry?.onClose;
|
const onClose = panelEntry?.onClose;
|
||||||
|
|
||||||
const contextMenu = interactive
|
const contextMenu = interactive
|
||||||
? (content: React.ReactNode) => (
|
? locked
|
||||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
? (content: React.ReactNode) => (
|
||||||
{content}
|
<MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
|
||||||
</PanelContextMenu>
|
)
|
||||||
)
|
: (content: React.ReactNode) => (
|
||||||
|
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||||
|
{content}
|
||||||
|
</PanelContextMenu>
|
||||||
|
)
|
||||||
: (content: React.ReactNode) => <>{content}</>;
|
: (content: React.ReactNode) => <>{content}</>;
|
||||||
|
|
||||||
const overlays = interactive ? (
|
const overlays = interactive && !locked ? (
|
||||||
<>
|
<>
|
||||||
<SwapOverlay panelId={panel.id} />
|
<SwapOverlay panelId={panel.id} />
|
||||||
<SwapSourceIndicator panelId={panel.id} />
|
<SwapSourceIndicator panelId={panel.id} />
|
||||||
@@ -214,7 +259,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (!AppComponent) {
|
if (!AppComponent) {
|
||||||
if (!interactive) {
|
if (!interactive || locked) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-1">
|
<div className="h-full w-full p-1">
|
||||||
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
|
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
|
||||||
@@ -254,7 +299,11 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
|
|||||||
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
||||||
|
|
||||||
const trafficLights = interactive && !isMobile ? (
|
const trafficLights = interactive && !isMobile ? (
|
||||||
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
locked ? (
|
||||||
|
<MaximizeButton panelId={panel.id} />
|
||||||
|
) : (
|
||||||
|
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
||||||
|
)
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const mobileBackButton = isMobile && onMobileBack ? (
|
const mobileBackButton = isMobile && onMobileBack ? (
|
||||||
@@ -285,9 +334,13 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
|
|||||||
);
|
);
|
||||||
|
|
||||||
const headerBar = interactive ? (
|
const headerBar = interactive ? (
|
||||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
locked ? (
|
||||||
{headerContent}
|
<MaximizeContextMenu panelId={panel.id}>{headerContent}</MaximizeContextMenu>
|
||||||
</PanelContextMenu>
|
) : (
|
||||||
|
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||||
|
{headerContent}
|
||||||
|
</PanelContextMenu>
|
||||||
|
)
|
||||||
) : headerContent;
|
) : headerContent;
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type WorkspaceRendererProps = {
|
|||||||
registry: AppRegistry;
|
registry: AppRegistry;
|
||||||
components?: PanelComponents;
|
components?: PanelComponents;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
|
locked?: boolean;
|
||||||
noHeader?: boolean;
|
noHeader?: boolean;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
mobilePanelId?: string;
|
mobilePanelId?: string;
|
||||||
@@ -23,6 +24,7 @@ export const WorkspaceRenderer = ({
|
|||||||
registry,
|
registry,
|
||||||
components,
|
components,
|
||||||
interactive = false,
|
interactive = false,
|
||||||
|
locked = false,
|
||||||
noHeader = false,
|
noHeader = false,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
mobilePanelId,
|
mobilePanelId,
|
||||||
@@ -40,6 +42,7 @@ export const WorkspaceRenderer = ({
|
|||||||
registry={registry}
|
registry={registry}
|
||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
|
locked={locked}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
@@ -58,6 +61,7 @@ type LayoutNodeRendererProps = {
|
|||||||
registry: AppRegistry;
|
registry: AppRegistry;
|
||||||
components?: PanelComponents;
|
components?: PanelComponents;
|
||||||
interactive: boolean;
|
interactive: boolean;
|
||||||
|
locked: boolean;
|
||||||
noHeader: boolean;
|
noHeader: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
mobilePanelId?: string;
|
mobilePanelId?: string;
|
||||||
@@ -91,6 +95,7 @@ const LayoutNodeRenderer = ({
|
|||||||
registry,
|
registry,
|
||||||
components,
|
components,
|
||||||
interactive,
|
interactive,
|
||||||
|
locked,
|
||||||
noHeader,
|
noHeader,
|
||||||
isMobile,
|
isMobile,
|
||||||
mobilePanelId,
|
mobilePanelId,
|
||||||
@@ -125,6 +130,7 @@ const LayoutNodeRenderer = ({
|
|||||||
registry={registry}
|
registry={registry}
|
||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
|
locked={locked}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
isLastPanel={totalPanels <= 1}
|
isLastPanel={totalPanels <= 1}
|
||||||
onSetApp={onSetApp}
|
onSetApp={onSetApp}
|
||||||
@@ -144,6 +150,7 @@ const LayoutNodeRenderer = ({
|
|||||||
registry={registry}
|
registry={registry}
|
||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
|
locked={locked}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
@@ -171,6 +178,7 @@ const LayoutNodeRenderer = ({
|
|||||||
registry={registry}
|
registry={registry}
|
||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
|
locked={locked}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
@@ -198,6 +206,7 @@ const LayoutNodeRenderer = ({
|
|||||||
registry={registry}
|
registry={registry}
|
||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
|
locked={locked}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
|||||||
|
|
||||||
type WorkspaceViewProps = {
|
type WorkspaceViewProps = {
|
||||||
workspace: WorkspaceState;
|
workspace: WorkspaceState;
|
||||||
|
locked?: boolean;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
root?: string;
|
root?: string;
|
||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
@@ -24,7 +25,7 @@ type WorkspaceViewProps = {
|
|||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
|
export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
|
||||||
const { registry } = useAppRegistry();
|
const { registry } = useAppRegistry();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@@ -178,11 +179,12 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
|
|||||||
registry={registry}
|
registry={registry}
|
||||||
components={components}
|
components={components}
|
||||||
interactive
|
interactive
|
||||||
|
locked={locked}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onSetApp={handleSetApp}
|
onSetApp={locked ? noop : handleSetApp}
|
||||||
onSplit={handleSplit}
|
onSplit={locked ? noop : handleSplit}
|
||||||
onRemove={handleRemove}
|
onRemove={locked ? noop : handleRemove}
|
||||||
onResized={handleResized}
|
onResized={handleResized}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
|||||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||||
|
onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
@@ -72,6 +73,9 @@ export type UserSettings = {
|
|||||||
default: string;
|
default: string;
|
||||||
translateTo: string;
|
translateTo: string;
|
||||||
};
|
};
|
||||||
|
onboarding: {
|
||||||
|
complete: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserState = Record<string, unknown>;
|
export type UserState = Record<string, unknown>;
|
||||||
@@ -103,4 +107,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
default: 'en',
|
default: 'en',
|
||||||
translateTo: 'en',
|
translateTo: 'en',
|
||||||
},
|
},
|
||||||
|
onboarding: {
|
||||||
|
complete: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user