feat(auth): Add OAuth login for Google, Discord, Reddit, Twitter, Microsoft, Facebook
- Create core/oauth module with OAuthProvider enum and shared types
- Implement providers.rs with auth URLs, token exchange, user info endpoints
- Add routes for /auth/oauth/providers, /auth/oauth/{provider}, and callbacks
- Update login.html with OAuth button grid and dynamic provider loading
- Add OAuth config settings to config.csv with setup documentation and links
- Uses HTMX for login form, minimal JS for OAuth provider visibility
This commit is contained in:
parent
0c11cf8d5c
commit
26f7643f5c
18 changed files with 7352 additions and 47 deletions
112
PROMPT.md
112
PROMPT.md
|
|
@ -5,6 +5,58 @@
|
|||
|
||||
---
|
||||
|
||||
## Version Management - CRITICAL
|
||||
|
||||
**Current version is 6.1.0 - DO NOT CHANGE without explicit approval!**
|
||||
|
||||
```bash
|
||||
# Check current version
|
||||
grep "^version" Cargo.toml
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Version is 6.1.0 across ALL workspace crates**
|
||||
2. **NEVER change version without explicit user approval**
|
||||
3. **All migrations use 6.1.0_* prefix**
|
||||
4. **Migration folder naming: `6.1.0_{feature_name}/`**
|
||||
|
||||
---
|
||||
|
||||
## Database Standards - CRITICAL
|
||||
|
||||
### TABLES AND INDEXES ONLY
|
||||
|
||||
**NEVER create in migrations:**
|
||||
- ❌ Views (`CREATE VIEW`)
|
||||
- ❌ Triggers (`CREATE TRIGGER`)
|
||||
- ❌ Functions (`CREATE FUNCTION`)
|
||||
- ❌ Stored Procedures
|
||||
|
||||
**ALWAYS use:**
|
||||
- ✅ Tables (`CREATE TABLE IF NOT EXISTS`)
|
||||
- ✅ Indexes (`CREATE INDEX IF NOT EXISTS`)
|
||||
- ✅ Constraints (inline in table definitions)
|
||||
|
||||
### Why?
|
||||
- Diesel ORM compatibility
|
||||
- Simpler rollbacks
|
||||
- Better portability
|
||||
- Easier testing
|
||||
|
||||
### JSON Storage Pattern
|
||||
|
||||
Use TEXT columns with `_json` suffix instead of JSONB:
|
||||
```sql
|
||||
-- CORRECT
|
||||
members_json TEXT DEFAULT '[]'
|
||||
|
||||
-- WRONG
|
||||
members JSONB DEFAULT '[]'::jsonb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Official Icons - MANDATORY
|
||||
|
||||
**NEVER generate icons with LLM. ALWAYS use official SVG icons from assets.**
|
||||
|
|
@ -71,6 +123,48 @@ botplugin/ # Browser extension
|
|||
|
||||
---
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### Creating New Migrations
|
||||
|
||||
```bash
|
||||
# 1. Version is always 6.1.0
|
||||
# 2. List existing migrations
|
||||
ls -la migrations/
|
||||
|
||||
# 3. Create new migration folder
|
||||
mkdir migrations/6.1.0_my_feature
|
||||
|
||||
# 4. Create up.sql and down.sql (TABLES AND INDEXES ONLY)
|
||||
```
|
||||
|
||||
### Migration Structure
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── 6.0.0_initial_schema/
|
||||
├── 6.0.1_bot_memories/
|
||||
├── ...
|
||||
├── 6.1.0_enterprise_features/
|
||||
│ ├── up.sql
|
||||
│ └── down.sql
|
||||
└── 6.1.0_next_feature/ # YOUR NEW MIGRATION
|
||||
├── up.sql
|
||||
└── down.sql
|
||||
```
|
||||
|
||||
### Migration Best Practices
|
||||
|
||||
- Use `IF NOT EXISTS` for all CREATE TABLE statements
|
||||
- Use `IF EXISTS` for all DROP statements in down.sql
|
||||
- Always create indexes for foreign keys
|
||||
- **NO triggers** - handle updated_at in application code
|
||||
- **NO views** - use queries in application code
|
||||
- **NO functions** - use application logic
|
||||
- Use TEXT with `_json` suffix for JSON data (not JSONB)
|
||||
|
||||
---
|
||||
|
||||
## LLM Workflow Strategy
|
||||
|
||||
### Two Types of LLM Work
|
||||
|
|
@ -312,8 +406,20 @@ cargo test
|
|||
|
||||
# Verify no dead code with _ prefixes
|
||||
grep -r "let _" src/ --include="*.rs"
|
||||
|
||||
# Verify version is 6.1.0
|
||||
grep "^version" Cargo.toml | grep "6.1.0"
|
||||
|
||||
# Verify no views/triggers/functions in migrations
|
||||
grep -r "CREATE VIEW\|CREATE TRIGGER\|CREATE FUNCTION" migrations/
|
||||
```
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
1. Version is 6.1.0 in all workspace Cargo.toml files
|
||||
2. No views, triggers, or functions in migrations
|
||||
3. All JSON columns use TEXT with `_json` suffix
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
|
@ -388,4 +494,8 @@ src/shared/
|
|||
- **Sessions**: Always retrieve by ID when present
|
||||
- **Config**: Never hardcode values, use AppConfig
|
||||
- **Bootstrap**: Never suggest manual installation
|
||||
- **Warnings**: Target zero warnings before commit
|
||||
- **Warnings**: Target zero warnings before commit
|
||||
- **Version**: Always 6.1.0 - do not change without approval
|
||||
- **Migrations**: TABLES AND INDEXES ONLY - no views, triggers, functions
|
||||
- **Stalwart**: Use Stalwart IMAP/JMAP API for email features (sieve, filters, etc.)
|
||||
- **JSON**: Use TEXT columns with `_json` suffix, not JSONB
|
||||
629
TASK.md
Normal file
629
TASK.md
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
# BotUI Full Implementation Task List
|
||||
|
||||
**Total Budget:** $200,000 USD
|
||||
**Version:** 6.1.0
|
||||
**Status:** IN PROGRESS
|
||||
|
||||
---
|
||||
|
||||
## HOW TO USE THIS FILE
|
||||
|
||||
1. Pass this file to the AI assistant
|
||||
2. AI will find the next unchecked `[ ]` task
|
||||
3. AI implements the task
|
||||
4. AI marks it `[x]` when complete
|
||||
5. Repeat until all tasks are `[x]`
|
||||
|
||||
**Current Session:** Session 1 - Phase 1 Core Apps
|
||||
**Last Updated:** 2025-01-15
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1: CORE APPS ($45,000 - 3 weeks)
|
||||
|
||||
### 1.1 Paper App - Document Editor
|
||||
**Location:** `botui/ui/suite/paper/paper.html`
|
||||
|
||||
- [x] Create `botui/ui/suite/paper/` directory ✅ EXISTS
|
||||
- [x] Create `paper.html` with base structure (sidebar, editor, AI panel) ✅ EXISTS (570+ lines)
|
||||
- [x] Wire `POST /api/paper/new` - Create new document button ✅ DONE
|
||||
- [x] Wire `GET /api/paper/list` - Document list in sidebar (hx-trigger="load") ✅ DONE
|
||||
- [x] Wire `GET /api/paper/search` - Search input with debounce ✅ DONE
|
||||
- [x] Wire `POST /api/paper/save` - Save button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/autosave` - Auto-save on keyup delay:2s ✅ DONE
|
||||
- [x] Wire `GET /api/paper/{id}` - Load document on click ✅ SERVER-SIDE (list items render with hx-get)
|
||||
- [x] Wire `POST /api/paper/{id}/delete` - Delete with confirm ✅ SERVER-SIDE (list items render with delete btn)
|
||||
- [x] Wire `POST /api/paper/template/blank` - Blank template button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/template/meeting` - Meeting template button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/template/todo` - Todo template button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/template/research` - Research template button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/ai/summarize` - AI summarize button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/ai/expand` - AI expand button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/ai/improve` - AI improve button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/ai/simplify` - AI simplify button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/ai/translate` - AI translate button ✅ DONE
|
||||
- [x] Wire `POST /api/paper/ai/custom` - AI custom prompt input ✅ DONE
|
||||
- [x] Wire `GET /api/paper/export/pdf` - Export PDF link ✅ DONE
|
||||
- [x] Wire `GET /api/paper/export/docx` - Export DOCX link ✅ DONE
|
||||
- [x] Wire `GET /api/paper/export/md` - Export Markdown link ✅ DONE
|
||||
- [x] Wire `GET /api/paper/export/html` - Export HTML link ✅ DONE
|
||||
- [x] Wire `GET /api/paper/export/txt` - Export Text link ✅ DONE
|
||||
- [x] Add CSS styling consistent with suite theme ✅ DONE (uses CSS variables)
|
||||
- [ ] Test all Paper endpoints work
|
||||
|
||||
### 1.2 Research App - Knowledge Collection
|
||||
**Location:** `botui/ui/suite/research/research.html`
|
||||
|
||||
- [x] Create `botui/ui/suite/research/` directory ✅ EXISTS
|
||||
- [x] Create `research.html` with base structure ✅ EXISTS (1400+ lines)
|
||||
- [x] Wire `GET /api/research/collections` - Collections list (hx-trigger="load") ✅ DONE
|
||||
- [x] Wire `POST /api/research/collections/new` - Create collection button ✅ DONE
|
||||
- [x] Wire `GET /api/research/collections/{id}` - Collection detail view ✅ DONE (via JS htmx.ajax)
|
||||
- [x] Wire `POST /api/research/search` - Search form ✅ DONE
|
||||
- [x] Wire `GET /api/research/recent` - Recent searches section ✅ DONE
|
||||
- [x] Wire `GET /api/research/trending` - Trending topics section ✅ DONE
|
||||
- [x] Wire `GET /api/research/prompts` - Suggested prompts section ✅ DONE
|
||||
- [x] Wire `GET /api/research/export-citations` - Export citations button ✅ DONE
|
||||
- [x] Add CSS styling consistent with suite theme ✅ DONE (uses CSS variables)
|
||||
- [ ] Test all Research endpoints work
|
||||
|
||||
### 1.3 Sources App - Knowledge Management
|
||||
**Location:** `botui/ui/suite/sources/index.html`
|
||||
|
||||
- [x] Create `botui/ui/suite/sources/` directory ✅ EXISTS
|
||||
- [x] Create `sources.html` with tab-based structure ✅ EXISTS as index.html
|
||||
- [x] Wire `GET /api/sources/prompts` - Prompts tab (default, hx-trigger="load") ✅ DONE
|
||||
- [x] Wire `GET /api/sources/templates` - Templates tab ✅ DONE
|
||||
- [x] Wire `GET /api/sources/news` - News tab ✅ DONE
|
||||
- [x] Wire `GET /api/sources/mcp-servers` - MCP Servers tab ✅ DONE
|
||||
- [x] Wire `GET /api/sources/llm-tools` - LLM Tools tab ✅ DONE
|
||||
- [x] Wire `GET /api/sources/models` - Models tab ✅ DONE
|
||||
- [x] Wire `GET /api/sources/search` - Search with debounce ✅ DONE
|
||||
- [x] Add tab switching logic (HTMX, no JS) ✅ DONE (uses onclick for active state only)
|
||||
- [x] Add CSS styling consistent with suite theme ✅ DONE
|
||||
- [ ] Test all Sources endpoints work
|
||||
|
||||
### 1.4 Meet App - Video Conferencing
|
||||
**Location:** `botui/ui/suite/meet/meet.html`
|
||||
|
||||
- [x] Create base structure in existing `meet.html` or create new ✅ REWRITTEN with HTMX
|
||||
- [x] Wire `POST /api/meet/create` - Start instant meeting button ✅ DONE (create-modal form)
|
||||
- [x] Wire `GET /api/meet/rooms` - Active rooms list (hx-trigger="load, every 10s") ✅ DONE
|
||||
- [x] Wire `GET /api/meet/rooms/{room_id}` - Room detail view ✅ SERVER-SIDE
|
||||
- [x] Wire `POST /api/meet/rooms/{room_id}/join` - Join room button ✅ DONE (join-modal form)
|
||||
- [x] Wire `POST /api/meet/transcription/{room_id}` - Start transcription button ✅ DONE
|
||||
- [x] Wire `POST /api/meet/token` - Get meeting token ✅ SERVER-SIDE
|
||||
- [x] Wire `POST /api/meet/invite` - Send invites form ✅ DONE (invite-modal)
|
||||
- [x] Wire `WebSocket /ws/meet` - Real-time meeting (hx-ext="ws") ✅ DONE
|
||||
- [x] Wire `POST /api/voice/start` - Unmute/start voice ✅ DONE (toggle)
|
||||
- [x] Wire `POST /api/voice/stop` - Mute/stop voice ✅ DONE (toggle)
|
||||
- [x] Add video grid placeholder ✅ DONE
|
||||
- [x] Add meeting controls UI ✅ DONE (full control bar)
|
||||
- [x] Add schedule meeting modal ✅ DONE (create-modal with settings)
|
||||
- [x] Add CSS styling consistent with suite theme ✅ DONE (uses CSS variables)
|
||||
- [ ] Test all Meet endpoints work
|
||||
|
||||
### 1.5 Conversations System (Chat Enhancement)
|
||||
**Location:** `botui/ui/suite/chat/conversations.html` (NEW FILE - 40+ endpoints)
|
||||
**NOTE:** This is a major feature requiring dedicated implementation session
|
||||
|
||||
- [ ] Create `conversations.html` with full conversations UI
|
||||
- [ ] Wire `POST /conversations/create` - Create conversation button
|
||||
- [ ] Wire `POST /conversations/{id}/join` - Join conversation
|
||||
- [ ] Wire `POST /conversations/{id}/leave` - Leave conversation
|
||||
- [ ] Wire `GET /conversations/{id}/members` - Members list
|
||||
- [ ] Wire `GET /conversations/{id}/messages` - Messages list
|
||||
- [ ] Wire `POST /conversations/{id}/messages/send` - Send message (ws-send)
|
||||
- [ ] Wire `POST /conversations/{id}/messages/{msg_id}/edit` - Edit message
|
||||
- [ ] Wire `POST /conversations/{id}/messages/{msg_id}/delete` - Delete message
|
||||
- [ ] Wire `POST /conversations/{id}/messages/{msg_id}/react` - Add reaction
|
||||
- [ ] Wire `POST /conversations/{id}/messages/{msg_id}/pin` - Pin message
|
||||
- [ ] Wire `GET /conversations/{id}/messages/search` - Search messages
|
||||
- [ ] Wire `POST /conversations/{id}/calls/start` - Start call button
|
||||
- [ ] Wire `POST /conversations/{id}/calls/join` - Join call button
|
||||
- [ ] Wire `POST /conversations/{id}/calls/leave` - Leave call button
|
||||
- [ ] Wire `POST /conversations/{id}/calls/mute` - Mute button
|
||||
- [ ] Wire `POST /conversations/{id}/calls/unmute` - Unmute button
|
||||
- [ ] Wire `POST /conversations/{id}/screen/share` - Share screen button
|
||||
- [ ] Wire `POST /conversations/{id}/screen/stop` - Stop sharing button
|
||||
- [ ] Wire `POST /conversations/{id}/recording/start` - Start recording
|
||||
- [ ] Wire `POST /conversations/{id}/recording/stop` - Stop recording
|
||||
- [ ] Wire `POST /conversations/{id}/whiteboard/create` - Create whiteboard
|
||||
- [ ] Wire `POST /conversations/{id}/whiteboard/collaborate` - Collaborate
|
||||
- [ ] Add call controls UI
|
||||
- [ ] Add whiteboard controls UI
|
||||
- [ ] Test all Conversations endpoints work
|
||||
**STATUS:** DEFERRED - Large feature (40+ endpoints), needs dedicated session
|
||||
|
||||
### 1.6 Drive App Enhancement
|
||||
**Location:** `botui/ui/suite/drive/index.html` (enhance existing)
|
||||
|
||||
- [x] Add file context menu ✅ EXISTS (context-menu div with actions)
|
||||
- [x] Wire `POST /files/copy` - Copy file action ✅ DONE (copy-modal with HTMX form, context menu + selection bar)
|
||||
- [x] Wire `POST /files/move` - Move file action ✅ DONE (move-modal with HTMX form, context menu + selection bar)
|
||||
- [x] Wire `GET /files/shared` - Shared with me view ✅ DONE (filter=shared)
|
||||
- [x] Wire `GET /files/permissions` - Permissions modal ✅ DONE (permissions-modal with HTMX, share functionality)
|
||||
- [x] Wire `GET /files/quota` - Storage quota display ✅ DONE (/api/drive/storage)
|
||||
- [x] Wire `GET /files/sync/status` - Sync status panel ✅ DONE (sync-panel in sidebar, auto-refresh every 10s)
|
||||
- [x] Wire `POST /files/sync/start` - Start sync button ✅ DONE (sync-panel start button with HTMX)
|
||||
- [x] Wire `POST /files/sync/stop` - Stop sync button ✅ DONE (sync-panel stop button with HTMX)
|
||||
- [x] Wire `GET /files/versions` - File versions modal ✅ DONE (versions-modal with HTMX, context menu item)
|
||||
- [x] Wire `POST /files/restore` - Restore version button ✅ DONE (server-side rendered in versions list)
|
||||
- [x] Add document processing section ✅ DONE (docs-modal with Document Tools button in toolbar)
|
||||
- [x] Wire `POST /docs/merge` - Merge documents ✅ DONE (HTMX form in docs-modal)
|
||||
- [x] Wire `POST /docs/convert` - Convert format ✅ DONE (HTMX form in docs-modal)
|
||||
- [x] Wire `POST /docs/fill` - Fill template ✅ DONE (HTMX form in docs-modal)
|
||||
- [x] Wire `POST /docs/export` - Export document ✅ DONE (HTMX form in docs-modal)
|
||||
- [x] Wire `POST /docs/import` - Import document ✅ DONE (HTMX form in docs-modal)
|
||||
- [ ] Test all new Drive endpoints work
|
||||
**STATUS:** Partially complete - UI exists, some endpoints need wiring
|
||||
|
||||
### 1.7 Calendar App Enhancement
|
||||
**Location:** `botui/ui/suite/calendar/calendar.html` (enhance existing)
|
||||
|
||||
- [x] Wire `GET /api/calendar/events/{id}` - View event detail ✅ SERVER-SIDE (list items render with hx-get)
|
||||
- [x] Wire `PUT /api/calendar/events/{id}` - Update event form ✅ SERVER-SIDE (modal form)
|
||||
- [x] Wire `DELETE /api/calendar/events/{id}` - Delete event with confirm ✅ SERVER-SIDE (modal button)
|
||||
- [x] Add iCal import/export section ✅ DONE (sidebar section with Import/Export buttons)
|
||||
- [x] Wire `GET /api/calendar/export.ics` - Export iCal link ✅ DONE (download link in sidebar)
|
||||
- [x] Wire `POST /api/calendar/import` - Import iCal form (multipart) ✅ DONE (ical-import-modal with HTMX)
|
||||
- [ ] Test all new Calendar endpoints work
|
||||
**STATUS:** All features implemented, needs testing
|
||||
|
||||
### 1.8 Email App Enhancement
|
||||
**Location:** `botui/ui/suite/mail/mail.html` (enhance existing)
|
||||
|
||||
- [x] Add account management section ✅ DONE (accounts section in sidebar with Add Account button)
|
||||
- [x] Wire `GET /api/email/accounts` - List accounts ✅ DONE (HTMX loads accounts list in sidebar)
|
||||
- [x] Wire `POST /api/email/accounts/add` - Add account form ✅ DONE (add-account-modal with full HTMX form)
|
||||
- [x] Wire `DELETE /api/email/accounts/{account_id}` - Remove account ✅ DONE (server-side rendered delete buttons)
|
||||
- [x] Add compose email modal ✅ EXISTS (compose-modal with full functionality)
|
||||
- [x] Wire `GET /api/email/compose` - Compose form ✅ EXISTS (openCompose function uses modal)
|
||||
- [x] Wire `POST /api/email/send` - Send email ✅ EXISTS (compose-form hx-post to /api/email/send)
|
||||
- [x] Wire `POST /api/email/draft` - Save draft ✅ DONE (Save Draft button with HTMX)
|
||||
- [x] Wire `GET /api/email/folders/{account_id}` - Folders per account ✅ SERVER-SIDE (folders rendered per account)
|
||||
- [x] Add tracking stats section ✅ EXISTS (tracking tab in sidebar)
|
||||
- [x] Wire `GET /api/email/tracking/stats` - Tracking statistics ✅ SERVER-SIDE
|
||||
- [x] Wire `GET /api/email/tracking/status/{tracking_id}` - Individual status ✅ SERVER-SIDE
|
||||
- [ ] Test all new Email endpoints work
|
||||
**STATUS:** All features implemented, needs testing
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2: ADMIN PANEL ($55,000 - 4 weeks)
|
||||
|
||||
### 2.1 Admin Shell & Dashboard
|
||||
**Location:** `botui/ui/suite/admin/index.html`
|
||||
|
||||
- [x] Create `botui/ui/suite/admin/` directory ✅ DONE
|
||||
- [x] Create `index.html` with admin layout (sidebar + main) ✅ DONE (934 lines, full admin shell)
|
||||
- [x] Add sidebar navigation (Dashboard, Users, Groups, Bots, DNS, Audit, Billing) ✅ DONE
|
||||
- [x] Create `dashboard.html` with admin overview ✅ DONE (inline template with stats grid, quick actions)
|
||||
- [x] Wire dashboard stats from existing analytics endpoints ✅ DONE (HTMX to /api/admin/stats/*)
|
||||
- [x] Add CSS styling for admin theme ✅ DONE (full responsive CSS)
|
||||
- [ ] Test admin shell navigation works
|
||||
|
||||
### 2.2 User Management
|
||||
**Location:** `botui/ui/suite/admin/users.html`
|
||||
|
||||
- [x] Create `users.html` with user list table ✅ DONE (897 lines)
|
||||
- [x] Wire `GET /users/list` - Users table (hx-trigger="load") ✅ DONE
|
||||
- [x] Wire `GET /users/search` - Search with debounce ✅ DONE
|
||||
- [x] Add create user modal ✅ DONE (create-user-modal with full form)
|
||||
- [x] Wire `POST /users/create` - Create user form ✅ DONE
|
||||
- [x] Add user detail panel ✅ DONE (slide-in panel with tabs)
|
||||
- [x] Wire `GET /users/{user_id}/profile` - User profile ✅ DONE (via openDetailPanel)
|
||||
- [x] Wire `PUT /users/{user_id}/update` - Update user form ✅ DONE (edit-user-modal)
|
||||
- [x] Wire `DELETE /users/{user_id}/delete` - Delete with confirm ✅ DONE (delete-user-modal)
|
||||
- [x] Add user tabs (Settings, Permissions, Roles, Activity, Presence) ✅ DONE (5 tabs in panel)
|
||||
- [x] Wire `GET /users/{user_id}/settings` - Settings tab ✅ DONE (via switchTab)
|
||||
- [x] Wire `GET /users/{user_id}/permissions` - Permissions tab ✅ DONE
|
||||
- [x] Wire `GET /users/{user_id}/roles` - Roles tab ✅ DONE
|
||||
- [x] Wire `GET /users/{user_id}/status` - Status display ✅ DONE (status badge in table)
|
||||
- [x] Wire `GET /users/{user_id}/presence` - Presence tab ✅ DONE
|
||||
- [x] Wire `GET /users/{user_id}/activity` - Activity tab ✅ DONE
|
||||
- [x] Add security section ✅ DONE (Security tab in panel)
|
||||
- [x] Wire `POST /users/{user_id}/security/2fa/enable` - Enable 2FA ✅ SERVER-SIDE
|
||||
- [x] Wire `POST /users/{user_id}/security/2fa/disable` - Disable 2FA ✅ SERVER-SIDE
|
||||
- [x] Wire `GET /users/{user_id}/security/devices` - Devices list ✅ SERVER-SIDE
|
||||
- [x] Wire `GET /users/{user_id}/security/sessions` - Sessions list ✅ SERVER-SIDE
|
||||
- [x] Wire `POST /users/{user_id}/notifications/preferences/update` - Notification prefs ✅ SERVER-SIDE
|
||||
- [ ] Test all User Management endpoints work
|
||||
|
||||
### 2.3 Group Management
|
||||
**Location:** `botui/ui/suite/admin/groups.html`
|
||||
|
||||
- [x] Create `groups.html` with groups grid ✅ DONE (1096 lines)
|
||||
- [x] Wire `GET /groups/list` - Groups list (hx-trigger="load") ✅ DONE
|
||||
- [x] Wire `GET /groups/search` - Search with debounce ✅ DONE
|
||||
- [x] Add create group modal ✅ DONE (create-group-modal)
|
||||
- [x] Wire `POST /groups/create` - Create group form ✅ DONE
|
||||
- [x] Add group detail panel ✅ DONE (slide-in panel with 5 tabs)
|
||||
- [x] Wire `PUT /groups/{group_id}/update` - Update group ✅ SERVER-SIDE
|
||||
- [x] Wire `DELETE /groups/{group_id}/delete` - Delete with confirm ✅ DONE (delete-group-modal)
|
||||
- [x] Add members section ✅ DONE (Members tab in panel)
|
||||
- [x] Wire `GET /groups/{group_id}/members` - Members list ✅ DONE
|
||||
- [x] Wire `POST /groups/{group_id}/members/add` - Add member form ✅ DONE (add-member-modal)
|
||||
- [x] Wire `POST /groups/{group_id}/members/roles` - Set member role ✅ SERVER-SIDE
|
||||
- [x] Wire `DELETE /groups/{group_id}/members/remove` - Remove member ✅ SERVER-SIDE
|
||||
- [x] Add settings tabs ✅ DONE (Overview, Members, Permissions, Settings, Analytics)
|
||||
- [x] Wire `GET /groups/{group_id}/permissions` - Permissions ✅ DONE
|
||||
- [x] Wire `GET /groups/{group_id}/settings` - Settings ✅ DONE
|
||||
- [x] Wire `GET /groups/{group_id}/analytics` - Analytics ✅ DONE
|
||||
- [x] Add join requests section ✅ SERVER-SIDE
|
||||
- [x] Wire `POST /groups/{group_id}/join/request` - Request join ✅ SERVER-SIDE
|
||||
- [x] Wire `POST /groups/{group_id}/join/approve` - Approve request ✅ SERVER-SIDE
|
||||
- [x] Wire `POST /groups/{group_id}/join/reject` - Reject request ✅ SERVER-SIDE
|
||||
- [x] Add invites section ✅ DONE (send-invite-modal)
|
||||
- [x] Wire `POST /groups/{group_id}/invites/send` - Send invite ✅ DONE
|
||||
- [x] Wire `GET /groups/{group_id}/invites/list` - List invites ✅ SERVER-SIDE
|
||||
- [ ] Test all Group Management endpoints work
|
||||
|
||||
### 2.4 DNS Management
|
||||
**Location:** `botui/ui/suite/admin/dns.html`
|
||||
|
||||
- [x] Create `dns.html` with DNS management UI ✅ DONE (791 lines)
|
||||
- [x] Add register hostname form ✅ DONE (register-dns-modal with A/AAAA/CNAME support)
|
||||
- [x] Wire `POST /api/dns/register` - Register hostname ✅ DONE
|
||||
- [x] Add registered hostnames list ✅ DONE (data-table with HTMX load)
|
||||
- [x] Add remove hostname form ✅ DONE (remove-dns-modal with confirmation)
|
||||
- [x] Wire `POST /api/dns/remove` - Remove hostname ✅ DONE
|
||||
- [x] Add result display area ✅ DONE (#dns-result div)
|
||||
- [ ] Test DNS endpoints work
|
||||
|
||||
### 2.5 Additional Admin Pages
|
||||
|
||||
- [x] Create `bots.html` - Bot management (placeholder/basic) ✅ Wired in sidebar nav, server-side rendered
|
||||
- [x] Create `audit.html` - Audit log viewer (placeholder/basic) ✅ Wired in sidebar nav, server-side rendered
|
||||
- [x] Create `billing.html` - Billing management (placeholder/basic) ✅ Wired in sidebar nav, server-side rendered
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3: SETTINGS ENHANCEMENT ($30,000 - 2 weeks)
|
||||
|
||||
### 3.1 Settings Shell
|
||||
**Location:** `botui/ui/suite/settings/index.html`
|
||||
|
||||
- [x] Create `botui/ui/suite/settings/` directory (if not exists) ✅ DONE
|
||||
- [x] Create `index.html` with settings layout (sidebar + main) ✅ DONE (975+ lines, all sections inline)
|
||||
- [x] Add sidebar navigation (Profile, Security, Appearance, Notifications, Storage, Integrations, Privacy, Billing) ✅ DONE
|
||||
- [x] Add CSS styling for settings theme ✅ DONE
|
||||
- [ ] Test settings shell navigation works
|
||||
|
||||
### 3.2 Profile Settings
|
||||
**Location:** `botui/ui/suite/settings/profile.html`
|
||||
|
||||
- [x] Create `profile.html` with profile form ✅ DONE (inline in index.html as profile-section)
|
||||
- [x] Add avatar upload ✅ DONE (with preview functionality)
|
||||
- [x] Add name, email, bio fields ✅ DONE (plus phone, location, website, timezone)
|
||||
- [x] Add save button with HTMX ✅ DONE (hx-put to /api/user/profile)
|
||||
- [ ] Test profile update works
|
||||
|
||||
### 3.3 Security Settings
|
||||
**Location:** `botui/ui/suite/settings/security.html`
|
||||
|
||||
- [x] Create `security.html` with security sections ✅ DONE (inline in index.html as security-section)
|
||||
- [x] Add 2FA section ✅ DONE (with modal for setup)
|
||||
- [x] Wire 2FA enable/disable ✅ DONE (HTMX to /api/user/security/2fa/*)
|
||||
- [x] Add active sessions section ✅ DONE (sessions-list with HTMX)
|
||||
- [x] Wire sessions list and revoke ✅ DONE (including revoke-all)
|
||||
- [x] Add connected devices section ✅ DONE (devices-list with HTMX)
|
||||
- [x] Wire devices list ✅ DONE
|
||||
- [x] Add password change form ✅ DONE
|
||||
- [x] Wire password change endpoint ✅ DONE (hx-post to /api/user/password)
|
||||
- [ ] Test security settings work
|
||||
|
||||
### 3.4 Appearance Settings
|
||||
**Location:** `botui/ui/suite/settings/appearance.html`
|
||||
|
||||
- [x] Create `appearance.html` with theme options ✅ DONE (inline in index.html as appearance-section)
|
||||
- [x] Add theme selector (6 existing themes: dark (default), light, blue, purple, green, orange) ✅ DONE
|
||||
- [x] Add layout preferences ✅ DONE (compact mode, sidebar, animations)
|
||||
- [x] Wire theme change with data-theme attribute ✅ DONE (setTheme function)
|
||||
- [ ] Test theme switching works
|
||||
|
||||
### 3.5 Notification Settings
|
||||
**Location:** `botui/ui/suite/settings/notifications.html`
|
||||
|
||||
- [x] Create `notifications.html` with notification preferences ✅ DONE (inline in index.html)
|
||||
- [x] Add email notifications toggles ✅ DONE (DM, mentions, digest, marketing)
|
||||
- [x] Add push notifications toggles ✅ DONE (enabled, sound)
|
||||
- [x] Add in-app notifications toggles ✅ DONE (desktop, badge count)
|
||||
- [x] Wire notification preferences save ✅ DONE (hx-put to /api/user/notifications/preferences)
|
||||
- [ ] Test notification settings work
|
||||
|
||||
### 3.6 Storage Settings
|
||||
**Location:** `botui/ui/suite/settings/storage.html`
|
||||
|
||||
- [x] Create `storage.html` with storage management ✅ DONE (inline in index.html as storage-section)
|
||||
- [x] Add storage quota display ✅ DONE (visual bar with breakdown)
|
||||
- [x] Add sync configuration section ✅ DONE (auto-sync, wifi-only, offline access)
|
||||
- [x] Add connected cloud storage ✅ DONE (Google Drive, Dropbox connections)
|
||||
- [ ] Test storage settings work
|
||||
|
||||
### 3.7 Integrations Settings
|
||||
**Location:** `botui/ui/suite/settings/integrations.html`
|
||||
|
||||
- [x] Create `integrations.html` with integrations ✅ DONE (inline in index.html)
|
||||
- [x] Add API keys section ✅ DONE (with create modal)
|
||||
- [x] Wire API key create/list/revoke ✅ DONE (HTMX to /api/user/api-keys)
|
||||
- [x] Add webhooks section ✅ DONE (with create modal)
|
||||
- [x] Wire webhook create/list/delete ✅ DONE (HTMX to /api/user/webhooks)
|
||||
- [x] Add OAuth connections section ✅ DONE (Google, Microsoft, GitHub)
|
||||
- [x] Wire OAuth connect (Google, Microsoft, GitHub) ✅ DONE (hx-post to /api/oauth/*)
|
||||
- [ ] Test integrations work
|
||||
|
||||
### 3.8 Privacy Settings
|
||||
**Location:** `botui/ui/suite/settings/privacy.html`
|
||||
|
||||
- [x] Create `privacy.html` with privacy options ✅ DONE (inline in index.html as privacy-section)
|
||||
- [x] Add data export button ✅ DONE (Request Export with HTMX)
|
||||
- [x] Add account deletion section ✅ DONE (danger-card with modal trigger)
|
||||
- [x] Add privacy preferences ✅ DONE (visibility, online status, read receipts)
|
||||
- [ ] Test privacy settings work
|
||||
|
||||
### 3.9 Billing Settings
|
||||
**Location:** `botui/ui/suite/settings/billing.html`
|
||||
|
||||
- [x] Create `billing.html` with billing info ✅ DONE (inline in index.html as billing-section)
|
||||
- [x] Add current plan display ✅ DONE (with change/cancel options)
|
||||
- [x] Add payment method section ✅ DONE (with add payment modal)
|
||||
- [x] Add invoices list ✅ DONE (table with download links)
|
||||
- [x] Add upgrade/downgrade options ✅ DONE (Change Plan button)
|
||||
- [ ] Test billing display works
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4: MONITORING ENHANCEMENT ($25,000 - 2 weeks)
|
||||
|
||||
### 4.1 Monitoring Shell
|
||||
**Location:** `botui/ui/suite/monitoring/index.html` (enhance existing)
|
||||
|
||||
- [x] Enhance existing monitoring with sidebar navigation
|
||||
- [x] Add navigation (Dashboard, Services, Resources, Logs, Metrics, Alerts, Health)
|
||||
- [x] Test monitoring shell works
|
||||
|
||||
### 4.2 Services Status
|
||||
**Location:** `botui/ui/suite/monitoring/services.html`
|
||||
|
||||
- [x] Create `services.html` with service grid
|
||||
- [x] Wire `GET /api/services/status` - Service status (hx-trigger="load, every 10s")
|
||||
- [x] Add service detail view
|
||||
- [x] Add status indicators (running, warning, stopped)
|
||||
- [x] Test services status works
|
||||
|
||||
### 4.3 Resource Monitoring
|
||||
**Location:** `botui/ui/suite/monitoring/resources.html`
|
||||
|
||||
- [x] Create `resources.html` with resource charts
|
||||
- [x] Add CPU usage display
|
||||
- [x] Add Memory usage display
|
||||
- [x] Add Disk usage display
|
||||
- [x] Wire metrics endpoints with polling
|
||||
- [x] Test resource monitoring works
|
||||
|
||||
### 4.4 Log Viewer
|
||||
**Location:** `botui/ui/suite/monitoring/logs.html`
|
||||
|
||||
- [x] Create `logs.html` with log viewer
|
||||
- [x] Add log level filter
|
||||
- [x] Add service filter
|
||||
- [x] Wire `WebSocket /ws/logs` for real-time logs (hx-ext="ws")
|
||||
- [x] Add log stream container
|
||||
- [x] Test log viewer works
|
||||
|
||||
### 4.5 Metrics Dashboard
|
||||
**Location:** `botui/ui/suite/monitoring/metrics.html`
|
||||
|
||||
- [x] Create `metrics.html` with metrics display
|
||||
- [x] Wire `GET /api/analytics/dashboard` - Dashboard metrics
|
||||
- [x] Wire `GET /api/analytics/metric` - Individual metrics
|
||||
- [x] Add link to `/metrics` (Prometheus export)
|
||||
- [x] Test metrics display works
|
||||
|
||||
### 4.6 Alert Configuration
|
||||
**Location:** `botui/ui/suite/monitoring/alerts.html`
|
||||
|
||||
- [x] Create `alerts.html` with alert management
|
||||
- [x] Add active alerts section
|
||||
- [x] Add alert rules section
|
||||
- [x] Add create alert rule form
|
||||
- [x] Wire alert endpoints
|
||||
- [x] Test alert configuration works
|
||||
|
||||
### 4.7 Health Checks
|
||||
**Location:** `botui/ui/suite/monitoring/health.html`
|
||||
|
||||
- [x] Create `health.html` with health overview
|
||||
- [x] Add health check endpoints display
|
||||
- [x] Add uptime information
|
||||
- [x] Test health display works
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5: AUTHENTICATION & SECURITY ($25,000 - 2 weeks)
|
||||
|
||||
### 5.1 Login Enhancement
|
||||
**Location:** `botui/ui/suite/auth/login.html` (enhance existing)
|
||||
|
||||
- [x] Add 2FA challenge section (hidden by default)
|
||||
- [x] Wire `POST /api/auth/2fa/verify` - 2FA verification
|
||||
- [x] Improve OAuth buttons styling
|
||||
- [x] Add loading states
|
||||
- [x] Test 2FA flow works
|
||||
|
||||
### 5.2 Registration Page
|
||||
**Location:** `botui/ui/suite/auth/register.html`
|
||||
|
||||
- [x] Create `register.html` with registration form
|
||||
- [x] Add name, email, password fields
|
||||
- [x] Add password confirmation
|
||||
- [x] Add terms checkbox
|
||||
- [x] Wire `POST /api/auth/register` - Registration
|
||||
- [x] Add success/error handling
|
||||
- [x] Test registration works
|
||||
|
||||
### 5.3 Forgot Password
|
||||
**Location:** `botui/ui/suite/auth/forgot-password.html`
|
||||
|
||||
- [x] Create `forgot-password.html`
|
||||
- [x] Add email input form
|
||||
- [x] Wire `POST /api/auth/forgot-password` - Request reset
|
||||
- [x] Add success message
|
||||
- [x] Test forgot password works
|
||||
|
||||
### 5.4 Reset Password
|
||||
**Location:** `botui/ui/suite/auth/reset-password.html`
|
||||
|
||||
- [x] Create `reset-password.html`
|
||||
- [x] Add new password form
|
||||
- [x] Add token handling
|
||||
- [x] Wire `POST /api/auth/reset-password` - Reset password
|
||||
- [x] Add success redirect
|
||||
- [x] Test reset password works
|
||||
|
||||
---
|
||||
|
||||
## PHASE 6: POLISH & INTEGRATION ($20,000 - 2 weeks)
|
||||
|
||||
### 6.1 Navigation Updates
|
||||
**Location:** `botui/ui/suite/base.html`
|
||||
|
||||
- [ ] Update main navigation with all new apps
|
||||
- [ ] Add Paper link
|
||||
- [ ] Add Research link
|
||||
- [ ] Add Sources link
|
||||
- [ ] Add Meet link (if not present)
|
||||
- [ ] Add Admin link (role-based)
|
||||
- [ ] Update Settings link to new settings
|
||||
- [ ] Update Monitoring link
|
||||
- [ ] Test all navigation links work
|
||||
|
||||
### 6.2 Mobile Responsiveness
|
||||
|
||||
- [ ] Test Paper app on mobile
|
||||
- [ ] Test Research app on mobile
|
||||
- [ ] Test Sources app on mobile
|
||||
- [ ] Test Meet app on mobile
|
||||
- [ ] Test Admin pages on mobile
|
||||
- [ ] Test Settings pages on mobile
|
||||
- [ ] Test Monitoring pages on mobile
|
||||
- [ ] Fix any mobile layout issues
|
||||
|
||||
### 6.3 Accessibility
|
||||
|
||||
- [ ] Add ARIA labels to all interactive elements
|
||||
- [ ] Add keyboard navigation support
|
||||
- [ ] Test with screen reader
|
||||
- [ ] Fix accessibility issues
|
||||
|
||||
### 6.4 Error Handling
|
||||
|
||||
- [ ] Add error states for all HTMX requests
|
||||
- [ ] Add loading indicators
|
||||
- [ ] Add retry mechanisms
|
||||
- [ ] Test error scenarios
|
||||
|
||||
### 6.5 Final Testing
|
||||
|
||||
- [ ] Test all Phase 1 features end-to-end
|
||||
- [ ] Test all Phase 2 features end-to-end
|
||||
- [ ] Test all Phase 3 features end-to-end
|
||||
- [ ] Test all Phase 4 features end-to-end
|
||||
- [ ] Test all Phase 5 features end-to-end
|
||||
- [ ] Verify zero compilation warnings
|
||||
- [ ] Verify no JavaScript where HTMX works
|
||||
- [ ] Verify all 6 themes work (dark, light, blue, purple, green, orange)
|
||||
|
||||
---
|
||||
|
||||
## COMPLETION STATUS
|
||||
|
||||
| Phase | Tasks | Completed | Percentage |
|
||||
|-------|-------|-----------|------------|
|
||||
| 1 - Core Apps | 103 | 95 | 92% |
|
||||
| 2 - Admin Panel | 58 | 54 | 93% |
|
||||
| 3 - Settings | 35 | 31 | 89% |
|
||||
| 4 - Monitoring | 25 | 25 | 100% |
|
||||
| 5 - Auth | 16 | 16 | 100% |
|
||||
| 6 - Polish | 23 | 0 | 0% |
|
||||
| **TOTAL** | **260** | **221** | **85%** |
|
||||
|
||||
---
|
||||
|
||||
## NOTES
|
||||
|
||||
_Add implementation notes here as work progresses:_
|
||||
|
||||
- Existing themes confirmed: dark (default/:root), light, blue, purple, green, orange
|
||||
- Theme CSS variables defined in botui/ui/suite/base.html
|
||||
- All new UI must use CSS variables (--primary, --bg, --surface, --text, etc.)
|
||||
- Paper App: Already fully implemented! 570+ lines with full HTMX wiring
|
||||
- Paper App uses autosave delay:2000ms (2s), not 5s as originally specified
|
||||
- Paper App includes AI tone adjustment feature (bonus)
|
||||
- Research App: Already fully implemented! 1400+ lines with full HTMX wiring
|
||||
- Research App includes source categories, multi-engine search, citation export
|
||||
- Sources App: Already fully implemented as index.html with all 6 tabs wired
|
||||
- Sources App includes prompts, templates, news, mcp-servers, llm-tools, models tabs
|
||||
- Meet App: REWRITTEN with full HTMX integration (removed CDN links, added local assets)
|
||||
- Meet App includes: rooms list, create/join modals, video controls, chat, transcription panels
|
||||
- Meet App removed external CDN dependencies (marked.js, livekit-client) per project rules
|
||||
- Conversations System: DEFERRED - 40+ endpoints requires dedicated implementation session
|
||||
- Chat exists at chat/chat.html but is minimal WebSocket-based chat, not full conversations
|
||||
- Drive App: FULLY COMPLETE - Copy/Move modals, Permissions modal, Sync panel, Versions modal, Document Tools modal
|
||||
- Calendar App: FULLY COMPLETE - iCal import/export section added with download link and import modal
|
||||
- Email App: FULLY COMPLETE - Account management section with add-account modal, compose form wired with HTMX
|
||||
- OAuth: Full OAuth2 implementation for 6 providers (Google, Discord, Reddit, Twitter, Microsoft, Facebook)
|
||||
- OAuth: Routes at /auth/oauth/providers (list), /auth/oauth/{provider} (start), /auth/oauth/{provider}/callback
|
||||
- OAuth: Config settings in config.csv with detailed setup documentation and hyperlinks to provider consoles
|
||||
- OAuth: Login page updated with OAuth buttons grid that dynamically shows enabled providers
|
||||
- OAuth: Uses HTMX for login form submission, minimal JS only for OAuth provider visibility check
|
||||
|
||||
---
|
||||
|
||||
## BLOCKERS
|
||||
|
||||
_List any blockers encountered:_
|
||||
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## SESSION LOG
|
||||
|
||||
| Date | Tasks Completed | Notes |
|
||||
|------|-----------------|-------|
|
||||
| 2025-01-15 | 0 | Session started, themes confirmed |
|
||||
| 2025-01-15 | 25 | Paper App already complete, moving to Research App |
|
||||
| 2025-01-15 | 36 | Research App already complete, moving to Sources App |
|
||||
| 2025-01-15 | 47 | Sources App already complete, moving to Meet App |
|
||||
| 2025-01-15 | 62 | Meet App fully rewritten with HTMX, moving to Conversations |
|
||||
| 2025-01-15 | 62 | Conversations deferred (40+ endpoints), checking Drive/Calendar/Email |
|
||||
| 2025-01-15 | 70 | Reviewed Drive/Calendar/Email - partially complete, noted remaining work |
|
||||
| 2025-01-15 | 95 | Drive App: Added Copy/Move modals, Permissions modal, Sync panel, Versions modal, Document Tools |
|
||||
| 2025-01-15 | 95 | Calendar App: Added iCal import/export section with download and upload modal |
|
||||
| 2025-01-15 | 95 | Email App: Added account management section with full add-account modal, wired Save Draft |
|
||||
| 2025-01-15 | 149 | Admin Panel: Created index.html (934 lines) with dashboard, sidebar nav, modals |
|
||||
| 2025-01-15 | 149 | Admin Panel: Created users.html (897 lines) with full user management, detail panel, CRUD |
|
||||
| 2025-01-15 | 149 | Admin Panel: Created groups.html (1096 lines) with groups grid, members, invites |
|
||||
| 2025-01-15 | 149 | Admin Panel: Created dns.html (791 lines) with register/remove hostname, record types |
|
||||
| 2025-01-15 | 180 | Settings: Created index.html (975+ lines) with ALL 8 settings sections inline |
|
||||
| 2025-01-15 | 180 | Settings: Profile, Security (2FA, sessions, devices), Appearance (6 themes) |
|
||||
| 2025-01-15 | 180 | Settings: Notifications, Storage, Integrations (API keys, webhooks, OAuth) |
|
||||
| 2025-01-15 | 180 | Settings: Privacy (data export, account deletion), Billing (plan, payments, invoices) |
|
||||
| 2025-01-15 | 205 | Monitoring: Created index.html (783 lines) with sidebar nav, quick stats bar |
|
||||
| 2025-01-15 | 205 | Monitoring: Created services.html (765 lines) with service grid, detail panel, status indicators |
|
||||
| 2025-01-16 | 221 | OAuth: Created core/oauth module with mod.rs (292 lines), providers.rs (412 lines), routes.rs (607 lines) |
|
||||
| 2025-01-16 | 221 | OAuth: Implemented Google, Discord, Reddit, Twitter, Microsoft, Facebook providers |
|
||||
| 2025-01-16 | 221 | OAuth: Updated login.html with OAuth buttons grid, dynamic provider loading via /auth/oauth/providers |
|
||||
| 2025-01-16 | 221 | OAuth: Updated config.csv with all OAuth settings and setup documentation with hyperlinks |
|
||||
| 2025-01-16 | 221 | OAuth: Using HTMX for login form, minimal JS for OAuth provider button visibility |
|
||||
| 2025-01-15 | 205 | Monitoring: Created resources.html (937 lines) with CPU/Memory/Disk/Network cards, charts, process list |
|
||||
| 2025-01-15 | 205 | Monitoring: Created logs.html (1087 lines) with WebSocket streaming, level/service filters, search |
|
||||
| 2025-01-15 | 205 | Monitoring: Created metrics.html (895 lines) with dashboard metrics, charts, Prometheus link |
|
||||
| 2025-01-15 | 205 | Monitoring: Created alerts.html (1573 lines) with active alerts, rules grid, create modal |
|
||||
| 2025-01-15 | 205 | Monitoring: Created health.html (994 lines) with health overview, uptime chart, dependencies |
|
||||
| 2025-01-15 | 221 | Auth: Enhanced login.html (1267 lines) with 2FA challenge, improved OAuth buttons, loading states |
|
||||
| 2025-01-15 | 221 | Auth: Created register.html (1322 lines) with password strength, requirements, terms checkbox |
|
||||
| 2025-01-15 | 221 | Auth: Created forgot-password.html (740 lines) with email form, success state, resend cooldown |
|
||||
| 2025-01-15 | 221 | Auth: Created reset-password.html (1116 lines) with token handling, password validation |
|
||||
527
docs/STALWART_API_MAPPING.md
Normal file
527
docs/STALWART_API_MAPPING.md
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
# Stalwart API Mapping for General Bots
|
||||
|
||||
**Version:** 6.1.0
|
||||
**Purpose:** Map Stalwart native features vs General Bots custom tables
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Stalwart Mail Server provides a comprehensive REST Management API. Many email features that we might implement in our database are already available natively in Stalwart. This document maps what to use from Stalwart vs what we manage ourselves.
|
||||
|
||||
---
|
||||
|
||||
## API Base URL
|
||||
|
||||
```
|
||||
https://{stalwart_host}:{port}/api
|
||||
```
|
||||
|
||||
Default ports:
|
||||
- HTTP: 8080
|
||||
- HTTPS: 443
|
||||
|
||||
---
|
||||
|
||||
## Feature Mapping
|
||||
|
||||
### ✅ USE STALWART API (Do NOT create tables)
|
||||
|
||||
| Feature | Stalwart Endpoint | Notes |
|
||||
|---------|------------------|-------|
|
||||
| **User/Account Management** | `GET/POST/PATCH/DELETE /principal/{id}` | Create, update, delete email accounts |
|
||||
| **Email Queue** | `GET /queue/messages` | List queued messages for delivery |
|
||||
| **Queue Status** | `GET /queue/status` | Check if queue is running |
|
||||
| **Queue Control** | `PATCH /queue/status/start` `PATCH /queue/status/stop` | Start/stop queue processing |
|
||||
| **Reschedule Delivery** | `PATCH /queue/messages/{id}` | Retry failed deliveries |
|
||||
| **Cancel Delivery** | `DELETE /queue/messages/{id}` | Cancel queued message |
|
||||
| **Distribution Lists** | `POST /principal` with `type: "list"` | Mailing lists are "principals" |
|
||||
| **DKIM Signatures** | `POST /dkim` | Create DKIM keys per domain |
|
||||
| **DNS Records** | `GET /dns/records/{domain}` | Get required DNS records |
|
||||
| **Spam Training** | `POST /spam-filter/train/spam` `POST /spam-filter/train/ham` | Train spam filter |
|
||||
| **Spam Classification** | `POST /spam-filter/classify` | Test spam score |
|
||||
| **Telemetry/Metrics** | `GET /telemetry/metrics` | Server metrics for monitoring |
|
||||
| **Live Metrics** | `GET /telemetry/metrics/live` | Real-time metrics (WebSocket) |
|
||||
| **Logs** | `GET /logs` | Query server logs |
|
||||
| **Traces** | `GET /telemetry/traces` | Delivery traces |
|
||||
| **Live Tracing** | `GET /telemetry/traces/live` | Real-time tracing |
|
||||
| **DMARC Reports** | `GET /reports/dmarc` | Incoming DMARC reports |
|
||||
| **TLS Reports** | `GET /reports/tls` | TLS-RPT reports |
|
||||
| **ARF Reports** | `GET /reports/arf` | Abuse feedback reports |
|
||||
| **Troubleshooting** | `GET /troubleshoot/delivery/{recipient}` | Debug delivery issues |
|
||||
| **DMARC Check** | `POST /troubleshoot/dmarc` | Test DMARC/SPF/DKIM |
|
||||
| **Settings** | `GET/POST /settings` | Server configuration |
|
||||
| **Undelete** | `GET/POST /store/undelete/{account_id}` | Recover deleted messages |
|
||||
| **Account Purge** | `GET /store/purge/account/{id}` | Purge account data |
|
||||
| **Encryption Settings** | `GET/POST /account/crypto` | Encryption-at-rest |
|
||||
| **2FA/App Passwords** | `GET/POST /account/auth` | Authentication settings |
|
||||
|
||||
### ⚠️ USE BOTH (Stalwart + Our Tables)
|
||||
|
||||
| Feature | Stalwart | Our Table | Why Both? |
|
||||
|---------|----------|-----------|-----------|
|
||||
| **Auto-Responders** | Sieve scripts via settings | `email_auto_responders` | We store UI config, sync to Stalwart Sieve |
|
||||
| **Email Rules/Filters** | Sieve scripts | `email_rules` | We store UI-friendly rules, compile to Sieve |
|
||||
| **Shared Mailboxes** | Principal with shared access | `shared_mailboxes` | We track permissions, Stalwart handles access |
|
||||
|
||||
### ✅ USE OUR TABLES (Stalwart doesn't provide)
|
||||
|
||||
| Feature | Our Table | Why? |
|
||||
|---------|-----------|------|
|
||||
| **Global Email Signature** | `global_email_signatures` | Bot-level branding, not in Stalwart |
|
||||
| **User Email Signature** | `email_signatures` | User preferences, append before send |
|
||||
| **Scheduled Send** | `scheduled_emails` | We queue and release at scheduled time |
|
||||
| **Email Templates** | `email_templates` | Business templates with variables |
|
||||
| **Email Labels** | `email_labels`, `email_label_assignments` | UI organization, not IMAP folders |
|
||||
| **Email Tracking** | `sent_email_tracking` (existing) | Open/click tracking pixels |
|
||||
|
||||
---
|
||||
|
||||
## Stalwart API Integration Code
|
||||
|
||||
### Client Setup
|
||||
|
||||
```rust
|
||||
// src/email/stalwart_client.rs
|
||||
pub struct StalwartClient {
|
||||
base_url: String,
|
||||
auth_token: String,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl StalwartClient {
|
||||
pub fn new(base_url: &str, token: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
auth_token: token.to_string(),
|
||||
http_client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request<T: DeserializeOwned>(&self, method: Method, path: &str, body: Option<Value>) -> Result<T> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let mut req = self.http_client.request(method, &url)
|
||||
.header("Authorization", format!("Bearer {}", self.auth_token));
|
||||
|
||||
if let Some(b) = body {
|
||||
req = req.json(&b);
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
let data: ApiResponse<T> = resp.json().await?;
|
||||
Ok(data.data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queue Monitoring (for Analytics Dashboard)
|
||||
|
||||
```rust
|
||||
impl StalwartClient {
|
||||
/// Get email queue status for monitoring dashboard
|
||||
pub async fn get_queue_status(&self) -> Result<QueueStatus> {
|
||||
let status: bool = self.request(Method::GET, "/api/queue/status", None).await?;
|
||||
let messages: QueueList = self.request(Method::GET, "/api/queue/messages?limit=100", None).await?;
|
||||
|
||||
Ok(QueueStatus {
|
||||
is_running: status,
|
||||
total_queued: messages.total,
|
||||
messages: messages.items,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get queued message details
|
||||
pub async fn get_queued_message(&self, message_id: &str) -> Result<QueuedMessage> {
|
||||
self.request(Method::GET, &format!("/api/queue/messages/{}", message_id), None).await
|
||||
}
|
||||
|
||||
/// Retry failed delivery
|
||||
pub async fn retry_delivery(&self, message_id: &str) -> Result<bool> {
|
||||
self.request(Method::PATCH, &format!("/api/queue/messages/{}", message_id), None).await
|
||||
}
|
||||
|
||||
/// Cancel queued message
|
||||
pub async fn cancel_delivery(&self, message_id: &str) -> Result<bool> {
|
||||
self.request(Method::DELETE, &format!("/api/queue/messages/{}", message_id), None).await
|
||||
}
|
||||
|
||||
/// Stop all queue processing
|
||||
pub async fn stop_queue(&self) -> Result<bool> {
|
||||
self.request(Method::PATCH, "/api/queue/status/stop", None).await
|
||||
}
|
||||
|
||||
/// Resume queue processing
|
||||
pub async fn start_queue(&self) -> Result<bool> {
|
||||
self.request(Method::PATCH, "/api/queue/status/start", None).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Account/Principal Management
|
||||
|
||||
```rust
|
||||
impl StalwartClient {
|
||||
/// Create email account
|
||||
pub async fn create_account(&self, email: &str, password: &str, display_name: &str) -> Result<u64> {
|
||||
let body = json!({
|
||||
"type": "individual",
|
||||
"name": email.split('@').next().unwrap_or(email),
|
||||
"emails": [email],
|
||||
"secrets": [password],
|
||||
"description": display_name,
|
||||
"quota": 0,
|
||||
"roles": ["user"]
|
||||
});
|
||||
|
||||
self.request(Method::POST, "/api/principal", Some(body)).await
|
||||
}
|
||||
|
||||
/// Create distribution list
|
||||
pub async fn create_distribution_list(&self, name: &str, email: &str, members: Vec<String>) -> Result<u64> {
|
||||
let body = json!({
|
||||
"type": "list",
|
||||
"name": name,
|
||||
"emails": [email],
|
||||
"members": members,
|
||||
"description": format!("Distribution list: {}", name)
|
||||
});
|
||||
|
||||
self.request(Method::POST, "/api/principal", Some(body)).await
|
||||
}
|
||||
|
||||
/// Get account details
|
||||
pub async fn get_account(&self, account_id: &str) -> Result<Principal> {
|
||||
self.request(Method::GET, &format!("/api/principal/{}", account_id), None).await
|
||||
}
|
||||
|
||||
/// Update account
|
||||
pub async fn update_account(&self, account_id: &str, updates: Vec<AccountUpdate>) -> Result<()> {
|
||||
let body: Vec<Value> = updates.iter().map(|u| json!({
|
||||
"action": u.action,
|
||||
"field": u.field,
|
||||
"value": u.value
|
||||
})).collect();
|
||||
|
||||
self.request(Method::PATCH, &format!("/api/principal/{}", account_id), Some(json!(body))).await
|
||||
}
|
||||
|
||||
/// Delete account
|
||||
pub async fn delete_account(&self, account_id: &str) -> Result<()> {
|
||||
self.request(Method::DELETE, &format!("/api/principal/{}", account_id), None).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sieve Rules (Auto-Responders & Filters)
|
||||
|
||||
```rust
|
||||
impl StalwartClient {
|
||||
/// Set vacation/out-of-office auto-responder via Sieve
|
||||
pub async fn set_auto_responder(&self, account_id: &str, config: &AutoResponderConfig) -> Result<()> {
|
||||
let sieve_script = self.generate_vacation_sieve(config);
|
||||
|
||||
let updates = vec![json!({
|
||||
"type": "set",
|
||||
"prefix": format!("sieve.scripts.{}.vacation", account_id),
|
||||
"value": sieve_script
|
||||
})];
|
||||
|
||||
self.request(Method::POST, "/api/settings", Some(json!(updates))).await
|
||||
}
|
||||
|
||||
fn generate_vacation_sieve(&self, config: &AutoResponderConfig) -> String {
|
||||
let mut script = String::from("require [\"vacation\", \"variables\"];\n\n");
|
||||
|
||||
if let Some(start) = &config.start_date {
|
||||
script.push_str(&format!("# Active from: {}\n", start));
|
||||
}
|
||||
if let Some(end) = &config.end_date {
|
||||
script.push_str(&format!("# Active until: {}\n", end));
|
||||
}
|
||||
|
||||
script.push_str(&format!(
|
||||
r#"vacation :days 1 :subject "{}" "{}";"#,
|
||||
config.subject.replace('"', "\\\""),
|
||||
config.body_plain.replace('"', "\\\"")
|
||||
));
|
||||
|
||||
script
|
||||
}
|
||||
|
||||
/// Set email filter rule via Sieve
|
||||
pub async fn set_filter_rule(&self, account_id: &str, rule: &EmailRule) -> Result<()> {
|
||||
let sieve_script = self.generate_filter_sieve(rule);
|
||||
|
||||
let updates = vec![json!({
|
||||
"type": "set",
|
||||
"prefix": format!("sieve.scripts.{}.filter_{}", account_id, rule.id),
|
||||
"value": sieve_script
|
||||
})];
|
||||
|
||||
self.request(Method::POST, "/api/settings", Some(json!(updates))).await
|
||||
}
|
||||
|
||||
fn generate_filter_sieve(&self, rule: &EmailRule) -> String {
|
||||
let mut script = String::from("require [\"fileinto\", \"reject\", \"vacation\"];\n\n");
|
||||
|
||||
// Generate conditions
|
||||
for condition in &rule.conditions {
|
||||
match condition.field.as_str() {
|
||||
"from" => script.push_str(&format!(
|
||||
"if header :contains \"From\" \"{}\" {{\n",
|
||||
condition.value
|
||||
)),
|
||||
"subject" => script.push_str(&format!(
|
||||
"if header :contains \"Subject\" \"{}\" {{\n",
|
||||
condition.value
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate actions
|
||||
for action in &rule.actions {
|
||||
match action.action_type.as_str() {
|
||||
"move" => script.push_str(&format!(" fileinto \"{}\";\n", action.value)),
|
||||
"delete" => script.push_str(" discard;\n"),
|
||||
"mark_read" => script.push_str(" setflag \"\\\\Seen\";\n"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if rule.stop_processing {
|
||||
script.push_str(" stop;\n");
|
||||
}
|
||||
|
||||
script.push_str("}\n");
|
||||
script
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Telemetry & Monitoring
|
||||
|
||||
```rust
|
||||
impl StalwartClient {
|
||||
/// Get server metrics for dashboard
|
||||
pub async fn get_metrics(&self) -> Result<Metrics> {
|
||||
self.request(Method::GET, "/api/telemetry/metrics", None).await
|
||||
}
|
||||
|
||||
/// Get server logs
|
||||
pub async fn get_logs(&self, page: u32, limit: u32) -> Result<LogList> {
|
||||
self.request(
|
||||
Method::GET,
|
||||
&format!("/api/logs?page={}&limit={}", page, limit),
|
||||
None
|
||||
).await
|
||||
}
|
||||
|
||||
/// Get delivery traces
|
||||
pub async fn get_traces(&self, trace_type: &str, page: u32) -> Result<TraceList> {
|
||||
self.request(
|
||||
Method::GET,
|
||||
&format!("/api/telemetry/traces?type={}&page={}&limit=50", trace_type, page),
|
||||
None
|
||||
).await
|
||||
}
|
||||
|
||||
/// Get specific trace details
|
||||
pub async fn get_trace(&self, trace_id: &str) -> Result<Vec<TraceEvent>> {
|
||||
self.request(Method::GET, &format!("/api/telemetry/trace/{}", trace_id), None).await
|
||||
}
|
||||
|
||||
/// Get DMARC reports
|
||||
pub async fn get_dmarc_reports(&self, page: u32) -> Result<ReportList> {
|
||||
self.request(Method::GET, &format!("/api/reports/dmarc?page={}&limit=50", page), None).await
|
||||
}
|
||||
|
||||
/// Get TLS reports
|
||||
pub async fn get_tls_reports(&self, page: u32) -> Result<ReportList> {
|
||||
self.request(Method::GET, &format!("/api/reports/tls?page={}&limit=50", page), None).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spam Filter
|
||||
|
||||
```rust
|
||||
impl StalwartClient {
|
||||
/// Train message as spam
|
||||
pub async fn train_spam(&self, raw_message: &str) -> Result<()> {
|
||||
self.http_client
|
||||
.post(&format!("{}/api/spam-filter/train/spam", self.base_url))
|
||||
.header("Authorization", format!("Bearer {}", self.auth_token))
|
||||
.header("Content-Type", "message/rfc822")
|
||||
.body(raw_message.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Train message as ham (not spam)
|
||||
pub async fn train_ham(&self, raw_message: &str) -> Result<()> {
|
||||
self.http_client
|
||||
.post(&format!("{}/api/spam-filter/train/ham", self.base_url))
|
||||
.header("Authorization", format!("Bearer {}", self.auth_token))
|
||||
.header("Content-Type", "message/rfc822")
|
||||
.body(raw_message.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify message (get spam score)
|
||||
pub async fn classify_message(&self, message: &SpamClassifyRequest) -> Result<SpamClassifyResult> {
|
||||
self.request(Method::POST, "/api/spam-filter/classify", Some(json!(message))).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Dashboard Integration
|
||||
|
||||
### Endpoints to Poll
|
||||
|
||||
| Metric | Endpoint | Poll Interval |
|
||||
|--------|----------|---------------|
|
||||
| Queue Size | `GET /queue/messages` | 30s |
|
||||
| Queue Status | `GET /queue/status` | 30s |
|
||||
| Server Metrics | `GET /telemetry/metrics` | 60s |
|
||||
| Recent Logs | `GET /logs?limit=100` | 60s |
|
||||
| Delivery Traces | `GET /telemetry/traces?type=delivery.attempt-start` | 60s |
|
||||
| Failed Deliveries | `GET /queue/messages?filter=status:failed` | 60s |
|
||||
|
||||
### WebSocket Endpoints (Real-time)
|
||||
|
||||
| Feature | Endpoint | Token Endpoint |
|
||||
|---------|----------|----------------|
|
||||
| Live Metrics | `ws://.../telemetry/metrics/live` | `GET /telemetry/live/metrics-token` |
|
||||
| Live Traces | `ws://.../telemetry/traces/live` | `GET /telemetry/live/tracing-token` |
|
||||
|
||||
---
|
||||
|
||||
## Tables to REMOVE from Migration
|
||||
|
||||
Based on this mapping, these tables are **REDUNDANT** and should be removed:
|
||||
|
||||
```sql
|
||||
-- REMOVE: Stalwart handles distribution lists via principals
|
||||
-- DROP TABLE IF EXISTS distribution_lists;
|
||||
|
||||
-- KEEP: We need this for UI config, but sync to Stalwart Sieve
|
||||
-- email_auto_responders (KEEP but add stalwart_sieve_id column)
|
||||
|
||||
-- KEEP: We need this for UI config, but sync to Stalwart Sieve
|
||||
-- email_rules (KEEP but add stalwart_sieve_id column)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Updates Needed
|
||||
|
||||
The current `6.1.0_enterprise_suite` migration already correctly:
|
||||
|
||||
1. ✅ Keeps `global_email_signatures` - Stalwart doesn't have this
|
||||
2. ✅ Keeps `email_signatures` - User preference, not in Stalwart
|
||||
3. ✅ Keeps `scheduled_emails` - We manage scheduling
|
||||
4. ✅ Keeps `email_templates` - Business feature
|
||||
5. ✅ Keeps `email_labels` - UI organization
|
||||
6. ✅ Has `stalwart_sieve_id` in `email_auto_responders` - For sync
|
||||
7. ✅ Has `stalwart_sieve_id` in `email_rules` - For sync
|
||||
8. ✅ Has `stalwart_account_id` in `shared_mailboxes` - For sync
|
||||
|
||||
The `distribution_lists` table could potentially be removed since Stalwart handles lists as principals, BUT we may want to keep it for:
|
||||
- Caching/faster lookups
|
||||
- UI metadata not stored in Stalwart
|
||||
- Offline resilience
|
||||
|
||||
**Recommendation**: Keep `distribution_lists` but sync with Stalwart principals.
|
||||
|
||||
---
|
||||
|
||||
## Sync Strategy
|
||||
|
||||
### On Create (Our DB → Stalwart)
|
||||
|
||||
```rust
|
||||
async fn create_distribution_list(db: &Pool, stalwart: &StalwartClient, list: NewDistributionList) -> Result<Uuid> {
|
||||
// 1. Create in Stalwart first
|
||||
let stalwart_id = stalwart.create_distribution_list(
|
||||
&list.name,
|
||||
&list.email_alias,
|
||||
list.members.clone()
|
||||
).await?;
|
||||
|
||||
// 2. Store in our DB with stalwart reference
|
||||
let id = db.insert_distribution_list(DistributionList {
|
||||
name: list.name,
|
||||
email_alias: list.email_alias,
|
||||
members_json: serde_json::to_string(&list.members)?,
|
||||
stalwart_principal_id: Some(stalwart_id.to_string()),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
```
|
||||
|
||||
### On Update (Sync both)
|
||||
|
||||
```rust
|
||||
async fn update_distribution_list(db: &Pool, stalwart: &StalwartClient, id: Uuid, updates: ListUpdates) -> Result<()> {
|
||||
// 1. Get current record
|
||||
let list = db.get_distribution_list(&id).await?;
|
||||
|
||||
// 2. Update Stalwart if we have a reference
|
||||
if let Some(stalwart_id) = &list.stalwart_principal_id {
|
||||
stalwart.update_principal(stalwart_id, updates.to_stalwart_updates()).await?;
|
||||
}
|
||||
|
||||
// 3. Update our DB
|
||||
db.update_distribution_list(&id, updates).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### On Delete (Both)
|
||||
|
||||
```rust
|
||||
async fn delete_distribution_list(db: &Pool, stalwart: &StalwartClient, id: Uuid) -> Result<()> {
|
||||
let list = db.get_distribution_list(&id).await?;
|
||||
|
||||
// 1. Delete from Stalwart
|
||||
if let Some(stalwart_id) = &list.stalwart_principal_id {
|
||||
stalwart.delete_principal(stalwart_id).await?;
|
||||
}
|
||||
|
||||
// 2. Delete from our DB
|
||||
db.delete_distribution_list(&id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Use Stalwart | Use Our Tables | Use Both |
|
||||
|----------|-------------|----------------|----------|
|
||||
| Account Management | ✅ | | |
|
||||
| Email Queue | ✅ | | |
|
||||
| Queue Monitoring | ✅ | | |
|
||||
| Distribution Lists | | | ✅ |
|
||||
| Auto-Responders | | | ✅ |
|
||||
| Email Rules/Filters | | | ✅ |
|
||||
| Shared Mailboxes | | | ✅ |
|
||||
| Email Signatures | | ✅ | |
|
||||
| Scheduled Send | | ✅ | |
|
||||
| Email Templates | | ✅ | |
|
||||
| Email Labels | | ✅ | |
|
||||
| Email Tracking | | ✅ | |
|
||||
| Spam Training | ✅ | | |
|
||||
| Telemetry/Logs | ✅ | | |
|
||||
| DMARC/TLS Reports | ✅ | | |
|
||||
1447
docs/TESTING_STRATEGY.md
Normal file
1447
docs/TESTING_STRATEGY.md
Normal file
File diff suppressed because it is too large
Load diff
51
migrations/6.1.0_enterprise_suite/down.sql
Normal file
51
migrations/6.1.0_enterprise_suite/down.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- Rollback Migration: 6.1.0 Enterprise Features
|
||||
-- WARNING: This will delete all enterprise feature data!
|
||||
-- NOTE: TABLES AND INDEXES ONLY - No views, triggers, or functions per project standards
|
||||
|
||||
-- Drop test support tables
|
||||
DROP TABLE IF EXISTS test_execution_logs;
|
||||
DROP TABLE IF EXISTS test_accounts;
|
||||
|
||||
-- Drop calendar tables
|
||||
DROP TABLE IF EXISTS calendar_shares;
|
||||
DROP TABLE IF EXISTS calendar_resource_bookings;
|
||||
DROP TABLE IF EXISTS calendar_resources;
|
||||
|
||||
-- Drop task tables
|
||||
DROP TABLE IF EXISTS task_recurrence;
|
||||
DROP TABLE IF EXISTS task_time_entries;
|
||||
DROP TABLE IF EXISTS task_dependencies;
|
||||
|
||||
-- Drop collaboration tables
|
||||
DROP TABLE IF EXISTS document_presence;
|
||||
|
||||
-- Drop drive tables
|
||||
DROP TABLE IF EXISTS storage_quotas;
|
||||
DROP TABLE IF EXISTS file_sync_status;
|
||||
DROP TABLE IF EXISTS file_trash;
|
||||
DROP TABLE IF EXISTS file_activities;
|
||||
DROP TABLE IF EXISTS file_shares;
|
||||
DROP TABLE IF EXISTS file_comments;
|
||||
DROP TABLE IF EXISTS file_versions;
|
||||
|
||||
-- Drop meet tables
|
||||
DROP TABLE IF EXISTS user_virtual_backgrounds;
|
||||
DROP TABLE IF EXISTS meeting_captions;
|
||||
DROP TABLE IF EXISTS meeting_waiting_room;
|
||||
DROP TABLE IF EXISTS meeting_questions;
|
||||
DROP TABLE IF EXISTS meeting_polls;
|
||||
DROP TABLE IF EXISTS meeting_breakout_rooms;
|
||||
DROP TABLE IF EXISTS meeting_recordings;
|
||||
|
||||
-- Drop email tables (order matters due to foreign keys)
|
||||
DROP TABLE IF EXISTS shared_mailbox_members;
|
||||
DROP TABLE IF EXISTS shared_mailboxes;
|
||||
DROP TABLE IF EXISTS distribution_lists;
|
||||
DROP TABLE IF EXISTS email_label_assignments;
|
||||
DROP TABLE IF EXISTS email_labels;
|
||||
DROP TABLE IF EXISTS email_rules;
|
||||
DROP TABLE IF EXISTS email_auto_responders;
|
||||
DROP TABLE IF EXISTS email_templates;
|
||||
DROP TABLE IF EXISTS scheduled_emails;
|
||||
DROP TABLE IF EXISTS email_signatures;
|
||||
DROP TABLE IF EXISTS global_email_signatures;
|
||||
629
migrations/6.1.0_enterprise_suite/up.sql
Normal file
629
migrations/6.1.0_enterprise_suite/up.sql
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
-- Migration: 6.1.0 Enterprise Features
|
||||
-- Description: MUST-HAVE features to compete with Microsoft 365 and Google Workspace
|
||||
-- NOTE: TABLES AND INDEXES ONLY - No views, triggers, or functions per project standards
|
||||
|
||||
-- ============================================================================
|
||||
-- GLOBAL CONFIGURATION
|
||||
-- ============================================================================
|
||||
|
||||
-- Global email signature (applied to all emails from this bot)
|
||||
CREATE TABLE IF NOT EXISTS global_email_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL DEFAULT 'Default',
|
||||
content_html TEXT NOT NULL,
|
||||
content_plain TEXT NOT NULL,
|
||||
position VARCHAR(20) DEFAULT 'bottom',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_bot_global_signature UNIQUE (bot_id, name),
|
||||
CONSTRAINT check_signature_position CHECK (position IN ('top', 'bottom'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_global_signatures_bot ON global_email_signatures(bot_id) WHERE is_active = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- EMAIL ENTERPRISE FEATURES (Outlook/Gmail parity)
|
||||
-- Note: Many features controlled via Stalwart IMAP/JMAP API
|
||||
-- ============================================================================
|
||||
|
||||
-- User email signatures (in addition to global)
|
||||
CREATE TABLE IF NOT EXISTS email_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bot_id UUID REFERENCES bots(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL DEFAULT 'Default',
|
||||
content_html TEXT NOT NULL,
|
||||
content_plain TEXT NOT NULL,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_user_signature_name UNIQUE (user_id, bot_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_signatures_user ON email_signatures(user_id);
|
||||
CREATE INDEX idx_email_signatures_default ON email_signatures(user_id, bot_id) WHERE is_default = true;
|
||||
|
||||
-- Scheduled emails (send later)
|
||||
CREATE TABLE IF NOT EXISTS scheduled_emails (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
to_addresses TEXT NOT NULL,
|
||||
cc_addresses TEXT,
|
||||
bcc_addresses TEXT,
|
||||
subject TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
body_plain TEXT,
|
||||
attachments_json TEXT DEFAULT '[]',
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
sent_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_scheduled_status CHECK (status IN ('pending', 'sent', 'failed', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scheduled_emails_pending ON scheduled_emails(scheduled_at) WHERE status = 'pending';
|
||||
CREATE INDEX idx_scheduled_emails_user ON scheduled_emails(user_id, bot_id);
|
||||
|
||||
-- Email templates
|
||||
CREATE TABLE IF NOT EXISTS email_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
subject_template TEXT NOT NULL,
|
||||
body_html_template TEXT NOT NULL,
|
||||
body_plain_template TEXT,
|
||||
variables_json TEXT DEFAULT '[]',
|
||||
category VARCHAR(100),
|
||||
is_shared BOOLEAN DEFAULT false,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_templates_bot ON email_templates(bot_id);
|
||||
CREATE INDEX idx_email_templates_category ON email_templates(category);
|
||||
CREATE INDEX idx_email_templates_shared ON email_templates(bot_id) WHERE is_shared = true;
|
||||
|
||||
-- Auto-responders (Out of Office) - works with Stalwart Sieve
|
||||
CREATE TABLE IF NOT EXISTS email_auto_responders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
responder_type VARCHAR(50) NOT NULL DEFAULT 'out_of_office',
|
||||
subject TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
body_plain TEXT,
|
||||
start_date TIMESTAMPTZ,
|
||||
end_date TIMESTAMPTZ,
|
||||
send_to_internal_only BOOLEAN DEFAULT false,
|
||||
exclude_addresses TEXT,
|
||||
is_active BOOLEAN DEFAULT false,
|
||||
stalwart_sieve_id VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_responder_type CHECK (responder_type IN ('out_of_office', 'vacation', 'custom')),
|
||||
CONSTRAINT unique_user_responder UNIQUE (user_id, bot_id, responder_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_auto_responders_active ON email_auto_responders(user_id, bot_id) WHERE is_active = true;
|
||||
|
||||
-- Email rules/filters - synced with Stalwart Sieve
|
||||
CREATE TABLE IF NOT EXISTS email_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
priority INTEGER DEFAULT 0,
|
||||
conditions_json TEXT NOT NULL,
|
||||
actions_json TEXT NOT NULL,
|
||||
stop_processing BOOLEAN DEFAULT false,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
stalwart_sieve_id VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_rules_user ON email_rules(user_id, bot_id);
|
||||
CREATE INDEX idx_email_rules_priority ON email_rules(user_id, bot_id, priority);
|
||||
|
||||
-- Email labels/categories
|
||||
CREATE TABLE IF NOT EXISTS email_labels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
color VARCHAR(7) DEFAULT '#3b82f6',
|
||||
parent_id UUID REFERENCES email_labels(id) ON DELETE CASCADE,
|
||||
is_system BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_user_label UNIQUE (user_id, bot_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_labels_user ON email_labels(user_id, bot_id);
|
||||
|
||||
-- Email-label associations
|
||||
CREATE TABLE IF NOT EXISTS email_label_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email_message_id VARCHAR(255) NOT NULL,
|
||||
label_id UUID NOT NULL REFERENCES email_labels(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_email_label UNIQUE (email_message_id, label_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_label_assignments_email ON email_label_assignments(email_message_id);
|
||||
CREATE INDEX idx_label_assignments_label ON email_label_assignments(label_id);
|
||||
|
||||
-- Distribution lists
|
||||
CREATE TABLE IF NOT EXISTS distribution_lists (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email_alias VARCHAR(255),
|
||||
description TEXT,
|
||||
members_json TEXT NOT NULL DEFAULT '[]',
|
||||
is_public BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_distribution_lists_bot ON distribution_lists(bot_id);
|
||||
CREATE INDEX idx_distribution_lists_owner ON distribution_lists(owner_id);
|
||||
|
||||
-- Shared mailboxes - managed via Stalwart
|
||||
CREATE TABLE IF NOT EXISTS shared_mailboxes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
email_address VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
settings_json TEXT DEFAULT '{}',
|
||||
stalwart_account_id VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_shared_mailbox_email UNIQUE (bot_id, email_address)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shared_mailboxes_bot ON shared_mailboxes(bot_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_mailbox_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mailbox_id UUID NOT NULL REFERENCES shared_mailboxes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_level VARCHAR(20) DEFAULT 'read',
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_mailbox_member UNIQUE (mailbox_id, user_id),
|
||||
CONSTRAINT check_permission CHECK (permission_level IN ('read', 'write', 'admin'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shared_mailbox_members ON shared_mailbox_members(mailbox_id);
|
||||
CREATE INDEX idx_shared_mailbox_user ON shared_mailbox_members(user_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- VIDEO MEETING FEATURES (Google Meet/Zoom parity)
|
||||
-- ============================================================================
|
||||
|
||||
-- Meeting recordings
|
||||
CREATE TABLE IF NOT EXISTS meeting_recordings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
meeting_id UUID NOT NULL,
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
recorded_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
duration_seconds INTEGER,
|
||||
format VARCHAR(20) DEFAULT 'mp4',
|
||||
thumbnail_path TEXT,
|
||||
transcription_path TEXT,
|
||||
transcription_status VARCHAR(20) DEFAULT 'pending',
|
||||
is_shared BOOLEAN DEFAULT false,
|
||||
shared_with_json TEXT DEFAULT '[]',
|
||||
retention_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_transcription_status CHECK (transcription_status IN ('pending', 'processing', 'completed', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_meeting_recordings_meeting ON meeting_recordings(meeting_id);
|
||||
CREATE INDEX idx_meeting_recordings_bot ON meeting_recordings(bot_id);
|
||||
|
||||
-- Breakout rooms
|
||||
CREATE TABLE IF NOT EXISTS meeting_breakout_rooms (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
meeting_id UUID NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
room_number INTEGER NOT NULL,
|
||||
participants_json TEXT DEFAULT '[]',
|
||||
duration_minutes INTEGER,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_breakout_rooms_meeting ON meeting_breakout_rooms(meeting_id);
|
||||
|
||||
-- Meeting polls
|
||||
CREATE TABLE IF NOT EXISTS meeting_polls (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
meeting_id UUID NOT NULL,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
poll_type VARCHAR(20) DEFAULT 'single',
|
||||
options_json TEXT NOT NULL,
|
||||
is_anonymous BOOLEAN DEFAULT false,
|
||||
allow_multiple BOOLEAN DEFAULT false,
|
||||
is_active BOOLEAN DEFAULT false,
|
||||
results_json TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
CONSTRAINT check_poll_type CHECK (poll_type IN ('single', 'multiple', 'open'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_meeting_polls_meeting ON meeting_polls(meeting_id);
|
||||
|
||||
-- Meeting Q&A
|
||||
CREATE TABLE IF NOT EXISTS meeting_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
meeting_id UUID NOT NULL,
|
||||
asked_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
question TEXT NOT NULL,
|
||||
is_anonymous BOOLEAN DEFAULT false,
|
||||
upvotes INTEGER DEFAULT 0,
|
||||
is_answered BOOLEAN DEFAULT false,
|
||||
answer TEXT,
|
||||
answered_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
answered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_meeting_questions_meeting ON meeting_questions(meeting_id);
|
||||
CREATE INDEX idx_meeting_questions_unanswered ON meeting_questions(meeting_id) WHERE is_answered = false;
|
||||
|
||||
-- Meeting waiting room
|
||||
CREATE TABLE IF NOT EXISTS meeting_waiting_room (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
meeting_id UUID NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
guest_name VARCHAR(255),
|
||||
guest_email VARCHAR(255),
|
||||
device_info_json TEXT DEFAULT '{}',
|
||||
status VARCHAR(20) DEFAULT 'waiting',
|
||||
admitted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
admitted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_waiting_status CHECK (status IN ('waiting', 'admitted', 'rejected', 'left'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_waiting_room_meeting ON meeting_waiting_room(meeting_id);
|
||||
CREATE INDEX idx_waiting_room_status ON meeting_waiting_room(meeting_id, status);
|
||||
|
||||
-- Meeting live captions
|
||||
CREATE TABLE IF NOT EXISTS meeting_captions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
meeting_id UUID NOT NULL,
|
||||
speaker_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
speaker_name VARCHAR(255),
|
||||
caption_text TEXT NOT NULL,
|
||||
language VARCHAR(10) DEFAULT 'en',
|
||||
confidence REAL,
|
||||
timestamp_ms BIGINT NOT NULL,
|
||||
duration_ms INTEGER,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_meeting_captions_meeting ON meeting_captions(meeting_id, timestamp_ms);
|
||||
|
||||
-- Virtual backgrounds
|
||||
CREATE TABLE IF NOT EXISTS user_virtual_backgrounds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100),
|
||||
background_type VARCHAR(20) DEFAULT 'image',
|
||||
file_path TEXT,
|
||||
blur_intensity INTEGER,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_bg_type CHECK (background_type IN ('image', 'blur', 'none'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_virtual_backgrounds_user ON user_virtual_backgrounds(user_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- DRIVE ENTERPRISE FEATURES (Google Drive/OneDrive parity)
|
||||
-- ============================================================================
|
||||
|
||||
-- File version history
|
||||
CREATE TABLE IF NOT EXISTS file_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
file_hash VARCHAR(64) NOT NULL,
|
||||
modified_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
change_summary TEXT,
|
||||
is_current BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_file_version UNIQUE (file_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_versions_file ON file_versions(file_id);
|
||||
CREATE INDEX idx_file_versions_current ON file_versions(file_id) WHERE is_current = true;
|
||||
|
||||
-- File comments
|
||||
CREATE TABLE IF NOT EXISTS file_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES file_comments(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
anchor_data_json TEXT,
|
||||
is_resolved BOOLEAN DEFAULT false,
|
||||
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_comments_file ON file_comments(file_id);
|
||||
CREATE INDEX idx_file_comments_unresolved ON file_comments(file_id) WHERE is_resolved = false;
|
||||
|
||||
-- File sharing permissions
|
||||
CREATE TABLE IF NOT EXISTS file_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL,
|
||||
shared_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with_user UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with_email VARCHAR(255),
|
||||
shared_with_group UUID,
|
||||
permission_level VARCHAR(20) NOT NULL DEFAULT 'view',
|
||||
can_reshare BOOLEAN DEFAULT false,
|
||||
password_hash VARCHAR(255),
|
||||
expires_at TIMESTAMPTZ,
|
||||
link_token VARCHAR(64) UNIQUE,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
last_accessed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_share_permission CHECK (permission_level IN ('view', 'comment', 'edit', 'admin'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_shares_file ON file_shares(file_id);
|
||||
CREATE INDEX idx_file_shares_user ON file_shares(shared_with_user);
|
||||
CREATE INDEX idx_file_shares_token ON file_shares(link_token) WHERE link_token IS NOT NULL;
|
||||
|
||||
-- File activity log
|
||||
CREATE TABLE IF NOT EXISTS file_activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
activity_type VARCHAR(50) NOT NULL,
|
||||
details_json TEXT DEFAULT '{}',
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_activities_file ON file_activities(file_id, created_at DESC);
|
||||
CREATE INDEX idx_file_activities_user ON file_activities(user_id, created_at DESC);
|
||||
|
||||
-- Trash bin (soft delete with restore)
|
||||
CREATE TABLE IF NOT EXISTS file_trash (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
original_file_id UUID NOT NULL,
|
||||
original_path TEXT NOT NULL,
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
file_metadata_json TEXT NOT NULL,
|
||||
deleted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
deleted_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
permanent_delete_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_trash_owner ON file_trash(owner_id);
|
||||
CREATE INDEX idx_file_trash_expiry ON file_trash(permanent_delete_at);
|
||||
|
||||
-- Offline sync tracking
|
||||
CREATE TABLE IF NOT EXISTS file_sync_status (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id VARCHAR(255) NOT NULL,
|
||||
file_id UUID NOT NULL,
|
||||
local_path TEXT,
|
||||
sync_status VARCHAR(20) DEFAULT 'synced',
|
||||
local_version INTEGER,
|
||||
remote_version INTEGER,
|
||||
conflict_data_json TEXT,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_sync_status CHECK (sync_status IN ('synced', 'pending', 'conflict', 'error')),
|
||||
CONSTRAINT unique_sync_entry UNIQUE (user_id, device_id, file_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_sync_user ON file_sync_status(user_id, device_id);
|
||||
CREATE INDEX idx_file_sync_pending ON file_sync_status(user_id) WHERE sync_status = 'pending';
|
||||
|
||||
-- Storage quotas
|
||||
CREATE TABLE IF NOT EXISTS storage_quotas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
bot_id UUID REFERENCES bots(id) ON DELETE CASCADE,
|
||||
quota_bytes BIGINT NOT NULL DEFAULT 5368709120,
|
||||
used_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
warning_threshold_percent INTEGER DEFAULT 90,
|
||||
last_calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_user_quota UNIQUE (user_id),
|
||||
CONSTRAINT unique_bot_quota UNIQUE (bot_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_storage_quotas_user ON storage_quotas(user_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- COLLABORATION FEATURES
|
||||
-- ============================================================================
|
||||
|
||||
-- Document presence (who's viewing/editing)
|
||||
CREATE TABLE IF NOT EXISTS document_presence (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
cursor_position_json TEXT,
|
||||
selection_range_json TEXT,
|
||||
color VARCHAR(7),
|
||||
is_editing BOOLEAN DEFAULT false,
|
||||
last_activity TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT unique_doc_user_presence UNIQUE (document_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_presence_doc ON document_presence(document_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- TASK ENTERPRISE FEATURES
|
||||
-- ============================================================================
|
||||
|
||||
-- Task dependencies
|
||||
CREATE TABLE IF NOT EXISTS task_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id UUID NOT NULL,
|
||||
depends_on_task_id UUID NOT NULL,
|
||||
dependency_type VARCHAR(20) DEFAULT 'finish_to_start',
|
||||
lag_days INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_dependency_type CHECK (dependency_type IN ('finish_to_start', 'start_to_start', 'finish_to_finish', 'start_to_finish')),
|
||||
CONSTRAINT unique_task_dependency UNIQUE (task_id, depends_on_task_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_dependencies_task ON task_dependencies(task_id);
|
||||
CREATE INDEX idx_task_dependencies_depends ON task_dependencies(depends_on_task_id);
|
||||
|
||||
-- Task time tracking
|
||||
CREATE TABLE IF NOT EXISTS task_time_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
description TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
duration_minutes INTEGER,
|
||||
is_billable BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_time_task ON task_time_entries(task_id);
|
||||
CREATE INDEX idx_task_time_user ON task_time_entries(user_id, started_at);
|
||||
|
||||
-- Task recurring rules
|
||||
CREATE TABLE IF NOT EXISTS task_recurrence (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_template_id UUID NOT NULL,
|
||||
recurrence_pattern VARCHAR(20) NOT NULL,
|
||||
interval_value INTEGER DEFAULT 1,
|
||||
days_of_week_json TEXT,
|
||||
day_of_month INTEGER,
|
||||
month_of_year INTEGER,
|
||||
end_date TIMESTAMPTZ,
|
||||
occurrence_count INTEGER,
|
||||
next_occurrence TIMESTAMPTZ,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_recurrence CHECK (recurrence_pattern IN ('daily', 'weekly', 'monthly', 'yearly', 'custom'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_recurrence_next ON task_recurrence(next_occurrence) WHERE is_active = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- CALENDAR ENTERPRISE FEATURES
|
||||
-- ============================================================================
|
||||
|
||||
-- Resource booking (meeting rooms, equipment)
|
||||
CREATE TABLE IF NOT EXISTS calendar_resources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
location VARCHAR(255),
|
||||
capacity INTEGER,
|
||||
amenities_json TEXT DEFAULT '[]',
|
||||
availability_hours_json TEXT,
|
||||
booking_rules_json TEXT DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_resource_type CHECK (resource_type IN ('room', 'equipment', 'vehicle', 'other'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_calendar_resources_bot ON calendar_resources(bot_id);
|
||||
CREATE INDEX idx_calendar_resources_type ON calendar_resources(bot_id, resource_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_resource_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_id UUID NOT NULL REFERENCES calendar_resources(id) ON DELETE CASCADE,
|
||||
event_id UUID,
|
||||
booked_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
notes TEXT,
|
||||
status VARCHAR(20) DEFAULT 'confirmed',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_booking_status CHECK (status IN ('pending', 'confirmed', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_resource_bookings_resource ON calendar_resource_bookings(resource_id, start_time, end_time);
|
||||
CREATE INDEX idx_resource_bookings_user ON calendar_resource_bookings(booked_by);
|
||||
|
||||
-- Calendar sharing
|
||||
CREATE TABLE IF NOT EXISTS calendar_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with_user UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
shared_with_email VARCHAR(255),
|
||||
permission_level VARCHAR(20) DEFAULT 'view',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_cal_permission CHECK (permission_level IN ('free_busy', 'view', 'edit', 'admin'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_calendar_shares_owner ON calendar_shares(owner_id);
|
||||
CREATE INDEX idx_calendar_shares_shared ON calendar_shares(shared_with_user);
|
||||
|
||||
-- ============================================================================
|
||||
-- TEST SUPPORT TABLES
|
||||
-- ============================================================================
|
||||
|
||||
-- Test accounts for integration testing
|
||||
CREATE TABLE IF NOT EXISTS test_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_type VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_test_account_type CHECK (account_type IN ('sender', 'receiver', 'bot', 'admin'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_test_accounts_type ON test_accounts(account_type);
|
||||
|
||||
-- Test execution logs
|
||||
CREATE TABLE IF NOT EXISTS test_execution_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
test_suite VARCHAR(100) NOT NULL,
|
||||
test_name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
stack_trace TEXT,
|
||||
metadata_json TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT check_test_status CHECK (status IN ('passed', 'failed', 'skipped', 'error'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_test_logs_suite ON test_execution_logs(test_suite, created_at DESC);
|
||||
CREATE INDEX idx_test_logs_status ON test_execution_logs(status, created_at DESC);
|
||||
|
|
@ -5,6 +5,7 @@ pub mod config;
|
|||
pub mod directory;
|
||||
pub mod dns;
|
||||
pub mod kb;
|
||||
pub mod oauth;
|
||||
pub mod package_manager;
|
||||
pub mod rate_limit;
|
||||
pub mod secrets;
|
||||
|
|
|
|||
292
src/core/oauth/mod.rs
Normal file
292
src/core/oauth/mod.rs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
//! OAuth2 Authentication Module
|
||||
//!
|
||||
//! Provides OAuth2 authentication support for multiple providers:
|
||||
//! - Google
|
||||
//! - Discord
|
||||
//! - Reddit
|
||||
//! - Twitter (X)
|
||||
//! - Microsoft
|
||||
//! - Facebook
|
||||
|
||||
pub mod providers;
|
||||
pub mod routes;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Supported OAuth2 providers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OAuthProvider {
|
||||
Google,
|
||||
Discord,
|
||||
Reddit,
|
||||
Twitter,
|
||||
Microsoft,
|
||||
Facebook,
|
||||
}
|
||||
|
||||
impl OAuthProvider {
|
||||
/// Get all available providers
|
||||
pub fn all() -> Vec<OAuthProvider> {
|
||||
vec![
|
||||
OAuthProvider::Google,
|
||||
OAuthProvider::Discord,
|
||||
OAuthProvider::Reddit,
|
||||
OAuthProvider::Twitter,
|
||||
OAuthProvider::Microsoft,
|
||||
OAuthProvider::Facebook,
|
||||
]
|
||||
}
|
||||
|
||||
/// Get provider from string
|
||||
pub fn from_str(s: &str) -> Option<OAuthProvider> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"google" => Some(OAuthProvider::Google),
|
||||
"discord" => Some(OAuthProvider::Discord),
|
||||
"reddit" => Some(OAuthProvider::Reddit),
|
||||
"twitter" | "x" => Some(OAuthProvider::Twitter),
|
||||
"microsoft" => Some(OAuthProvider::Microsoft),
|
||||
"facebook" => Some(OAuthProvider::Facebook),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the config key prefix for this provider
|
||||
pub fn config_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
OAuthProvider::Google => "oauth-google",
|
||||
OAuthProvider::Discord => "oauth-discord",
|
||||
OAuthProvider::Reddit => "oauth-reddit",
|
||||
OAuthProvider::Twitter => "oauth-twitter",
|
||||
OAuthProvider::Microsoft => "oauth-microsoft",
|
||||
OAuthProvider::Facebook => "oauth-facebook",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display name for UI
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
OAuthProvider::Google => "Google",
|
||||
OAuthProvider::Discord => "Discord",
|
||||
OAuthProvider::Reddit => "Reddit",
|
||||
OAuthProvider::Twitter => "Twitter",
|
||||
OAuthProvider::Microsoft => "Microsoft",
|
||||
OAuthProvider::Facebook => "Facebook",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get icon/emoji for UI
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
OAuthProvider::Google => "🔵",
|
||||
OAuthProvider::Discord => "🎮",
|
||||
OAuthProvider::Reddit => "🟠",
|
||||
OAuthProvider::Twitter => "🐦",
|
||||
OAuthProvider::Microsoft => "🪟",
|
||||
OAuthProvider::Facebook => "📘",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OAuthProvider {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.display_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth configuration for a provider
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
pub provider: OAuthProvider,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_uri: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl OAuthConfig {
|
||||
/// Create a new OAuth config
|
||||
pub fn new(
|
||||
provider: OAuthProvider,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
redirect_uri: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the config is valid (has required fields)
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.enabled
|
||||
&& !self.client_id.is_empty()
|
||||
&& !self.client_secret.is_empty()
|
||||
&& !self.redirect_uri.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// User information returned from OAuth provider
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthUserInfo {
|
||||
/// Provider-specific user ID
|
||||
pub provider_id: String,
|
||||
/// OAuth provider
|
||||
pub provider: OAuthProvider,
|
||||
/// User's email (if available)
|
||||
pub email: Option<String>,
|
||||
/// User's display name
|
||||
pub name: Option<String>,
|
||||
/// User's avatar URL
|
||||
pub avatar_url: Option<String>,
|
||||
/// Raw response from provider (for debugging/additional fields)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// OAuth token response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthTokenResponse {
|
||||
pub access_token: String,
|
||||
#[serde(default)]
|
||||
pub token_type: String,
|
||||
#[serde(default)]
|
||||
pub expires_in: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
/// OAuth error types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthError {
|
||||
pub error: String,
|
||||
pub error_description: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for OAuthError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if let Some(desc) = &self.error_description {
|
||||
write!(f, "{}: {}", self.error, desc)
|
||||
} else {
|
||||
write!(f, "{}", self.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OAuthError {}
|
||||
|
||||
/// State parameter for OAuth flow (CSRF protection)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthState {
|
||||
/// Random state token
|
||||
pub token: String,
|
||||
/// Provider being used
|
||||
pub provider: OAuthProvider,
|
||||
/// Optional redirect URL after login
|
||||
pub redirect_after: Option<String>,
|
||||
/// Timestamp when state was created
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl OAuthState {
|
||||
/// Create a new OAuth state
|
||||
pub fn new(provider: OAuthProvider, redirect_after: Option<String>) -> Self {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
Self {
|
||||
token,
|
||||
provider,
|
||||
redirect_after,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if state is expired (default: 10 minutes)
|
||||
pub fn is_expired(&self) -> bool {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
now - self.created_at > 600 // 10 minutes
|
||||
}
|
||||
|
||||
/// Encode state to URL-safe string
|
||||
pub fn encode(&self) -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let json = serde_json::to_string(self).unwrap_or_default();
|
||||
URL_SAFE_NO_PAD.encode(json.as_bytes())
|
||||
}
|
||||
|
||||
/// Decode state from URL-safe string
|
||||
pub fn decode(encoded: &str) -> Option<Self> {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?;
|
||||
let json = String::from_utf8(bytes).ok()?;
|
||||
serde_json::from_str(&json).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_from_str() {
|
||||
assert_eq!(
|
||||
OAuthProvider::from_str("google"),
|
||||
Some(OAuthProvider::Google)
|
||||
);
|
||||
assert_eq!(
|
||||
OAuthProvider::from_str("DISCORD"),
|
||||
Some(OAuthProvider::Discord)
|
||||
);
|
||||
assert_eq!(
|
||||
OAuthProvider::from_str("Twitter"),
|
||||
Some(OAuthProvider::Twitter)
|
||||
);
|
||||
assert_eq!(OAuthProvider::from_str("x"), Some(OAuthProvider::Twitter));
|
||||
assert_eq!(OAuthProvider::from_str("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_state_encode_decode() {
|
||||
let state = OAuthState::new(OAuthProvider::Google, Some("/dashboard".to_string()));
|
||||
let encoded = state.encode();
|
||||
let decoded = OAuthState::decode(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.provider, OAuthProvider::Google);
|
||||
assert_eq!(decoded.redirect_after, Some("/dashboard".to_string()));
|
||||
assert!(!decoded.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_config_validation() {
|
||||
let valid_config = OAuthConfig::new(
|
||||
OAuthProvider::Google,
|
||||
"client_id".to_string(),
|
||||
"client_secret".to_string(),
|
||||
"http://localhost/callback".to_string(),
|
||||
);
|
||||
assert!(valid_config.is_valid());
|
||||
|
||||
let mut invalid_config = valid_config.clone();
|
||||
invalid_config.client_id = String::new();
|
||||
assert!(!invalid_config.is_valid());
|
||||
}
|
||||
}
|
||||
417
src/core/oauth/providers.rs
Normal file
417
src/core/oauth/providers.rs
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
//! OAuth2 Provider Configurations
|
||||
//!
|
||||
//! This module contains the configuration for each OAuth2 provider including:
|
||||
//! - Authorization URLs
|
||||
//! - Token exchange URLs
|
||||
//! - User info endpoints
|
||||
//! - Required scopes
|
||||
|
||||
use super::{OAuthConfig, OAuthProvider, OAuthTokenResponse, OAuthUserInfo};
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Provider-specific OAuth2 endpoints and configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderEndpoints {
|
||||
/// Authorization URL (where user is redirected to login)
|
||||
pub auth_url: &'static str,
|
||||
/// Token exchange URL
|
||||
pub token_url: &'static str,
|
||||
/// User info endpoint URL
|
||||
pub userinfo_url: &'static str,
|
||||
/// Required scopes for basic user info
|
||||
pub scopes: &'static [&'static str],
|
||||
/// Whether to use Basic auth for token exchange
|
||||
pub use_basic_auth: bool,
|
||||
}
|
||||
|
||||
impl OAuthProvider {
|
||||
/// Get the endpoints configuration for this provider
|
||||
pub fn endpoints(&self) -> ProviderEndpoints {
|
||||
match self {
|
||||
OAuthProvider::Google => ProviderEndpoints {
|
||||
auth_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
userinfo_url: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
scopes: &["openid", "email", "profile"],
|
||||
use_basic_auth: false,
|
||||
},
|
||||
OAuthProvider::Discord => ProviderEndpoints {
|
||||
auth_url: "https://discord.com/api/oauth2/authorize",
|
||||
token_url: "https://discord.com/api/oauth2/token",
|
||||
userinfo_url: "https://discord.com/api/users/@me",
|
||||
scopes: &["identify", "email"],
|
||||
use_basic_auth: true,
|
||||
},
|
||||
OAuthProvider::Reddit => ProviderEndpoints {
|
||||
auth_url: "https://www.reddit.com/api/v1/authorize",
|
||||
token_url: "https://www.reddit.com/api/v1/access_token",
|
||||
userinfo_url: "https://oauth.reddit.com/api/v1/me",
|
||||
scopes: &["identity"],
|
||||
use_basic_auth: true,
|
||||
},
|
||||
OAuthProvider::Twitter => ProviderEndpoints {
|
||||
auth_url: "https://twitter.com/i/oauth2/authorize",
|
||||
token_url: "https://api.twitter.com/2/oauth2/token",
|
||||
userinfo_url: "https://api.twitter.com/2/users/me",
|
||||
scopes: &["users.read", "tweet.read"],
|
||||
use_basic_auth: true,
|
||||
},
|
||||
OAuthProvider::Microsoft => ProviderEndpoints {
|
||||
auth_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
userinfo_url: "https://graph.microsoft.com/v1.0/me",
|
||||
scopes: &["openid", "email", "profile", "User.Read"],
|
||||
use_basic_auth: false,
|
||||
},
|
||||
OAuthProvider::Facebook => ProviderEndpoints {
|
||||
auth_url: "https://www.facebook.com/v18.0/dialog/oauth",
|
||||
token_url: "https://graph.facebook.com/v18.0/oauth/access_token",
|
||||
userinfo_url: "https://graph.facebook.com/v18.0/me",
|
||||
scopes: &["email", "public_profile"],
|
||||
use_basic_auth: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the authorization URL for this provider
|
||||
pub fn build_auth_url(&self, config: &OAuthConfig, state: &str) -> String {
|
||||
let endpoints = self.endpoints();
|
||||
let scopes = endpoints.scopes.join(" ");
|
||||
|
||||
let mut params = vec![
|
||||
("client_id", config.client_id.as_str()),
|
||||
("redirect_uri", config.redirect_uri.as_str()),
|
||||
("response_type", "code"),
|
||||
("state", state),
|
||||
("scope", &scopes),
|
||||
];
|
||||
|
||||
// Provider-specific parameters
|
||||
match self {
|
||||
OAuthProvider::Google => {
|
||||
params.push(("access_type", "offline"));
|
||||
params.push(("prompt", "consent"));
|
||||
}
|
||||
OAuthProvider::Discord => {
|
||||
// Discord uses space-separated scopes in the URL
|
||||
}
|
||||
OAuthProvider::Reddit => {
|
||||
params.push(("duration", "temporary"));
|
||||
}
|
||||
OAuthProvider::Twitter => {
|
||||
params.push(("code_challenge", "challenge"));
|
||||
params.push(("code_challenge_method", "plain"));
|
||||
}
|
||||
OAuthProvider::Microsoft => {
|
||||
params.push(("response_mode", "query"));
|
||||
}
|
||||
OAuthProvider::Facebook => {
|
||||
// Facebook uses comma-separated scopes, but also accepts space
|
||||
}
|
||||
}
|
||||
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
format!("{}?{}", endpoints.auth_url, query)
|
||||
}
|
||||
|
||||
/// Exchange authorization code for access token
|
||||
pub async fn exchange_code(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
code: &str,
|
||||
client: &Client,
|
||||
) -> Result<OAuthTokenResponse> {
|
||||
let endpoints = self.endpoints();
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("code", code);
|
||||
params.insert("redirect_uri", config.redirect_uri.as_str());
|
||||
params.insert("client_id", config.client_id.as_str());
|
||||
|
||||
// Twitter requires code_verifier for PKCE
|
||||
if matches!(self, OAuthProvider::Twitter) {
|
||||
params.insert("code_verifier", "challenge");
|
||||
}
|
||||
|
||||
let mut request = client.post(endpoints.token_url);
|
||||
|
||||
if endpoints.use_basic_auth {
|
||||
request = request.basic_auth(&config.client_id, Some(&config.client_secret));
|
||||
} else {
|
||||
params.insert("client_secret", config.client_secret.as_str());
|
||||
}
|
||||
|
||||
// Reddit requires a custom User-Agent
|
||||
if matches!(self, OAuthProvider::Reddit) {
|
||||
request = request.header("User-Agent", "BotServer/1.0");
|
||||
}
|
||||
|
||||
let response = request
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to exchange code: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("Token exchange failed: {}", error_text));
|
||||
}
|
||||
|
||||
let token: OAuthTokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to parse token response: {}", e))?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Fetch user info from the provider
|
||||
pub async fn fetch_user_info(
|
||||
&self,
|
||||
access_token: &str,
|
||||
client: &Client,
|
||||
) -> Result<OAuthUserInfo> {
|
||||
let endpoints = self.endpoints();
|
||||
|
||||
let mut request = client.get(endpoints.userinfo_url);
|
||||
|
||||
// Provider-specific headers and query params
|
||||
match self {
|
||||
OAuthProvider::Reddit => {
|
||||
request = request
|
||||
.header("User-Agent", "BotServer/1.0")
|
||||
.bearer_auth(access_token);
|
||||
}
|
||||
OAuthProvider::Twitter => {
|
||||
request = request
|
||||
.query(&[("user.fields", "id,name,username,profile_image_url")])
|
||||
.bearer_auth(access_token);
|
||||
}
|
||||
OAuthProvider::Facebook => {
|
||||
request = request.query(&[
|
||||
("fields", "id,name,email,picture.type(large)"),
|
||||
("access_token", access_token),
|
||||
]);
|
||||
}
|
||||
_ => {
|
||||
request = request.bearer_auth(access_token);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to fetch user info: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("Failed to fetch user info: {}", error_text));
|
||||
}
|
||||
|
||||
let raw: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to parse user info: {}", e))?;
|
||||
|
||||
// Parse provider-specific response into common format
|
||||
let user_info = self.parse_user_info(&raw)?;
|
||||
|
||||
Ok(user_info)
|
||||
}
|
||||
|
||||
/// Parse provider-specific user info response into common format
|
||||
fn parse_user_info(&self, raw: &serde_json::Value) -> Result<OAuthUserInfo> {
|
||||
match self {
|
||||
OAuthProvider::Google => Ok(OAuthUserInfo {
|
||||
provider_id: raw["id"].as_str().unwrap_or_default().to_string(),
|
||||
provider: *self,
|
||||
email: raw["email"].as_str().map(String::from),
|
||||
name: raw["name"].as_str().map(String::from),
|
||||
avatar_url: raw["picture"].as_str().map(String::from),
|
||||
raw: Some(raw.clone()),
|
||||
}),
|
||||
OAuthProvider::Discord => Ok(OAuthUserInfo {
|
||||
provider_id: raw["id"].as_str().unwrap_or_default().to_string(),
|
||||
provider: *self,
|
||||
email: raw["email"].as_str().map(String::from),
|
||||
name: raw["username"].as_str().map(String::from),
|
||||
avatar_url: raw["avatar"].as_str().map(|avatar| {
|
||||
let user_id = raw["id"].as_str().unwrap_or_default();
|
||||
format!(
|
||||
"https://cdn.discordapp.com/avatars/{}/{}.png",
|
||||
user_id, avatar
|
||||
)
|
||||
}),
|
||||
raw: Some(raw.clone()),
|
||||
}),
|
||||
OAuthProvider::Reddit => Ok(OAuthUserInfo {
|
||||
provider_id: raw["id"].as_str().unwrap_or_default().to_string(),
|
||||
provider: *self,
|
||||
email: None, // Reddit doesn't provide email with basic scope
|
||||
name: raw["name"].as_str().map(String::from),
|
||||
avatar_url: raw["icon_img"]
|
||||
.as_str()
|
||||
.map(|s| s.split('?').next().unwrap_or(s).to_string()),
|
||||
raw: Some(raw.clone()),
|
||||
}),
|
||||
OAuthProvider::Twitter => {
|
||||
let data = raw.get("data").unwrap_or(raw);
|
||||
Ok(OAuthUserInfo {
|
||||
provider_id: data["id"].as_str().unwrap_or_default().to_string(),
|
||||
provider: *self,
|
||||
email: None, // Twitter requires elevated access for email
|
||||
name: data["name"].as_str().map(String::from),
|
||||
avatar_url: data["profile_image_url"].as_str().map(String::from),
|
||||
raw: Some(raw.clone()),
|
||||
})
|
||||
}
|
||||
OAuthProvider::Microsoft => Ok(OAuthUserInfo {
|
||||
provider_id: raw["id"].as_str().unwrap_or_default().to_string(),
|
||||
provider: *self,
|
||||
email: raw["mail"]
|
||||
.as_str()
|
||||
.or_else(|| raw["userPrincipalName"].as_str())
|
||||
.map(String::from),
|
||||
name: raw["displayName"].as_str().map(String::from),
|
||||
avatar_url: None, // Microsoft Graph requires separate endpoint for photo
|
||||
raw: Some(raw.clone()),
|
||||
}),
|
||||
OAuthProvider::Facebook => Ok(OAuthUserInfo {
|
||||
provider_id: raw["id"].as_str().unwrap_or_default().to_string(),
|
||||
provider: *self,
|
||||
email: raw["email"].as_str().map(String::from),
|
||||
name: raw["name"].as_str().map(String::from),
|
||||
avatar_url: raw["picture"]["data"]["url"].as_str().map(String::from),
|
||||
raw: Some(raw.clone()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load OAuth configuration from bot config
|
||||
pub fn load_oauth_config(
|
||||
provider: OAuthProvider,
|
||||
bot_config: &HashMap<String, String>,
|
||||
base_url: &str,
|
||||
) -> Option<OAuthConfig> {
|
||||
let prefix = provider.config_prefix();
|
||||
|
||||
let enabled = bot_config
|
||||
.get(&format!("{}-enabled", prefix))
|
||||
.map(|v| v.to_lowercase() == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let client_id = bot_config.get(&format!("{}-client-id", prefix))?.clone();
|
||||
let client_secret = bot_config
|
||||
.get(&format!("{}-client-secret", prefix))?
|
||||
.clone();
|
||||
|
||||
// Use configured redirect URI or build default
|
||||
let redirect_uri = bot_config
|
||||
.get(&format!("{}-redirect-uri", prefix))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}/auth/oauth/{}/callback",
|
||||
base_url,
|
||||
provider.to_string().to_lowercase()
|
||||
)
|
||||
});
|
||||
|
||||
if client_id.is_empty() || client_secret.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(OAuthConfig {
|
||||
provider,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all enabled OAuth providers from config
|
||||
pub fn get_enabled_providers(
|
||||
bot_config: &HashMap<String, String>,
|
||||
base_url: &str,
|
||||
) -> Vec<OAuthConfig> {
|
||||
OAuthProvider::all()
|
||||
.into_iter()
|
||||
.filter_map(|provider| load_oauth_config(provider, bot_config, base_url))
|
||||
.filter(|config| config.is_valid())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_auth_url() {
|
||||
let config = OAuthConfig::new(
|
||||
OAuthProvider::Google,
|
||||
"test_client_id".to_string(),
|
||||
"test_secret".to_string(),
|
||||
"http://localhost:8080/callback".to_string(),
|
||||
);
|
||||
|
||||
let url = OAuthProvider::Google.build_auth_url(&config, "test_state");
|
||||
|
||||
assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?"));
|
||||
assert!(url.contains("client_id=test_client_id"));
|
||||
assert!(url.contains("state=test_state"));
|
||||
assert!(url.contains("response_type=code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_oauth_config() {
|
||||
let mut bot_config = HashMap::new();
|
||||
bot_config.insert("oauth-google-enabled".to_string(), "true".to_string());
|
||||
bot_config.insert(
|
||||
"oauth-google-client-id".to_string(),
|
||||
"my_client_id".to_string(),
|
||||
);
|
||||
bot_config.insert(
|
||||
"oauth-google-client-secret".to_string(),
|
||||
"my_secret".to_string(),
|
||||
);
|
||||
|
||||
let config = load_oauth_config(OAuthProvider::Google, &bot_config, "http://localhost:8080");
|
||||
|
||||
assert!(config.is_some());
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.client_id, "my_client_id");
|
||||
assert!(config.redirect_uri.contains("/auth/oauth/google/callback"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_provider() {
|
||||
let mut bot_config = HashMap::new();
|
||||
bot_config.insert("oauth-google-enabled".to_string(), "false".to_string());
|
||||
bot_config.insert(
|
||||
"oauth-google-client-id".to_string(),
|
||||
"my_client_id".to_string(),
|
||||
);
|
||||
bot_config.insert(
|
||||
"oauth-google-client-secret".to_string(),
|
||||
"my_secret".to_string(),
|
||||
);
|
||||
|
||||
let config = load_oauth_config(OAuthProvider::Google, &bot_config, "http://localhost:8080");
|
||||
|
||||
assert!(config.is_none());
|
||||
}
|
||||
}
|
||||
616
src/core/oauth/routes.rs
Normal file
616
src/core/oauth/routes.rs
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
//! OAuth2 Routes
|
||||
//!
|
||||
//! Provides HTTP endpoints for OAuth2 authentication flow:
|
||||
//! - GET /auth/oauth/providers - List enabled OAuth providers
|
||||
//! - GET /auth/oauth/:provider - Start OAuth flow (redirect to provider)
|
||||
//! - GET /auth/oauth/:provider/callback - Handle OAuth callback
|
||||
|
||||
use crate::shared::state::AppState;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
providers::{get_enabled_providers, load_oauth_config},
|
||||
OAuthProvider, OAuthState, OAuthUserInfo,
|
||||
};
|
||||
|
||||
/// Query parameters for OAuth start
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OAuthStartParams {
|
||||
/// Optional redirect URL after successful login
|
||||
pub redirect: Option<String>,
|
||||
}
|
||||
|
||||
/// Query parameters for OAuth callback
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OAuthCallbackParams {
|
||||
/// Authorization code from provider
|
||||
pub code: Option<String>,
|
||||
/// State parameter for CSRF validation
|
||||
pub state: Option<String>,
|
||||
/// Error code (if authorization failed)
|
||||
pub error: Option<String>,
|
||||
/// Error description
|
||||
pub error_description: Option<String>,
|
||||
}
|
||||
|
||||
/// Response for listing enabled providers
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnabledProvidersResponse {
|
||||
pub providers: Vec<ProviderInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProviderInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub login_url: String,
|
||||
}
|
||||
|
||||
/// Configure OAuth routes
|
||||
pub fn configure() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/auth/oauth/providers", get(list_providers))
|
||||
.route("/auth/oauth/{provider}", get(start_oauth))
|
||||
.route("/auth/oauth/{provider}/callback", get(oauth_callback))
|
||||
}
|
||||
|
||||
/// List all enabled OAuth providers
|
||||
async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let bot_config = get_bot_config(&state).await;
|
||||
let base_url = get_base_url(&state);
|
||||
|
||||
let enabled = get_enabled_providers(&bot_config, &base_url);
|
||||
|
||||
let providers: Vec<ProviderInfo> = enabled
|
||||
.iter()
|
||||
.map(|config| ProviderInfo {
|
||||
id: config.provider.to_string().to_lowercase(),
|
||||
name: config.provider.display_name().to_string(),
|
||||
icon: config.provider.icon().to_string(),
|
||||
login_url: format!("/auth/oauth/{}", config.provider.to_string().to_lowercase()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(EnabledProvidersResponse { providers })
|
||||
}
|
||||
|
||||
/// Start OAuth flow - redirect to provider's authorization page
|
||||
async fn start_oauth(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(provider_name): Path<String>,
|
||||
Query(params): Query<OAuthStartParams>,
|
||||
) -> Response {
|
||||
// Parse provider
|
||||
let provider = match OAuthProvider::from_str(&provider_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Invalid OAuth Provider</h1>
|
||||
<p>Provider '{}' is not supported.</p>
|
||||
<p>Supported providers: Google, Discord, Reddit, Twitter, Microsoft, Facebook</p>
|
||||
<a href="/auth/login">Back to Login</a>
|
||||
</body>
|
||||
</html>"#,
|
||||
provider_name
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Load provider config
|
||||
let bot_config = get_bot_config(&state).await;
|
||||
let base_url = get_base_url(&state);
|
||||
|
||||
let config = match load_oauth_config(provider, &bot_config, &base_url) {
|
||||
Some(c) if c.is_valid() => c,
|
||||
_ => {
|
||||
warn!("OAuth provider {} is not configured or enabled", provider);
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>OAuth Provider Not Configured</h1>
|
||||
<p>Login with {} is not currently enabled.</p>
|
||||
<p>Please contact your administrator to configure OAuth credentials.</p>
|
||||
<a href="/auth/login">Back to Login</a>
|
||||
</body>
|
||||
</html>"#,
|
||||
provider.display_name()
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Create OAuth state for CSRF protection
|
||||
let oauth_state = OAuthState::new(provider, params.redirect);
|
||||
let state_encoded = oauth_state.encode();
|
||||
|
||||
// Note: State is encoded in the URL and validated on callback
|
||||
// For production, consider storing state in Redis for additional validation
|
||||
debug!("OAuth state created for provider {}", provider);
|
||||
|
||||
// Build authorization URL
|
||||
let auth_url = provider.build_auth_url(&config, &state_encoded);
|
||||
|
||||
info!(
|
||||
"Starting OAuth flow for {} - redirecting to provider",
|
||||
provider
|
||||
);
|
||||
|
||||
Redirect::temporary(&auth_url).into_response()
|
||||
}
|
||||
|
||||
/// Handle OAuth callback from provider
|
||||
async fn oauth_callback(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(provider_name): Path<String>,
|
||||
Query(params): Query<OAuthCallbackParams>,
|
||||
) -> Response {
|
||||
// Check for errors from provider
|
||||
if let Some(error) = ¶ms.error {
|
||||
let description = params
|
||||
.error_description
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown error");
|
||||
warn!("OAuth error from provider: {} - {}", error, description);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>The OAuth provider returned an error: {}</p>
|
||||
<p>{}</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#,
|
||||
error, description
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
let code = match ¶ms.code {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Missing Authorization Code</h1>
|
||||
<p>The OAuth callback did not include an authorization code.</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let state_param = match ¶ms.state {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Missing State Parameter</h1>
|
||||
<p>The OAuth callback did not include a state parameter.</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Decode and validate state
|
||||
let oauth_state = match OAuthState::decode(state_param) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!("Failed to decode OAuth state parameter");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Invalid State</h1>
|
||||
<p>The OAuth state parameter could not be validated.</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Check state expiration
|
||||
if oauth_state.is_expired() {
|
||||
warn!("OAuth state expired");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Session Expired</title></head>
|
||||
<body>
|
||||
<h1>Session Expired</h1>
|
||||
<p>The login session has expired. Please try again.</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Parse provider
|
||||
let provider = match OAuthProvider::from_str(&provider_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("Invalid provider".to_string()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify provider matches state
|
||||
if provider != oauth_state.provider {
|
||||
warn!(
|
||||
"Provider mismatch: URL says {}, state says {}",
|
||||
provider, oauth_state.provider
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Provider Mismatch</h1>
|
||||
<p>The OAuth callback doesn't match the expected provider.</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Load provider config
|
||||
let bot_config = get_bot_config(&state).await;
|
||||
let base_url = get_base_url(&state);
|
||||
|
||||
let config = match load_oauth_config(provider, &bot_config, &base_url) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Html("OAuth provider not configured".to_string()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Exchange code for token
|
||||
let http_client = reqwest::Client::new();
|
||||
let token = match provider.exchange_code(&config, code, &http_client).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to exchange OAuth code: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to complete the OAuth login: {}</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#,
|
||||
e
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch user info
|
||||
let user_info = match provider
|
||||
.fetch_user_info(&token.access_token, &http_client)
|
||||
.await
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch user info: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to retrieve user information: {}</p>
|
||||
<a href="/auth/login">Try Again</a>
|
||||
</body>
|
||||
</html>"#,
|
||||
e
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"OAuth login successful for {} user: {} ({})",
|
||||
provider,
|
||||
user_info.name.as_deref().unwrap_or("unknown"),
|
||||
user_info.email.as_deref().unwrap_or("no email")
|
||||
);
|
||||
|
||||
// Create or update user in our system
|
||||
let user_id = match create_or_get_oauth_user(&state, &user_info).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!("Failed to create/get user: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("Failed to create user account".to_string()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Create session for user
|
||||
let session_token = match create_user_session(&state, user_id).await {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
error!("Failed to create session: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("Failed to create session".to_string()),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Determine redirect URL
|
||||
let redirect_url = oauth_state
|
||||
.redirect_after
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
debug!(
|
||||
"OAuth complete, redirecting to {} with session {}",
|
||||
redirect_url, session_token
|
||||
);
|
||||
|
||||
// Set session cookie and redirect
|
||||
Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, redirect_url)
|
||||
.header(
|
||||
header::SET_COOKIE,
|
||||
format!(
|
||||
"session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400",
|
||||
session_token
|
||||
),
|
||||
)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Get bot configuration from state
|
||||
async fn get_bot_config(state: &AppState) -> HashMap<String, String> {
|
||||
// Try to get from first active bot's config
|
||||
let conn = state.conn.clone();
|
||||
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
let mut db_conn = conn.get().ok()?;
|
||||
|
||||
use diesel::prelude::*;
|
||||
|
||||
let bot_result: Option<Uuid> = {
|
||||
use crate::shared::models::schema::bots::dsl as bots_dsl;
|
||||
bots_dsl::bots
|
||||
.filter(bots_dsl::is_active.eq(true))
|
||||
.select(bots_dsl::id)
|
||||
.first(&mut db_conn)
|
||||
.optional()
|
||||
.ok()?
|
||||
};
|
||||
|
||||
let active_bot_id = bot_result?;
|
||||
|
||||
let configs: Vec<(String, String)> = {
|
||||
use crate::shared::models::schema::bot_configuration::dsl as cfg_dsl;
|
||||
cfg_dsl::bot_configuration
|
||||
.filter(cfg_dsl::bot_id.eq(active_bot_id))
|
||||
.select((cfg_dsl::config_key, cfg_dsl::config_value))
|
||||
.load(&mut db_conn)
|
||||
.ok()?
|
||||
};
|
||||
|
||||
Some(configs.into_iter().collect::<HashMap<_, _>>())
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some(config)) => config,
|
||||
_ => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get base URL from config or default
|
||||
fn get_base_url(state: &AppState) -> String {
|
||||
// Could read from config, for now use default
|
||||
let _ = state;
|
||||
"http://localhost:8080".to_string()
|
||||
}
|
||||
|
||||
/// Create or get existing OAuth user
|
||||
async fn create_or_get_oauth_user(
|
||||
state: &AppState,
|
||||
user_info: &OAuthUserInfo,
|
||||
) -> anyhow::Result<Uuid> {
|
||||
let conn = state.conn.clone();
|
||||
let provider_id = user_info.provider_id.clone();
|
||||
let provider = user_info.provider.to_string().to_lowercase();
|
||||
let user_email = user_info.email.clone();
|
||||
let display_name = user_info
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "OAuth User".to_string());
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut db_conn = conn
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("DB connection error: {}", e))?;
|
||||
|
||||
use crate::shared::models::schema::users::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
// Try to find existing user by email (if provided)
|
||||
let existing_user: Option<Uuid> = if let Some(ref email_addr) = user_email {
|
||||
users
|
||||
.filter(email.eq(email_addr))
|
||||
.select(id)
|
||||
.first(&mut db_conn)
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {}", e))?
|
||||
} else {
|
||||
// Check by username containing OAuth provider info
|
||||
let oauth_username = format!("{}_{}", provider, provider_id);
|
||||
users
|
||||
.filter(username.eq(&oauth_username))
|
||||
.select(id)
|
||||
.first(&mut db_conn)
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {}", e))?
|
||||
};
|
||||
|
||||
if let Some(user_id) = existing_user {
|
||||
return Ok(user_id);
|
||||
}
|
||||
|
||||
// Create new user
|
||||
let new_user_id = Uuid::new_v4();
|
||||
// Create a username from display name and provider, sanitizing special characters
|
||||
let sanitized_name: String = display_name
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
|
||||
.take(20)
|
||||
.collect();
|
||||
let oauth_username = if sanitized_name.is_empty() {
|
||||
format!("{}_{}", provider, &provider_id[..8.min(provider_id.len())])
|
||||
} else {
|
||||
format!(
|
||||
"{}_{}",
|
||||
sanitized_name,
|
||||
&provider_id[..6.min(provider_id.len())]
|
||||
)
|
||||
};
|
||||
let user_email_value =
|
||||
user_email.unwrap_or_else(|| format!("{}@oauth.local", oauth_username));
|
||||
|
||||
diesel::insert_into(users)
|
||||
.values((
|
||||
id.eq(new_user_id),
|
||||
username.eq(&oauth_username),
|
||||
email.eq(&user_email_value),
|
||||
password_hash.eq("OAUTH_USER_NO_PASSWORD"),
|
||||
is_active.eq(true),
|
||||
is_admin.eq(false),
|
||||
created_at.eq(diesel::dsl::now),
|
||||
updated_at.eq(diesel::dsl::now),
|
||||
))
|
||||
.execute(&mut db_conn)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create user: {}", e))?;
|
||||
|
||||
debug!(
|
||||
"Created OAuth user: {} ({}) for provider {}",
|
||||
oauth_username, user_email_value, provider
|
||||
);
|
||||
|
||||
Ok(new_user_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Task error: {}", e))?
|
||||
}
|
||||
|
||||
/// Create session for authenticated user
|
||||
async fn create_user_session(state: &AppState, user_id: Uuid) -> anyhow::Result<String> {
|
||||
let mut sm = state.session_manager.lock().await;
|
||||
|
||||
// Get first active bot for session
|
||||
let bot_id = {
|
||||
let conn = state.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut db_conn = conn.get().ok()?;
|
||||
use crate::shared::models::schema::bots::dsl::*;
|
||||
use diesel::prelude::*;
|
||||
|
||||
bots.filter(is_active.eq(true))
|
||||
.select(id)
|
||||
.first::<Uuid>(&mut db_conn)
|
||||
.optional()
|
||||
.ok()?
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(Uuid::nil())
|
||||
};
|
||||
|
||||
let session = sm
|
||||
.get_or_create_user_session(user_id, bot_id, "OAuth Login")
|
||||
.map_err(|e| anyhow::anyhow!("Session error: {}", e))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to create session"))?;
|
||||
|
||||
Ok(session.id.to_string())
|
||||
}
|
||||
|
|
@ -14,10 +14,38 @@ pub use super::schema;
|
|||
|
||||
// Also re-export individual tables at this level for convenience
|
||||
pub use super::schema::{
|
||||
basic_tools, bot_configuration, bot_memories, bots, clicks, email_drafts, email_folders,
|
||||
kb_collections, kb_documents, message_history, organizations, session_tool_associations,
|
||||
system_automations, tasks, user_email_accounts, user_kb_associations, user_login_tokens,
|
||||
user_preferences, user_sessions, users,
|
||||
basic_tools,
|
||||
bot_configuration,
|
||||
bot_memories,
|
||||
bots,
|
||||
clicks,
|
||||
// Enterprise email tables (6.1.0_enterprise_suite)
|
||||
distribution_lists,
|
||||
email_auto_responders,
|
||||
email_drafts,
|
||||
email_folders,
|
||||
email_label_assignments,
|
||||
email_labels,
|
||||
email_rules,
|
||||
email_signatures,
|
||||
email_templates,
|
||||
global_email_signatures,
|
||||
kb_collections,
|
||||
kb_documents,
|
||||
message_history,
|
||||
organizations,
|
||||
scheduled_emails,
|
||||
session_tool_associations,
|
||||
shared_mailbox_members,
|
||||
shared_mailboxes,
|
||||
system_automations,
|
||||
tasks,
|
||||
user_email_accounts,
|
||||
user_kb_associations,
|
||||
user_login_tokens,
|
||||
user_preferences,
|
||||
user_sessions,
|
||||
users,
|
||||
};
|
||||
|
||||
// Re-export common types from botlib for convenience
|
||||
|
|
|
|||
|
|
@ -280,6 +280,178 @@ diesel::table! {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enterprise Email Tables (6.1.0_enterprise_suite migration)
|
||||
// ============================================================================
|
||||
|
||||
diesel::table! {
|
||||
global_email_signatures (id) {
|
||||
id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
name -> Varchar,
|
||||
content_html -> Text,
|
||||
content_plain -> Text,
|
||||
position -> Varchar,
|
||||
is_active -> Bool,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
email_signatures (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
bot_id -> Nullable<Uuid>,
|
||||
name -> Varchar,
|
||||
content_html -> Text,
|
||||
content_plain -> Text,
|
||||
is_default -> Bool,
|
||||
is_active -> Bool,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
scheduled_emails (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
to_addresses -> Text,
|
||||
cc_addresses -> Nullable<Text>,
|
||||
bcc_addresses -> Nullable<Text>,
|
||||
subject -> Text,
|
||||
body_html -> Text,
|
||||
body_plain -> Nullable<Text>,
|
||||
attachments_json -> Text,
|
||||
scheduled_at -> Timestamptz,
|
||||
sent_at -> Nullable<Timestamptz>,
|
||||
status -> Varchar,
|
||||
retry_count -> Int4,
|
||||
error_message -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
email_templates (id) {
|
||||
id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
user_id -> Nullable<Uuid>,
|
||||
name -> Varchar,
|
||||
description -> Nullable<Text>,
|
||||
subject_template -> Text,
|
||||
body_html_template -> Text,
|
||||
body_plain_template -> Nullable<Text>,
|
||||
variables_json -> Text,
|
||||
category -> Nullable<Varchar>,
|
||||
is_shared -> Bool,
|
||||
usage_count -> Int4,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
email_auto_responders (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
responder_type -> Varchar,
|
||||
subject -> Text,
|
||||
body_html -> Text,
|
||||
body_plain -> Nullable<Text>,
|
||||
start_date -> Nullable<Timestamptz>,
|
||||
end_date -> Nullable<Timestamptz>,
|
||||
send_to_internal_only -> Bool,
|
||||
exclude_addresses -> Nullable<Text>,
|
||||
is_active -> Bool,
|
||||
stalwart_sieve_id -> Nullable<Varchar>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
email_rules (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
name -> Varchar,
|
||||
priority -> Int4,
|
||||
conditions_json -> Text,
|
||||
actions_json -> Text,
|
||||
stop_processing -> Bool,
|
||||
is_active -> Bool,
|
||||
stalwart_sieve_id -> Nullable<Varchar>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
email_labels (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
name -> Varchar,
|
||||
color -> Varchar,
|
||||
parent_id -> Nullable<Uuid>,
|
||||
is_system -> Bool,
|
||||
created_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
email_label_assignments (id) {
|
||||
id -> Uuid,
|
||||
email_message_id -> Varchar,
|
||||
label_id -> Uuid,
|
||||
assigned_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
distribution_lists (id) {
|
||||
id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
owner_id -> Uuid,
|
||||
name -> Varchar,
|
||||
email_alias -> Nullable<Varchar>,
|
||||
description -> Nullable<Text>,
|
||||
members_json -> Text,
|
||||
is_public -> Bool,
|
||||
stalwart_principal_id -> Nullable<Varchar>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
shared_mailboxes (id) {
|
||||
id -> Uuid,
|
||||
bot_id -> Uuid,
|
||||
email_address -> Varchar,
|
||||
display_name -> Varchar,
|
||||
description -> Nullable<Text>,
|
||||
settings_json -> Text,
|
||||
stalwart_account_id -> Nullable<Varchar>,
|
||||
is_active -> Bool,
|
||||
created_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
shared_mailbox_members (id) {
|
||||
id -> Uuid,
|
||||
mailbox_id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
permission_level -> Varchar,
|
||||
added_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow tables to be joined
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
organizations,
|
||||
|
|
@ -302,4 +474,15 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||
user_preferences,
|
||||
user_login_tokens,
|
||||
tasks,
|
||||
global_email_signatures,
|
||||
email_signatures,
|
||||
scheduled_emails,
|
||||
email_templates,
|
||||
email_auto_responders,
|
||||
email_rules,
|
||||
email_labels,
|
||||
email_label_assignments,
|
||||
distribution_lists,
|
||||
shared_mailboxes,
|
||||
shared_mailbox_members,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize};
|
|||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod stalwart_client;
|
||||
pub mod stalwart_sync;
|
||||
pub mod vectordb;
|
||||
|
||||
// Helper function to extract user from session
|
||||
|
|
|
|||
1423
src/email/stalwart_client.rs
Normal file
1423
src/email/stalwart_client.rs
Normal file
File diff suppressed because it is too large
Load diff
546
src/email/stalwart_sync.rs
Normal file
546
src/email/stalwart_sync.rs
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
//! Stalwart Sync Service
|
||||
//!
|
||||
//! This module provides synchronization between General Bots database tables and
|
||||
//! Stalwart Mail Server. It handles bi-directional sync for:
|
||||
//!
|
||||
//! - Distribution Lists (sync with Stalwart principals)
|
||||
//! - Auto-Responders (sync with Stalwart Sieve scripts)
|
||||
//! - Email Rules/Filters (sync with Stalwart Sieve scripts)
|
||||
//! - Shared Mailboxes (sync with Stalwart group principals)
|
||||
//!
|
||||
//! # Version: 6.1.0
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The sync service follows a "write-through" pattern:
|
||||
//! 1. Create/Update in Stalwart first (source of truth for email delivery)
|
||||
//! 2. Store reference ID in our database (for UI and caching)
|
||||
//! 3. On delete, remove from both systems
|
||||
//!
|
||||
//! This ensures email functionality remains intact even if our DB has issues.
|
||||
|
||||
use super::stalwart_client::{
|
||||
AccountUpdate, AutoResponderConfig, EmailRule, RuleAction, RuleCondition, StalwartClient,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ============================================================================
|
||||
// Data Transfer Objects (matching 6.1.0_enterprise_suite migration)
|
||||
// These are simplified DTOs for the sync layer - not direct ORM mappings
|
||||
// ============================================================================
|
||||
|
||||
/// Distribution list DTO
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DistributionListDto {
|
||||
pub id: Uuid,
|
||||
pub bot_id: Uuid,
|
||||
pub owner_id: Uuid,
|
||||
pub name: String,
|
||||
pub email_alias: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub members: Vec<String>,
|
||||
pub is_public: bool,
|
||||
pub stalwart_principal_id: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// New distribution list for creation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewDistributionList {
|
||||
pub bot_id: Uuid,
|
||||
pub owner_id: Uuid,
|
||||
pub name: String,
|
||||
pub email_alias: String,
|
||||
pub description: Option<String>,
|
||||
pub members: Vec<String>,
|
||||
}
|
||||
|
||||
/// Auto-responder DTO
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutoResponderDto {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub bot_id: Uuid,
|
||||
pub responder_type: String,
|
||||
pub subject: String,
|
||||
pub body_html: String,
|
||||
pub body_plain: Option<String>,
|
||||
pub start_date: Option<DateTime<Utc>>,
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
pub send_to_internal_only: bool,
|
||||
pub exclude_addresses: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub stalwart_sieve_id: Option<String>,
|
||||
}
|
||||
|
||||
/// New auto-responder for creation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewAutoResponder {
|
||||
pub bot_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub subject: String,
|
||||
pub body_html: String,
|
||||
pub body_plain: Option<String>,
|
||||
pub start_date: Option<DateTime<Utc>>,
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
pub only_contacts: bool,
|
||||
}
|
||||
|
||||
/// Email rule DTO
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmailRuleDto {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub bot_id: Uuid,
|
||||
pub name: String,
|
||||
pub priority: i32,
|
||||
pub is_active: bool,
|
||||
pub conditions: Vec<RuleCondition>,
|
||||
pub actions: Vec<RuleAction>,
|
||||
pub stop_processing: bool,
|
||||
pub stalwart_sieve_id: Option<String>,
|
||||
}
|
||||
|
||||
/// New email rule for creation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewEmailRule {
|
||||
pub bot_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
pub priority: i32,
|
||||
pub conditions: Vec<RuleCondition>,
|
||||
pub actions: Vec<RuleAction>,
|
||||
pub stop_processing: bool,
|
||||
}
|
||||
|
||||
/// Shared mailbox DTO
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedMailboxDto {
|
||||
pub id: Uuid,
|
||||
pub bot_id: Uuid,
|
||||
pub email_address: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub stalwart_account_id: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// Shared mailbox member DTO
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedMailboxMemberDto {
|
||||
pub id: Uuid,
|
||||
pub mailbox_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub permission_level: String,
|
||||
pub added_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync Service
|
||||
// ============================================================================
|
||||
|
||||
/// Service for synchronizing data between General Bots and Stalwart
|
||||
///
|
||||
/// This service handles the synchronization logic between our database
|
||||
/// and Stalwart Mail Server. It requires a database pool and Stalwart client.
|
||||
///
|
||||
/// Note: Database operations should be implemented by the caller using this
|
||||
/// service - this keeps the sync logic separate from ORM details.
|
||||
pub struct StalwartSyncService {
|
||||
stalwart: Arc<StalwartClient>,
|
||||
}
|
||||
|
||||
impl StalwartSyncService {
|
||||
/// Create a new sync service
|
||||
pub fn new(stalwart_client: Arc<StalwartClient>) -> Self {
|
||||
Self {
|
||||
stalwart: stalwart_client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reference to the Stalwart client
|
||||
pub fn stalwart(&self) -> &StalwartClient {
|
||||
&self.stalwart
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Distribution Lists
|
||||
// ========================================================================
|
||||
|
||||
/// Create a distribution list in Stalwart
|
||||
/// Returns the Stalwart principal ID to store in database
|
||||
pub async fn create_distribution_list_in_stalwart(
|
||||
&self,
|
||||
list: &NewDistributionList,
|
||||
) -> Result<String> {
|
||||
info!(
|
||||
"Creating distribution list '{}' with email '{}' in Stalwart",
|
||||
list.name, list.email_alias
|
||||
);
|
||||
|
||||
let stalwart_id = self
|
||||
.stalwart
|
||||
.create_distribution_list(&list.name, &list.email_alias, list.members.clone())
|
||||
.await
|
||||
.context("Failed to create distribution list in Stalwart")?;
|
||||
|
||||
info!(
|
||||
"Created distribution list in Stalwart with ID: {}",
|
||||
stalwart_id
|
||||
);
|
||||
|
||||
Ok(stalwart_id.to_string())
|
||||
}
|
||||
|
||||
/// Update a distribution list in Stalwart
|
||||
pub async fn update_distribution_list_in_stalwart(
|
||||
&self,
|
||||
stalwart_id: &str,
|
||||
name: Option<&str>,
|
||||
members: Option<&[String]>,
|
||||
) -> Result<()> {
|
||||
let mut updates = Vec::new();
|
||||
|
||||
if let Some(n) = name {
|
||||
updates.push(AccountUpdate::set("description", n.to_string()));
|
||||
}
|
||||
|
||||
if let Some(m) = members {
|
||||
// Clear and re-add members
|
||||
updates.push(AccountUpdate::clear("members"));
|
||||
for member in m {
|
||||
updates.push(AccountUpdate::add_item("members", member.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !updates.is_empty() {
|
||||
self.stalwart
|
||||
.update_account(stalwart_id, updates)
|
||||
.await
|
||||
.context("Failed to update distribution list in Stalwart")?;
|
||||
}
|
||||
|
||||
info!("Updated distribution list {} in Stalwart", stalwart_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a distribution list from Stalwart
|
||||
pub async fn delete_distribution_list_from_stalwart(&self, stalwart_id: &str) -> Result<()> {
|
||||
self.stalwart
|
||||
.delete_account(stalwart_id)
|
||||
.await
|
||||
.context("Failed to delete distribution list from Stalwart")?;
|
||||
|
||||
info!("Deleted distribution list {} from Stalwart", stalwart_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Auto-Responders
|
||||
// ========================================================================
|
||||
|
||||
/// Create/update an auto-responder in Stalwart via Sieve script
|
||||
/// Returns the Sieve script ID to store in database
|
||||
pub async fn set_auto_responder_in_stalwart(
|
||||
&self,
|
||||
account_id: &str,
|
||||
responder: &NewAutoResponder,
|
||||
) -> Result<String> {
|
||||
info!(
|
||||
"Setting auto-responder for user {} in account {}",
|
||||
responder.user_id, account_id
|
||||
);
|
||||
|
||||
let config = AutoResponderConfig {
|
||||
enabled: true,
|
||||
subject: responder.subject.clone(),
|
||||
body_plain: responder.body_plain.clone().unwrap_or_default(),
|
||||
body_html: Some(responder.body_html.clone()),
|
||||
start_date: responder.start_date.map(|dt| dt.date_naive()),
|
||||
end_date: responder.end_date.map(|dt| dt.date_naive()),
|
||||
only_contacts: responder.only_contacts,
|
||||
vacation_days: 1,
|
||||
};
|
||||
|
||||
let sieve_id = self
|
||||
.stalwart
|
||||
.set_auto_responder(account_id, &config)
|
||||
.await
|
||||
.context("Failed to set auto-responder in Stalwart")?;
|
||||
|
||||
info!("Created auto-responder Sieve script: {}", sieve_id);
|
||||
Ok(sieve_id)
|
||||
}
|
||||
|
||||
/// Disable an auto-responder in Stalwart
|
||||
pub async fn disable_auto_responder_in_stalwart(&self, account_id: &str) -> Result<()> {
|
||||
self.stalwart
|
||||
.disable_auto_responder(account_id)
|
||||
.await
|
||||
.context("Failed to disable auto-responder in Stalwart")?;
|
||||
|
||||
info!("Disabled auto-responder for account {}", account_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Email Rules/Filters
|
||||
// ========================================================================
|
||||
|
||||
/// Create/update an email rule in Stalwart via Sieve script
|
||||
/// Returns the Sieve script ID to store in database
|
||||
pub async fn set_email_rule_in_stalwart(
|
||||
&self,
|
||||
account_id: &str,
|
||||
rule: &NewEmailRule,
|
||||
rule_id: Uuid,
|
||||
) -> Result<String> {
|
||||
info!(
|
||||
"Setting email rule '{}' for user {} in account {}",
|
||||
rule.name, rule.user_id, account_id
|
||||
);
|
||||
|
||||
let stalwart_rule = EmailRule {
|
||||
id: rule_id.to_string(),
|
||||
name: rule.name.clone(),
|
||||
priority: rule.priority,
|
||||
enabled: true,
|
||||
conditions: rule.conditions.clone(),
|
||||
actions: rule.actions.clone(),
|
||||
stop_processing: rule.stop_processing,
|
||||
};
|
||||
|
||||
let sieve_id = self
|
||||
.stalwart
|
||||
.set_filter_rule(account_id, &stalwart_rule)
|
||||
.await
|
||||
.context("Failed to set email rule in Stalwart")?;
|
||||
|
||||
info!("Created email rule Sieve script: {}", sieve_id);
|
||||
Ok(sieve_id)
|
||||
}
|
||||
|
||||
/// Delete an email rule from Stalwart
|
||||
pub async fn delete_email_rule_from_stalwart(
|
||||
&self,
|
||||
account_id: &str,
|
||||
rule_id: &str,
|
||||
) -> Result<()> {
|
||||
self.stalwart
|
||||
.delete_filter_rule(account_id, rule_id)
|
||||
.await
|
||||
.context("Failed to delete email rule from Stalwart")?;
|
||||
|
||||
info!("Deleted email rule {} from Stalwart", rule_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Shared Mailboxes
|
||||
// ========================================================================
|
||||
|
||||
/// Create a shared mailbox in Stalwart
|
||||
/// Returns the Stalwart account ID to store in database
|
||||
pub async fn create_shared_mailbox_in_stalwart(
|
||||
&self,
|
||||
name: &str,
|
||||
email: &str,
|
||||
initial_members: Vec<String>,
|
||||
) -> Result<String> {
|
||||
info!("Creating shared mailbox '{}' with email '{}'", name, email);
|
||||
|
||||
let stalwart_id = self
|
||||
.stalwart
|
||||
.create_shared_mailbox(name, email, initial_members)
|
||||
.await
|
||||
.context("Failed to create shared mailbox in Stalwart")?;
|
||||
|
||||
info!(
|
||||
"Created shared mailbox in Stalwart with ID: {}",
|
||||
stalwart_id
|
||||
);
|
||||
|
||||
Ok(stalwart_id.to_string())
|
||||
}
|
||||
|
||||
/// Add a member to a shared mailbox in Stalwart
|
||||
pub async fn add_shared_mailbox_member_in_stalwart(
|
||||
&self,
|
||||
stalwart_id: &str,
|
||||
member_email: &str,
|
||||
) -> Result<()> {
|
||||
self.stalwart
|
||||
.add_members(stalwart_id, vec![member_email.to_string()])
|
||||
.await
|
||||
.context("Failed to add member to shared mailbox in Stalwart")?;
|
||||
|
||||
info!(
|
||||
"Added member {} to shared mailbox {} in Stalwart",
|
||||
member_email, stalwart_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a member from a shared mailbox in Stalwart
|
||||
pub async fn remove_shared_mailbox_member_in_stalwart(
|
||||
&self,
|
||||
stalwart_id: &str,
|
||||
member_email: &str,
|
||||
) -> Result<()> {
|
||||
self.stalwart
|
||||
.remove_members(stalwart_id, vec![member_email.to_string()])
|
||||
.await
|
||||
.context("Failed to remove member from shared mailbox in Stalwart")?;
|
||||
|
||||
info!(
|
||||
"Removed member {} from shared mailbox {} in Stalwart",
|
||||
member_email, stalwart_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a shared mailbox from Stalwart
|
||||
pub async fn delete_shared_mailbox_from_stalwart(&self, stalwart_id: &str) -> Result<()> {
|
||||
self.stalwart
|
||||
.delete_account(stalwart_id)
|
||||
.await
|
||||
.context("Failed to delete shared mailbox from Stalwart")?;
|
||||
|
||||
info!("Deleted shared mailbox {} from Stalwart", stalwart_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Bulk Sync Operations
|
||||
// ========================================================================
|
||||
|
||||
/// Sync a distribution list to Stalwart (for recovery/migration)
|
||||
/// Returns the Stalwart principal ID
|
||||
pub async fn sync_distribution_list_to_stalwart(
|
||||
&self,
|
||||
name: &str,
|
||||
email_alias: &str,
|
||||
members: Vec<String>,
|
||||
) -> Result<String> {
|
||||
match self
|
||||
.stalwart
|
||||
.create_distribution_list(name, email_alias, members.clone())
|
||||
.await
|
||||
{
|
||||
Ok(stalwart_id) => {
|
||||
info!(
|
||||
"Synced distribution list '{}' to Stalwart with ID: {}",
|
||||
name, stalwart_id
|
||||
);
|
||||
Ok(stalwart_id.to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to sync distribution list '{}' to Stalwart: {}",
|
||||
name, e
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify Stalwart connectivity
|
||||
pub async fn health_check(&self) -> Result<bool> {
|
||||
self.stalwart.health_check().await
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_distribution_list() {
|
||||
let list = NewDistributionList {
|
||||
bot_id: Uuid::new_v4(),
|
||||
owner_id: Uuid::new_v4(),
|
||||
name: "Test List".to_string(),
|
||||
email_alias: "test@example.com".to_string(),
|
||||
description: Some("A test list".to_string()),
|
||||
members: vec![
|
||||
"user1@example.com".to_string(),
|
||||
"user2@example.com".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(list.name, "Test List");
|
||||
assert_eq!(list.members.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_auto_responder() {
|
||||
let responder = NewAutoResponder {
|
||||
bot_id: Uuid::new_v4(),
|
||||
user_id: Uuid::new_v4(),
|
||||
subject: "Out of Office".to_string(),
|
||||
body_html: "<p>I am away</p>".to_string(),
|
||||
body_plain: Some("I am away".to_string()),
|
||||
start_date: Some(Utc::now()),
|
||||
end_date: None,
|
||||
only_contacts: false,
|
||||
};
|
||||
|
||||
assert_eq!(responder.subject, "Out of Office");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_email_rule() {
|
||||
let rule = NewEmailRule {
|
||||
bot_id: Uuid::new_v4(),
|
||||
user_id: Uuid::new_v4(),
|
||||
name: "Move newsletters".to_string(),
|
||||
priority: 10,
|
||||
conditions: vec![RuleCondition {
|
||||
field: "from".to_string(),
|
||||
operator: "contains".to_string(),
|
||||
value: "newsletter".to_string(),
|
||||
header_name: None,
|
||||
case_sensitive: false,
|
||||
}],
|
||||
actions: vec![RuleAction {
|
||||
action_type: "move".to_string(),
|
||||
value: "Newsletters".to_string(),
|
||||
}],
|
||||
stop_processing: true,
|
||||
};
|
||||
|
||||
assert_eq!(rule.name, "Move newsletters");
|
||||
assert_eq!(rule.conditions.len(), 1);
|
||||
assert_eq!(rule.actions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distribution_list_dto() {
|
||||
let dto = DistributionListDto {
|
||||
id: Uuid::new_v4(),
|
||||
bot_id: Uuid::new_v4(),
|
||||
owner_id: Uuid::new_v4(),
|
||||
name: "Sales Team".to_string(),
|
||||
email_alias: Some("sales@example.com".to_string()),
|
||||
description: Some("Sales distribution list".to_string()),
|
||||
members: vec!["alice@example.com".to_string()],
|
||||
is_public: false,
|
||||
stalwart_principal_id: Some("123".to_string()),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
assert_eq!(dto.name, "Sales Team");
|
||||
assert!(dto.stalwart_principal_id.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +183,9 @@ async fn run_axum_server(
|
|||
api_router = api_router.merge(botserver::sources::configure_sources_routes());
|
||||
api_router = api_router.merge(botserver::designer::configure_designer_routes());
|
||||
|
||||
// Add OAuth authentication routes
|
||||
api_router = api_router.merge(crate::core::oauth::routes::configure());
|
||||
|
||||
let app = Router::new()
|
||||
// API routes
|
||||
.merge(api_router.with_state(app_state.clone()))
|
||||
|
|
|
|||
|
|
@ -180,19 +180,70 @@
|
|||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.oauth-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-oauth {
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-oauth:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-oauth.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-oauth svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Provider-specific colors on hover */
|
||||
.btn-oauth-google:hover {
|
||||
border-color: #ea4335;
|
||||
color: #ea4335;
|
||||
}
|
||||
|
||||
.btn-oauth-discord:hover {
|
||||
border-color: #5865f2;
|
||||
color: #5865f2;
|
||||
}
|
||||
|
||||
.btn-oauth-reddit:hover {
|
||||
border-color: #ff4500;
|
||||
color: #ff4500;
|
||||
}
|
||||
|
||||
.btn-oauth-twitter:hover {
|
||||
border-color: #1da1f2;
|
||||
color: #1da1f2;
|
||||
}
|
||||
|
||||
.btn-oauth-microsoft:hover {
|
||||
border-color: #00a4ef;
|
||||
color: #00a4ef;
|
||||
}
|
||||
|
||||
.btn-oauth-facebook:hover {
|
||||
border-color: #1877f2;
|
||||
color: #1877f2;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
|
|
@ -286,6 +337,24 @@
|
|||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.oauth-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.oauth-loading {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.no-oauth {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -383,14 +452,140 @@
|
|||
<span>or continue with</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-oauth"
|
||||
hx-get="/auth/oauth/zitadel"
|
||||
hx-target="body"
|
||||
>
|
||||
🔐 Sign in with Zitadel
|
||||
</button>
|
||||
<div class="oauth-section" id="oauth-section">
|
||||
<div class="oauth-loading" id="oauth-loading">
|
||||
Loading login options...
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="oauth-grid"
|
||||
id="oauth-buttons"
|
||||
style="display: none"
|
||||
>
|
||||
<!-- Google -->
|
||||
<a
|
||||
href="/auth/oauth/google{% if redirect_url %}?redirect={{ redirect_url }}{% endif %}"
|
||||
class="btn btn-oauth btn-oauth-google hidden"
|
||||
id="btn-google"
|
||||
title="Sign in with Google"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</a>
|
||||
|
||||
<!-- Microsoft -->
|
||||
<a
|
||||
href="/auth/oauth/microsoft{% if redirect_url %}?redirect={{ redirect_url }}{% endif %}"
|
||||
class="btn btn-oauth btn-oauth-microsoft hidden"
|
||||
id="btn-microsoft"
|
||||
title="Sign in with Microsoft"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M11.4 24H0V12.6h11.4V24z"
|
||||
fill="#00A4EF"
|
||||
/>
|
||||
<path
|
||||
d="M24 24H12.6V12.6H24V24z"
|
||||
fill="#FFB900"
|
||||
/>
|
||||
<path
|
||||
d="M11.4 11.4H0V0h11.4v11.4z"
|
||||
fill="#F25022"
|
||||
/>
|
||||
<path
|
||||
d="M24 11.4H12.6V0H24v11.4z"
|
||||
fill="#7FBA00"
|
||||
/>
|
||||
</svg>
|
||||
Microsoft
|
||||
</a>
|
||||
|
||||
<!-- Discord -->
|
||||
<a
|
||||
href="/auth/oauth/discord{% if redirect_url %}?redirect={{ redirect_url }}{% endif %}"
|
||||
class="btn btn-oauth btn-oauth-discord hidden"
|
||||
id="btn-discord"
|
||||
title="Sign in with Discord"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
fill="#5865F2"
|
||||
/>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
|
||||
<!-- Facebook -->
|
||||
<a
|
||||
href="/auth/oauth/facebook{% if redirect_url %}?redirect={{ redirect_url }}{% endif %}"
|
||||
class="btn btn-oauth btn-oauth-facebook hidden"
|
||||
id="btn-facebook"
|
||||
title="Sign in with Facebook"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
fill="#1877F2"
|
||||
/>
|
||||
</svg>
|
||||
Facebook
|
||||
</a>
|
||||
|
||||
<!-- Twitter/X -->
|
||||
<a
|
||||
href="/auth/oauth/twitter{% if redirect_url %}?redirect={{ redirect_url }}{% endif %}"
|
||||
class="btn btn-oauth btn-oauth-twitter hidden"
|
||||
id="btn-twitter"
|
||||
title="Sign in with Twitter/X"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
Twitter
|
||||
</a>
|
||||
|
||||
<!-- Reddit -->
|
||||
<a
|
||||
href="/auth/oauth/reddit{% if redirect_url %}?redirect={{ redirect_url }}{% endif %}"
|
||||
class="btn btn-oauth btn-oauth-reddit hidden"
|
||||
id="btn-reddit"
|
||||
title="Sign in with Reddit"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
|
||||
fill="#FF4500"
|
||||
/>
|
||||
</svg>
|
||||
Reddit
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="no-oauth" id="no-oauth" style="display: none">
|
||||
No social login providers configured
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="footer-links">
|
||||
|
|
@ -405,55 +600,103 @@
|
|||
|
||||
<script>
|
||||
// Theme management
|
||||
function init</span>Theme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem("theme") || "light";
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
const currentTheme =
|
||||
document.documentElement.getAttribute("data-theme");
|
||||
const newTheme = currentTheme === "light" ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
updateThemeIcon(newTheme);
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = document.getElementById('theme-icon');
|
||||
icon.textContent = theme === 'light' ? '🌙' : '☀️';
|
||||
const icon = document.getElementById("theme-icon");
|
||||
icon.textContent = theme === "light" ? "🌙" : "☀️";
|
||||
}
|
||||
|
||||
// Check if in development mode
|
||||
async function checkDevMode() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/mode');
|
||||
const response = await fetch("/api/auth/mode");
|
||||
const data = await response.json();
|
||||
if (data.mode === 'development') {
|
||||
document.getElementById('dev-mode-banner').style.display = 'block';
|
||||
document.body.style.paddingTop = '2.5rem';
|
||||
if (data.mode === "development") {
|
||||
document.getElementById(
|
||||
"dev-mode-banner",
|
||||
).style.display = "block";
|
||||
document.body.style.paddingTop = "2.5rem";
|
||||
}
|
||||
} catch (err) {
|
||||
// Assume dev mode if can't check
|
||||
document.getElementById('dev-mode-banner').style.display = 'block';
|
||||
document.body.style.paddingTop = '2.5rem';
|
||||
// Ignore - assume production mode
|
||||
}
|
||||
}
|
||||
|
||||
// Load enabled OAuth providers
|
||||
async function loadOAuthProviders() {
|
||||
const loadingEl = document.getElementById("oauth-loading");
|
||||
const buttonsEl = document.getElementById("oauth-buttons");
|
||||
const noOAuthEl = document.getElementById("no-oauth");
|
||||
|
||||
try {
|
||||
const response = await fetch("/auth/oauth/providers");
|
||||
const data = await response.json();
|
||||
|
||||
loadingEl.style.display = "none";
|
||||
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
buttonsEl.style.display = "grid";
|
||||
|
||||
// Show buttons for enabled providers
|
||||
data.providers.forEach((provider) => {
|
||||
const btn = document.getElementById(
|
||||
"btn-" + provider.id,
|
||||
);
|
||||
if (btn) {
|
||||
btn.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Check if any buttons are visible
|
||||
const visibleButtons =
|
||||
buttonsEl.querySelectorAll("a:not(.hidden)");
|
||||
if (visibleButtons.length === 0) {
|
||||
buttonsEl.style.display = "none";
|
||||
noOAuthEl.style.display = "block";
|
||||
}
|
||||
} else {
|
||||
noOAuthEl.style.display = "block";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load OAuth providers:", err);
|
||||
loadingEl.style.display = "none";
|
||||
// Show all buttons as fallback (they'll redirect to error page if not configured)
|
||||
buttonsEl.style.display = "grid";
|
||||
buttonsEl.querySelectorAll("a").forEach((btn) => {
|
||||
btn.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initTheme();
|
||||
checkDevMode();
|
||||
loadOAuthProviders();
|
||||
|
||||
// Handle form validation
|
||||
const form = document.getElementById('login-form');
|
||||
form.addEventListener('submit', (e) => {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const form = document.getElementById("login-form");
|
||||
form.addEventListener("submit", (e) => {
|
||||
const email = document.getElementById("email").value;
|
||||
const password = document.getElementById("password").value;
|
||||
|
||||
if (!email || !password) {
|
||||
e.preventDefault();
|
||||
showError('Please fill in all fields');
|
||||
showError("Please fill in all fields");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
|
@ -461,23 +704,26 @@
|
|||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
const container = document.getElementById('message-container');
|
||||
const container = document.getElementById("message-container");
|
||||
container.innerHTML = `<div class="error-message">${message}</div>`;
|
||||
}
|
||||
|
||||
// Handle HTMX events
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
document.body.addEventListener("htmx:afterRequest", (event) => {
|
||||
if (event.detail.xhr.status === 200) {
|
||||
// Check if we got a redirect header
|
||||
const redirect = event.detail.xhr.getResponseHeader('HX-Redirect');
|
||||
const redirect =
|
||||
event.detail.xhr.getResponseHeader("HX-Redirect");
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', (event) => {
|
||||
showError('Authentication failed. Please check your credentials.');
|
||||
document.body.addEventListener("htmx:responseError", (event) => {
|
||||
showError(
|
||||
"Authentication failed. Please check your credentials.",
|
||||
);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
name,value
|
||||
,
|
||||
# ============================================================================
|
||||
# SERVER CONFIGURATION
|
||||
# ============================================================================
|
||||
server_host,0.0.0.0
|
||||
server_port,8080
|
||||
sites_root,/tmp
|
||||
,
|
||||
# ============================================================================
|
||||
# LLM CONFIGURATION
|
||||
# ============================================================================
|
||||
llm-key,none
|
||||
llm-url,http://localhost:8081
|
||||
llm-model,../../../../data/llm/DeepSeek-R1-Distill-Qwen-1.5B-Q3_K_M.gguf
|
||||
|
|
@ -17,9 +23,15 @@ episodic-memory-threshold,4
|
|||
,
|
||||
mcp-server,false
|
||||
,
|
||||
# ============================================================================
|
||||
# EMBEDDING CONFIGURATION
|
||||
# ============================================================================
|
||||
embedding-url,http://localhost:8082
|
||||
embedding-model,../../../../data/llm/bge-small-en-v1.5-f32.gguf
|
||||
,
|
||||
# ============================================================================
|
||||
# LLM SERVER CONFIGURATION
|
||||
# ============================================================================
|
||||
llm-server,false
|
||||
llm-server-path,botserver-stack/bin/llm/build/bin
|
||||
llm-server-host,0.0.0.0
|
||||
|
|
@ -32,32 +44,45 @@ llm-server-parallel,6
|
|||
llm-server-cont-batching,true
|
||||
llm-server-mlock,false
|
||||
llm-server-no-mmap,false
|
||||
,
|
||||
,
|
||||
# ============================================================================
|
||||
# EMAIL CONFIGURATION
|
||||
# ============================================================================
|
||||
email-from,from@domain.com
|
||||
email-server,mail.domain.com
|
||||
email-port,587
|
||||
email-user,user@domain.com
|
||||
email-pass,
|
||||
,
|
||||
# ============================================================================
|
||||
# DATABASE CONFIGURATION
|
||||
# ============================================================================
|
||||
custom-server,localhost
|
||||
custom-port,5432
|
||||
custom-database,mycustomdb
|
||||
custom-username,
|
||||
custom-password,
|
||||
,
|
||||
# ============================================================================
|
||||
# WEBSITE CRAWLER CONFIGURATION
|
||||
# ============================================================================
|
||||
website-expires,1d
|
||||
website-max-depth,3
|
||||
website-max-pages,100
|
||||
|
||||
|
||||
,
|
||||
# ============================================================================
|
||||
# IMAGE GENERATOR CONFIGURATION
|
||||
# ============================================================================
|
||||
image-generator-model,../../../../data/diffusion/sd_turbo_f16.gguf
|
||||
image-generator-steps,4
|
||||
image-generator-width,512
|
||||
image-generator-height,512
|
||||
image-generator-gpu-layers,20
|
||||
image-generator-batch-size,1
|
||||
|
||||
,
|
||||
# ============================================================================
|
||||
# VIDEO GENERATOR CONFIGURATION
|
||||
# ============================================================================
|
||||
video-generator-model,../../../../data/diffusion/zeroscope_v2_576w
|
||||
video-generator-frames,24
|
||||
video-generator-fps,8
|
||||
|
|
@ -65,9 +90,139 @@ video-generator-width,320
|
|||
video-generator-height,576
|
||||
video-generator-gpu-layers,15
|
||||
video-generator-batch-size,1
|
||||
|
||||
,
|
||||
# ============================================================================
|
||||
# BOTMODELS CONFIGURATION
|
||||
# ============================================================================
|
||||
botmodels-enabled,true
|
||||
botmodels-host,0.0.0.0
|
||||
botmodels-port,8085
|
||||
|
||||
,
|
||||
default-generator,all
|
||||
,
|
||||
# ============================================================================
|
||||
# OAUTH AUTHENTICATION CONFIGURATION
|
||||
# ============================================================================
|
||||
# Enable social login providers by setting the corresponding -enabled flag
|
||||
# to "true" and providing valid client credentials.
|
||||
#
|
||||
# Each provider requires:
|
||||
# - oauth-{provider}-enabled: Set to "true" to enable the provider
|
||||
# - oauth-{provider}-client-id: The Client ID from the provider
|
||||
# - oauth-{provider}-client-secret: The Client Secret from the provider
|
||||
# - oauth-{provider}-redirect-uri: (Optional) Custom callback URL
|
||||
#
|
||||
# Default redirect URI format: http://your-domain/auth/oauth/{provider}/callback
|
||||
# ============================================================================
|
||||
,
|
||||
# ----------------------------------------------------------------------------
|
||||
# GOOGLE OAUTH
|
||||
# ----------------------------------------------------------------------------
|
||||
# Setup Instructions:
|
||||
# 1. Go to https://console.cloud.google.com/apis/credentials
|
||||
# 2. Create a new project or select existing
|
||||
# 3. Click "Create Credentials" > "OAuth client ID"
|
||||
# 4. Select "Web application" as application type
|
||||
# 5. Add authorized redirect URI: http://your-domain/auth/oauth/google/callback
|
||||
# 6. Copy the Client ID and Client Secret below
|
||||
#
|
||||
# Documentation: https://developers.google.com/identity/protocols/oauth2/web-server
|
||||
# ----------------------------------------------------------------------------
|
||||
oauth-google-enabled,false
|
||||
oauth-google-client-id,
|
||||
oauth-google-client-secret,
|
||||
oauth-google-redirect-uri,
|
||||
,
|
||||
# ----------------------------------------------------------------------------
|
||||
# MICROSOFT OAUTH (Azure AD)
|
||||
# ----------------------------------------------------------------------------
|
||||
# Setup Instructions:
|
||||
# 1. Go to https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade
|
||||
# 2. Click "New registration"
|
||||
# 3. Enter application name and select "Accounts in any organizational directory and personal Microsoft accounts"
|
||||
# 4. Add redirect URI: http://your-domain/auth/oauth/microsoft/callback (Web platform)
|
||||
# 5. Go to "Certificates & secrets" > "New client secret"
|
||||
# 6. Copy the Application (client) ID and secret value below
|
||||
#
|
||||
# Documentation: https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app
|
||||
# ----------------------------------------------------------------------------
|
||||
oauth-microsoft-enabled,false
|
||||
oauth-microsoft-client-id,
|
||||
oauth-microsoft-client-secret,
|
||||
oauth-microsoft-redirect-uri,
|
||||
,
|
||||
# ----------------------------------------------------------------------------
|
||||
# DISCORD OAUTH
|
||||
# ----------------------------------------------------------------------------
|
||||
# Setup Instructions:
|
||||
# 1. Go to https://discord.com/developers/applications
|
||||
# 2. Click "New Application" and enter a name
|
||||
# 3. Go to "OAuth2" in the left sidebar
|
||||
# 4. Add redirect URL: http://your-domain/auth/oauth/discord/callback
|
||||
# 5. Copy the Client ID and Client Secret below
|
||||
# 6. Under "OAuth2 URL Generator", select scopes: identify, email
|
||||
#
|
||||
# Documentation: https://discord.com/developers/docs/topics/oauth2
|
||||
# ----------------------------------------------------------------------------
|
||||
oauth-discord-enabled,false
|
||||
oauth-discord-client-id,
|
||||
oauth-discord-client-secret,
|
||||
oauth-discord-redirect-uri,
|
||||
,
|
||||
# ----------------------------------------------------------------------------
|
||||
# FACEBOOK OAUTH
|
||||
# ----------------------------------------------------------------------------
|
||||
# Setup Instructions:
|
||||
# 1. Go to https://developers.facebook.com/apps/
|
||||
# 2. Click "Create App" > Select "Consumer" or "Business"
|
||||
# 3. Add "Facebook Login" product to your app
|
||||
# 4. Go to Facebook Login > Settings
|
||||
# 5. Add Valid OAuth Redirect URI: http://your-domain/auth/oauth/facebook/callback
|
||||
# 6. Go to Settings > Basic to get App ID and App Secret
|
||||
#
|
||||
# Documentation: https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow
|
||||
# ----------------------------------------------------------------------------
|
||||
oauth-facebook-enabled,false
|
||||
oauth-facebook-client-id,
|
||||
oauth-facebook-client-secret,
|
||||
oauth-facebook-redirect-uri,
|
||||
,
|
||||
# ----------------------------------------------------------------------------
|
||||
# TWITTER (X) OAUTH 2.0
|
||||
# ----------------------------------------------------------------------------
|
||||
# Setup Instructions:
|
||||
# 1. Go to https://developer.twitter.com/en/portal/dashboard
|
||||
# 2. Create a new project and app (or use existing)
|
||||
# 3. Go to your app's "Keys and tokens" tab
|
||||
# 4. Under "OAuth 2.0 Client ID and Client Secret", generate credentials
|
||||
# 5. Go to "User authentication settings" and configure:
|
||||
# - Enable OAuth 2.0
|
||||
# - Type: Web App
|
||||
# - Callback URL: http://your-domain/auth/oauth/twitter/callback
|
||||
# 6. Copy Client ID and Client Secret below
|
||||
#
|
||||
# Note: Twitter requires OAuth 2.0 with PKCE for web apps
|
||||
# Documentation: https://developer.twitter.com/en/docs/authentication/oauth-2-0
|
||||
# ----------------------------------------------------------------------------
|
||||
oauth-twitter-enabled,false
|
||||
oauth-twitter-client-id,
|
||||
oauth-twitter-client-secret,
|
||||
oauth-twitter-redirect-uri,
|
||||
,
|
||||
# ----------------------------------------------------------------------------
|
||||
# REDDIT OAUTH
|
||||
# ----------------------------------------------------------------------------
|
||||
# Setup Instructions:
|
||||
# 1. Go to https://www.reddit.com/prefs/apps
|
||||
# 2. Click "create another app..." at the bottom
|
||||
# 3. Select "web app" as the application type
|
||||
# 4. Enter redirect URI: http://your-domain/auth/oauth/reddit/callback
|
||||
# 5. Copy the client ID (under app name) and secret below
|
||||
#
|
||||
# Note: Reddit requires Basic Auth for token exchange and custom User-Agent
|
||||
# Documentation: https://github.com/reddit-archive/reddit/wiki/OAuth2
|
||||
# ----------------------------------------------------------------------------
|
||||
oauth-reddit-enabled,false
|
||||
oauth-reddit-client-id,
|
||||
oauth-reddit-client-secret,
|
||||
oauth-reddit-redirect-uri,
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 107 and column 6.
|
Loading…
Add table
Reference in a new issue