feat: added desktop sso endpoints
This commit is contained in:
161
VIBNCODE_PLAN.md
Normal file
161
VIBNCODE_PLAN.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# VibnCode: Cloud-Powered Agent Desktop IDE Architecture & Implementation Plan
|
||||
|
||||
**Project Name:** `vibncode` (formerly TalkCody)
|
||||
**Target Architecture:** Desktop Thin Client with Monaco + Native Cloud Hosting Integration
|
||||
**Backend Platform:** Vibnai Cloud Infrastructure (`vibn-frontend`, `vibn-agent-runner`, Gitea, Coolify)
|
||||
**Status File Location:** `master-ai/VIBNCODE_PLAN.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. System High-Level Architecture Diagram
|
||||
|
||||
`vibncode` functions as a high-fidelity window into your cloud-hosted workspace, routing all actions, skills, and terminal commands to the `vibn-agent-runner` and Gitea/Coolify backend.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
%% Styling
|
||||
classDef client fill:#E1F5FE,stroke:#0288D1,stroke-width:2px;
|
||||
classDef runner fill:#EDE7F6,stroke:#5E35B1,stroke-width:2px;
|
||||
classDef infra fill:#E8F5E9,stroke:#2E7D32,stroke-width:2px;
|
||||
|
||||
subgraph Client [Desktop client - vibncode]
|
||||
UI[React 19 / Monaco Editor UI]
|
||||
State[Zustand Stores / Local SQLite DB Cache]
|
||||
Tauri[Tauri v2 App Wrapper]
|
||||
end
|
||||
|
||||
subgraph CloudRunner [Cloud Workspace - vibn-agent-runner]
|
||||
Runner[Agent Session Runner Engine]
|
||||
Workspace[Sandboxed Project Dir: /workspaces]
|
||||
Tools[Vibn Platform & Sentry Tools]
|
||||
end
|
||||
|
||||
subgraph Infrastructure [Vibnai Platform]
|
||||
DB[(PostgreSQL Database)]
|
||||
WebAPI[Next.js API Server: vibn-frontend]
|
||||
Gitea[(Gitea Git Server)]
|
||||
Coolify[Coolify Server Hosting]
|
||||
end
|
||||
|
||||
%% Connections
|
||||
UI <-->|1. Event Streams & File Sync| WebAPI
|
||||
UI -->|2. Start/Cancel Execution| WebAPI
|
||||
WebAPI -->|3. Route Exec Request| Runner
|
||||
Runner <-->|4. Code Edits & Shell Runs| Workspace
|
||||
Runner -->|5. Save Output & File Changes| WebAPI
|
||||
WebAPI <-->|6. Write State & Logs| DB
|
||||
Runner <-->|7. Push/Pull/Clone Code| Gitea
|
||||
Runner -->|8. Manage & Deploy Apps| Coolify
|
||||
Workspace -->|9. Pull Guidelines| Gitea
|
||||
|
||||
class UI,State,Tauri client;
|
||||
class Runner,Workspace,Tools runner;
|
||||
class DB,WebAPI,Gitea,Coolify,Workspace infra;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Structural Requirements & Feature Specification
|
||||
|
||||
### 2.1 Rebranding to `vibncode`
|
||||
* All codebase identifiers, folder structures, logs, config registries, and binary product properties are renamed from `talkcody` to `vibncode` / `VibnCode` to prevent trademark leaks.
|
||||
* The application bundle identifier is modified to `com.vibnai.vibncode`.
|
||||
|
||||
### 2.2 Preservation of the Desktop UI Experience
|
||||
* The Monaco Editor, collapsible multi-panel grid, dark theme, and rich agent step logs are preserved exactly.
|
||||
* All compiler execution and file system checks are offloaded to your secure cloud container runner.
|
||||
|
||||
### 2.3 Cloud-Hosted Live Browser Preview Pane
|
||||
* **Split-Screen Interface**: Adjacent to the Monaco code editor, we inject a live webview/iframe tab.
|
||||
* **Wildcard DNS Mapping**: The pane points directly to your dynamic wildcard URL generated during a dev server boot:
|
||||
`https://[projectSlug]-[devServerPort].preview.vibnai.com`
|
||||
* **Instant Hot-Module Replacement (HMR)**: As the cloud agent or the developer edits files, Vite or Next.js compiles the changes inside the cloud container in milliseconds. Traefik reverse-proxies the stream, causing the embedded browser pane to update instantly on-screen without requiring a manual refresh.
|
||||
|
||||
### 2.4 Autonomous Gitea & Coolify Environment Administration
|
||||
* We add tabs to the left navigation panel matching the modules of your Vibnai Web Dashboard:
|
||||
1. **Code [📝]**: Monaco editor, virtual filesystem browser.
|
||||
2. **Plan [📋]**: Synchronized feature checklist showing requirements directly tied to `fs_projects.phase_data`.
|
||||
3. **Host [🚀]**: Lists running Coolify application containers, database states, environment credentials (`.env`), and displays live resource monitors (RAM, CPU, Build Logs).
|
||||
4. **Market [📊]**: Suggests GBP categories, researches competitor domains (DataForSEO), and performs technical site scrapes.
|
||||
5. **Preview [🌐]**: Embedded browser preview tab rendering the wildcard live deployment URL.
|
||||
* **Autonomous Agent Tools**: The AI is equipped with Gitea and Coolify administration commands (e.g. `git_commit_and_push`, `apps_create_database`, `apps_deploy`, `hosting_set_env`, `project_recent_errors`). The AI can provision databases, modify env keys, verify compiling logs, and fix its own code bugs inside the sandboxed cloud workspaces autonomously.
|
||||
|
||||
---
|
||||
|
||||
## 3. Database & Database Synchronization Specifications
|
||||
|
||||
### 3.1 Mapping Local Client States to PostgreSQL
|
||||
We will link `vibncode`'s local state structures with your `vibn-frontend` PostgreSQL databases:
|
||||
|
||||
* **Project Lists**: Retrieved via `GET /api/projects` querying the `fs_projects` and `fs_users` tables.
|
||||
* **Active Agent Session**: Instantiated via `POST /api/projects/[projectId]/agent/sessions` and updated by `vibn-agent-runner` sending `PATCH /api/projects/[projectId]/agent/sessions/[sessionId]` with `outputLine` and `changedFile` updates.
|
||||
* **Virtual File Tree**: Read via `GET /api/workspace/tree` which performs a directory scrape of `/workspaces/[giteaRepo]` and renders a visual file browser in the desktop client's sidebar.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Code Paths & Planned Refactoring
|
||||
|
||||
To implement this architecture elegantly, the following specific files will be refactored:
|
||||
|
||||
### 4.1 Client-Side Refactoring (Tauri / React)
|
||||
1. **`src/services/file-service.ts`**: Replace `@tauri-apps/plugin-fs` reads/writes with authorized cloud workspace endpoints:
|
||||
* `GET /api/workspace/tree` $\rightarrow$ Scrapes Gitea directory tree.
|
||||
* `POST /api/workspace/file/read` $\rightarrow$ Fetches content for Monaco.
|
||||
* `POST /api/workspace/file/write` $\rightarrow$ Writes edits back to the cloud.
|
||||
2. **`src/services/execution-service.ts`**:
|
||||
* Redirect `startExecution()` to trigger `POST /api/projects/[projectId]/agent/execute` on the server instead of running the loop inside the local app process.
|
||||
* Connect to `vibn-frontend`'s SSE or session poll endpoint to map logs, changed file notifications, and status streams directly to Zustand.
|
||||
3. **`src/components/chat/embedded-preview.tsx`** (New Component):
|
||||
* Incorporate a split-screen panel with an `<iframe src="https://[subdomain].preview.vibnai.com" />` which shows/hides adjacent to the Monaco Editor when a dev server is booted.
|
||||
|
||||
### 4.2 Server-Side Porting (`vibn-agent-runner`)
|
||||
1. **Incorporate Parallel Agent Brain**:
|
||||
* Extract TalkCody's `tool-dependency-analyzer.ts` and `tool-executor.ts` (TypeScript) and port them inside `vibn-agent-runner/src/orchestration/`.
|
||||
* This equips your cloud runner with **4-level Smart Concurrency** (batching parallel reads and serializing write reviews inside Docker).
|
||||
2. **Enable the Ralph Loop Autonomy**:
|
||||
* Inject `ralph-loop-service.ts` into your `runSessionAgent()` background executor. This ensures that when the agent outputs self-reflection keywords, the cloud-runner automatically spawns a continuation cycle until the task is complete.
|
||||
|
||||
---
|
||||
|
||||
## 5. Component-Level Service Redirections (The Vibn Integration Bridge)
|
||||
|
||||
To preserve the rich desktop UI completely intact and prevent breaking any complex React views, we map all native local OS dependencies inside `src/components/` to go over our cloud services instead of rewriting the files individually:
|
||||
|
||||
### 5.1 Virtualizing file-editor-content.tsx & file-tree.tsx
|
||||
* **The Component**: The Monaco Editor panel and the Sidebar File Tree.
|
||||
* **Current State**: Calls `repositoryService.readFileContent(path)` and `fastDirectoryTreeService.buildDirectoryTree(rootPath)` which invoke local Rust file-readers and Tauri FS plugins.
|
||||
* **Re-engineered Bridge**: We modify `repositoryService` and `fastDirectoryTreeService` to POST to your secure Next.js `POST /api/mcp` endpoint with the actions `"fs.read"` and `"fs.tree"`. This fetches the file content and directories list directly from Gitea inside the `/workspaces` Docker environment and populates Monaco seamlessly.
|
||||
|
||||
### 5.2 Redirecting empty-repository-state.tsx
|
||||
* **The Component**: The welcoming dashboard panel shown when no local repository folder is opened.
|
||||
* **Current State**: Prompts the developer to select a folder on their local hard drive.
|
||||
* **Re-engineered Bridge**: When the developer logs in and selects a project on the `projects-page.tsx`, we automatically set the active project `root_path` to the cloud repository path (derived from the database) and immediately trigger the virtual directory tree, bypassing local disk queries entirely.
|
||||
|
||||
### 5.3 Bridging file-preview.tsx (Chat Attachments)
|
||||
* **The Component**: The attachment downloader inside your chat messages (saving images/videos locally).
|
||||
* **Current State**: Uses Tauri's `save` dialog and `plugin-fs` to write bytes to the local Downloads folder.
|
||||
* **Re-engineered Bridge**: We modify this to download attachments directly from your GCP Cloud Storage bucket / R2 CDN (`https://cdn.vibnai.com/attachments/...`) over HTTP using the native browser download manager.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phased Implementation Roadmap
|
||||
|
||||
```
|
||||
PHASE 1: System Rebranding & Desktop App Clean-up
|
||||
├── Rebrand 'talkcody' to 'vibncode' globally across folders & package assets.
|
||||
├── Modify Tauri config JSONs with the bundle ID 'com.vibnai.vibncode'.
|
||||
└── Disable local compilers and mock local SQLite caches.
|
||||
|
||||
PHASE 2: Backend API Development in Next.js
|
||||
├── Implement virtual file endpoints (POST /api/workspace/file/read, GET /tree).
|
||||
└── Establish SSE / WebSockets stream routes for agent output lines.
|
||||
|
||||
PHASE 3: UI Layout & Cloud Connection Setup
|
||||
├── Implement left-nav tabs: Plan, Host, Market, and Code.
|
||||
├── Hook file explorer & Monaco to read/write cloud files.
|
||||
└── Embed wildcard preview tab pointing to *.preview.vibnai.com.
|
||||
|
||||
PHASE 4: Orchestrating the Cloud Agent Brain
|
||||
├── Migrate TalkCody's parallel analyzer & Ralph Loop to vibn-agent-runner.
|
||||
└── Enable full, autonomous environment management (Gitea, Sentry, Coolify API).
|
||||
```
|
||||
1
talkcody
Submodule
1
talkcody
Submodule
Submodule talkcody added at 5543bf9264
226
vibn-attribution-package/README.md
Normal file
226
vibn-attribution-package/README.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# 🚀 Vibn Marketing Attribution & Identity Sync SDK
|
||||
|
||||
A modular, highly scalable, and decoupled SDK package to link anonymous site traffic tracking (**Umami Analytics**) with registered user profiles (**Vibn Database**), providing comprehensive **First-Touch Marketing Attribution** and acquisition logs.
|
||||
|
||||
Developed originally for **Missinglettr** and structured specifically to be drop-in ready for the **Vibn Platform** architecture.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Package Inventory
|
||||
|
||||
This package contains three decoupled layers designed to be copied directly into your respective repositories:
|
||||
|
||||
| Path | Purpose | Language / Framework |
|
||||
| :--- | :--- | :--- |
|
||||
| `frontend/VibnTracker.tsx` | Client-side search params listener, sessionStorage / Cookie persistence, automated Umami injection, and session identity bridging (`identify()`). | TypeScript / Next.js React |
|
||||
| `backend/models.py` | Abstract Base Django Model adding UTM and Referrer fields to any User / Client record. | Python / Django |
|
||||
| `backend/mixins.py` | Django REST Framework (DRF) interceptor mixin to auto-capture attribution variables from incoming signup payloads. | Python / Django REST Framework |
|
||||
| `umami-bridge/umami_service.py` | Reusable bridge to pull aggregated pageviews, session timestamps, and raw click-trails from Umami (via REST API or raw DB joins). | Python / Django |
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architecture Overview
|
||||
|
||||
The system bridges anonymous traffic with database records using a secure **Identity Exchange**:
|
||||
|
||||
```
|
||||
[Anonymous Visitor Clicks Ad / Tweet]
|
||||
│
|
||||
▼
|
||||
[Lands on Website with UTMs & Referrer]
|
||||
│ ├── Auto-logged in Umami (analytics.vibnai.com)
|
||||
│ └── Cached client-side in sessionStorage & Cookie fallback
|
||||
▼
|
||||
[Signs Up / Logs in via Google OAuth]
|
||||
│
|
||||
▼
|
||||
[Frontend POSTs Signup payload + UTM params to Backend]
|
||||
│
|
||||
▼
|
||||
[Django Backend saves user record + permanently binds UTM columns]
|
||||
│
|
||||
▼
|
||||
[Frontend calls useVibnTracker.identify(user_id)]
|
||||
│
|
||||
▼
|
||||
[Umami merges ALL previous anonymous browsing history with this new User ID!]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 Step-by-Step Implementation Guide
|
||||
|
||||
### 1. Frontend Integration (Vibn Next.js Web App)
|
||||
|
||||
Copy `frontend/VibnTracker.tsx` into your Next.js project (e.g., inside `components/metrics/VibnTracker.tsx`):
|
||||
|
||||
#### A. Wrap your Root Layout:
|
||||
In `app/layout.tsx` (or your entry file), wrap the tree with `VibnTrackerProvider`. This will automatically inject your Umami tracking script and listen for UTM parameters across your landing and console domains:
|
||||
|
||||
```tsx
|
||||
import { VibnTrackerProvider } from "@/components/metrics/VibnTracker";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<VibnTrackerProvider
|
||||
umamiWebsiteId="your-umami-website-uuid"
|
||||
umamiScriptUrl="https://analytics.vibnai.com/script.js"
|
||||
>
|
||||
<body>{children}</body>
|
||||
</VibnTrackerProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Capture & Send on Signup / Google OAuth exchange:
|
||||
In your signup or Google OAuth exchange page (where the user completes registration), extract the cached attribution parameters using the `useVibnTracker` hook and send them in the request body to your backend:
|
||||
|
||||
```tsx
|
||||
import { useVibnTracker } from "@/components/metrics/VibnTracker";
|
||||
|
||||
export default function GoogleCallbackPage() {
|
||||
const { getStoredAttribution, identify, track } = useVibnTracker();
|
||||
|
||||
const handleOAuthSignup = async (googleToken: string) => {
|
||||
// 1. Get first-touch parameters cached in browser
|
||||
const attribution = getStoredAttribution();
|
||||
|
||||
// 2. POST to your register endpoint
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
google_token: googleToken,
|
||||
...attribution // Sends utm_source, utm_medium, utm_campaign, referrer, etc.
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
|
||||
// 3. BRIDGING IDENTITY: Link anonymous browser history with real user ID!
|
||||
identify({
|
||||
userId: user.id, // Primary key in your database
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
plan: user.plan
|
||||
});
|
||||
|
||||
// 4. Track custom sign-up conversion in Umami
|
||||
track("user_completed_signup", { plan: user.plan });
|
||||
}
|
||||
};
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend Integration (Vibn Django API)
|
||||
|
||||
Copy `backend/models.py` and `backend/mixins.py` into your Django apps.
|
||||
|
||||
#### A. Inherit from the Abstract Model:
|
||||
Add first-touch marketing attribution columns to your active User or APIClient model by inheriting from `VibnAbstractAttributionModel`:
|
||||
|
||||
```python
|
||||
# your_app/models.py
|
||||
from backend.models import VibnAbstractAttributionModel
|
||||
|
||||
class UserProfile(VibnAbstractAttributionModel):
|
||||
# Your existing fields
|
||||
email = models.EmailField(unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
db_table = "user_profile"
|
||||
```
|
||||
|
||||
Then generate and apply your database migrations:
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
#### B. Intercept requests with the DRF View Mixin:
|
||||
Add the `VibnAttributionCaptureMixin` to your Signup or Authentication API ViewSet. It will automatically intercept, extract, and write the UTM parameters into the saving model:
|
||||
|
||||
```python
|
||||
# your_app/views.py
|
||||
from rest_framework import viewsets
|
||||
from backend.mixins import VibnAttributionCaptureMixin
|
||||
from .models import UserProfile
|
||||
from .serializers import UserProfileSerializer
|
||||
|
||||
class RegisterViewSet(VibnAttributionCaptureMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
User registration API view.
|
||||
VibnAttributionCaptureMixin automatically grabs utm_source, utm_campaign,
|
||||
and Referrer headers from requests and binds them onto the UserProfile on save.
|
||||
"""
|
||||
queryset = UserProfile.objects.all()
|
||||
serializer_class = UserProfileSerializer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Expose Live Traffic Trails in Admin Consoles
|
||||
|
||||
Copy `umami-bridge/umami_service.py` into your Python services.
|
||||
|
||||
You can securely query stats directly from your Umami self-hosted database (read-only) inside your Admin panels, allowing you to render **actual pageviews, device breakdowns, and exact visitor session timelines** for any specific client.
|
||||
|
||||
#### A. Configure Umami DB Connection:
|
||||
In Django's `settings.py`, register your Umami database:
|
||||
|
||||
```python
|
||||
DATABASES = {
|
||||
'default': {
|
||||
# Your main application DB
|
||||
},
|
||||
'umami_db': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'umami_production_db',
|
||||
'USER': 'readonly_user',
|
||||
'PASSWORD': 'password',
|
||||
'HOST': '34.19.250.135',
|
||||
'PORT': '5432',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Fetch Live Session Data for Admin Console:
|
||||
In your Admin API view (e.g. `admin_user_detail`), call the service to fetch raw click-trails:
|
||||
|
||||
```python
|
||||
# your_admin_app/views.py
|
||||
from rest_framework.response import Response
|
||||
from umami_bridge.umami_service import VibnUmamiService
|
||||
|
||||
def get_admin_user_profile(request, user_id):
|
||||
# Initialize service
|
||||
umami = VibnUmamiService()
|
||||
|
||||
# 1. Fetch total web sessions, total pageviews, and first interaction timestamp
|
||||
funnel = umami.get_aggregated_funnel_for_user(user_id, db_connection_name="umami_db")
|
||||
|
||||
# 2. Fetch the actual 50 most recent page clicks, devices, and browsers used by this client!
|
||||
click_trail = umami.get_user_session_click_trail(user_id, db_connection_name="umami_db")
|
||||
|
||||
return Response({
|
||||
"user_id": user_id,
|
||||
"first_touch_web_funnel": funnel,
|
||||
"live_click_trail": click_trail
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Best Practices & GDPR Compliance
|
||||
|
||||
1. **Self-Hosted Privacy:** Because Umami is hosted on your domain (`analytics.vibnai.com`), cookie and script blockades are minimized, and no data is shared with third parties (complying strictly with GDPR, CCPA, and PECR).
|
||||
2. **First-Touch Preservation:** The mixin uses `if not client.utm_source:` checks on save, ensuring first-touch parameters (the exact ad or link that *originally* brought the user in) are preserved and never overwritten by subsequent organic logins.
|
||||
3. **No Database Blockades:** The `umami_service.py` uses read-only connections and limits queries to `LIMIT 50` to make sure raw analytics querying never causes performance blocks on your main application database thread.
|
||||
61
vibn-attribution-package/backend/mixins.py
Normal file
61
vibn-attribution-package/backend/mixins.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VibnAttributionCaptureMixin:
|
||||
"""
|
||||
A Django REST Framework ViewSet/APIView mixin that automatically
|
||||
extracts marketing attribution fields (UTMs, referrer) from incoming
|
||||
request payloads or HTTP headers and binds them to the saving object.
|
||||
|
||||
Compatible with any Django REST Framework serializer.
|
||||
"""
|
||||
|
||||
def extract_attribution_data(self, request):
|
||||
"""
|
||||
Extracts UTM and referrer keys from request data, falling back
|
||||
to request headers if required.
|
||||
"""
|
||||
# Try request payload first
|
||||
data = request.data or {}
|
||||
|
||||
utm_source = data.get("utm_source")
|
||||
utm_medium = data.get("utm_medium")
|
||||
utm_campaign = data.get("utm_campaign")
|
||||
utm_content = data.get("utm_content")
|
||||
utm_term = data.get("utm_term")
|
||||
|
||||
# Capture Referrer from payload or fallback to HTTP headers
|
||||
referrer = data.get("referrer")
|
||||
if not referrer:
|
||||
referrer = request.META.get("HTTP_REFERER")
|
||||
|
||||
return {
|
||||
"utm_source": utm_source,
|
||||
"utm_medium": utm_medium,
|
||||
"utm_campaign": utm_campaign,
|
||||
"utm_content": utm_content,
|
||||
"utm_term": utm_term,
|
||||
"referrer": referrer,
|
||||
}
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""
|
||||
In DRF ViewSets, overrides perform_create to automatically inject
|
||||
attribution fields directly into the saved model instance.
|
||||
"""
|
||||
attrib_data = self.extract_attribution_data(self.request)
|
||||
|
||||
# Save serializer with attribution params injected as overrides
|
||||
instance = serializer.save(
|
||||
**{k: v for k, v in attrib_data.items() if v is not None}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"VibnTracker: Saved attribution data to {instance.__class__.__name__} "
|
||||
f"(Source: {attrib_data['utm_source']}, Campaign: {attrib_data['utm_campaign']})"
|
||||
)
|
||||
return instance
|
||||
164
vibn-attribution-package/backend/models.py
Normal file
164
vibn-attribution-package/backend/models.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class VibnAbstractAttributionModel(models.Model):
|
||||
"""
|
||||
An abstract Django model that adds standard marketing attribution
|
||||
(first-touch UTM parameters & referrers) to any model (e.g. User, APIClient, Customer).
|
||||
|
||||
To implement:
|
||||
class APIClient(VibnAbstractAttributionModel):
|
||||
name = models.CharField(max_length=255)
|
||||
# ... other fields ...
|
||||
"""
|
||||
|
||||
utm_source = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Marketing channel source (e.g. twitter, google, newsletter)",
|
||||
)
|
||||
utm_medium = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Marketing medium (e.g. cpc, social, organic, email)",
|
||||
)
|
||||
utm_campaign = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Marketing campaign name (e.g. mcp-launch, spring-discount)",
|
||||
)
|
||||
utm_content = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Specific content links or ad identifiers clicked",
|
||||
)
|
||||
utm_term = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Keywords searched or paid terms clicked",
|
||||
)
|
||||
referrer = models.CharField(
|
||||
max_length=1000,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="First-touch browser referrer URL (e.g. https://t.co/, https://google.com)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save_attribution(
|
||||
self,
|
||||
utm_source,
|
||||
utm_medium=None,
|
||||
utm_campaign=None,
|
||||
utm_content=None,
|
||||
utm_term=None,
|
||||
referrer=None,
|
||||
overwrite=False,
|
||||
):
|
||||
"""
|
||||
Helper method to securely save marketing attribution data if it hasn't been set yet.
|
||||
"""
|
||||
if overwrite or not self.utm_source:
|
||||
self.utm_source = utm_source
|
||||
self.utm_medium = utm_medium
|
||||
self.utm_campaign = utm_campaign
|
||||
self.utm_content = utm_content
|
||||
self.utm_term = utm_term
|
||||
self.referrer = referrer
|
||||
self.save(
|
||||
update_fields=[
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
"referrer",
|
||||
]
|
||||
if self.pk
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
class VibnPageview(models.Model):
|
||||
"""
|
||||
Vibn Pageview Tracking Model.
|
||||
Tracks active user interactions and page loads in real-time,
|
||||
providing fully dynamic traffic data and click timelines.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
"UserProfile", # Replace with your custom User model name
|
||||
on_delete=models.CASCADE,
|
||||
related_name="pageviews",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
session_id = models.CharField(
|
||||
max_length=255, db_index=True, help_text="Unique browser session tracker ID"
|
||||
)
|
||||
url_path = models.CharField(
|
||||
max_length=500, db_index=True, help_text="Tracked page path, e.g. /pricing"
|
||||
)
|
||||
referrer = models.CharField(
|
||||
max_length=1000, null=True, blank=True, help_text="Traffic referrer URL"
|
||||
)
|
||||
|
||||
# Captured UTM variables
|
||||
utm_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_medium = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_campaign = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_content = models.CharField(max_length=255, null=True, blank=True)
|
||||
utm_term = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
# Device/Geo details
|
||||
device = models.CharField(max_length=100, default="desktop")
|
||||
browser = models.CharField(max_length=100, default="chrome")
|
||||
country = models.CharField(max_length=100, default="US")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "vibn_pageview"
|
||||
verbose_name = "Vibn Pageview"
|
||||
verbose_name_plural = "Vibn Pageviews"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class VibnEvent(models.Model):
|
||||
"""
|
||||
Vibn Product Event Tracking Model.
|
||||
Tracks custom user conversion events (e.g. signup, connect_channel, create_campaign)
|
||||
and binds them to their sessions and profiles.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
"UserProfile", # Replace with your custom User model name
|
||||
on_delete=models.CASCADE,
|
||||
related_name="events",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
session_id = models.CharField(max_length=255, db_index=True)
|
||||
event_name = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="Custom event name, e.g. connect_social",
|
||||
)
|
||||
properties = models.JSONField(
|
||||
default=dict, blank=True, help_text="Custom event properties JSON"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "vibn_event"
|
||||
verbose_name = "Vibn Event"
|
||||
verbose_name_plural = "Vibn Events"
|
||||
ordering = ["-created_at"]
|
||||
185
vibn-attribution-package/frontend/VibnTracker.tsx
Normal file
185
vibn-attribution-package/frontend/VibnTracker.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, createContext, useContext } from "react";
|
||||
|
||||
// ── TYPES & INTERFACES ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UTMParams {
|
||||
utm_source: string | null;
|
||||
utm_medium: string | null;
|
||||
utm_campaign: string | null;
|
||||
utm_content: string | null;
|
||||
utm_term: string | null;
|
||||
referrer: string | null;
|
||||
}
|
||||
|
||||
export interface IdentifyTraits {
|
||||
userId: string | number;
|
||||
email: string;
|
||||
name?: string;
|
||||
plan?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface VibnTrackerContextType {
|
||||
getStoredAttribution: () => UTMParams;
|
||||
clearAttribution: () => void;
|
||||
identify: (traits: IdentifyTraits) => void;
|
||||
track: (eventName: string, properties?: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
identify: (traits: Record<string, any>) => void;
|
||||
track: (eventName: string, properties?: Record<string, any>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── UTILITY FUNCTIONS ─────────────────────────────────────────────────────────
|
||||
|
||||
const COOKIE_EXPIRY_DAYS = 30;
|
||||
|
||||
function setCookie(name: string, value: string, days: number) {
|
||||
if (typeof document === "undefined") return;
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
const expires = "; expires=" + date.toUTCString();
|
||||
document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax";
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(";");
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === " ") c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteCookie(name: string) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = name + "=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
|
||||
}
|
||||
|
||||
// ── CONTEXT PROVIDER ──────────────────────────────────────────────────────────
|
||||
|
||||
const VibnTrackerContext = createContext<VibnTrackerContextType | undefined>(undefined);
|
||||
|
||||
export function VibnTrackerProvider({
|
||||
children,
|
||||
umamiWebsiteId,
|
||||
umamiScriptUrl = "https://analytics.vibnai.com/script.js",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
umamiWebsiteId?: string;
|
||||
umamiScriptUrl?: string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 1. Extract UTMs from active search string
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const keys: Array<keyof UTMParams> = [
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
];
|
||||
|
||||
keys.forEach((key) => {
|
||||
const val = urlParams.get(key);
|
||||
if (val) {
|
||||
// Persist to sessionStorage (for current window lifespans)
|
||||
sessionStorage.setItem(key, val);
|
||||
// Persist to cookie (cross-session backup, used if user closes window and signs up later)
|
||||
setCookie(`vibn_${key}`, val, COOKIE_EXPIRY_DAYS);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Persist browser referrer
|
||||
if (!sessionStorage.getItem("referrer") && !getCookie("vibn_referrer")) {
|
||||
const ref = document.referrer || "direct";
|
||||
sessionStorage.setItem("referrer", ref);
|
||||
setCookie("vibn_referrer", ref, COOKIE_EXPIRY_DAYS);
|
||||
}
|
||||
|
||||
// 3. Inject Umami analytics script automatically if website ID provided
|
||||
if (umamiWebsiteId && !document.querySelector(`script[data-website-id="${umamiWebsiteId}"]`)) {
|
||||
const script = document.createElement("script");
|
||||
script.src = umamiScriptUrl;
|
||||
script.setAttribute("data-website-id", umamiWebsiteId);
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, [umamiWebsiteId, umamiScriptUrl]);
|
||||
|
||||
const getStoredAttribution = (): UTMParams => {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
utm_source: null,
|
||||
utm_medium: null,
|
||||
utm_campaign: null,
|
||||
utm_content: null,
|
||||
utm_term: null,
|
||||
referrer: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
utm_source: sessionStorage.getItem("utm_source") || getCookie("vibn_utm_source"),
|
||||
utm_medium: sessionStorage.getItem("utm_medium") || getCookie("vibn_utm_medium"),
|
||||
utm_campaign: sessionStorage.getItem("utm_campaign") || getCookie("vibn_utm_campaign"),
|
||||
utm_content: sessionStorage.getItem("utm_content") || getCookie("vibn_utm_content"),
|
||||
utm_term: sessionStorage.getItem("utm_term") || getCookie("vibn_utm_term"),
|
||||
referrer: sessionStorage.getItem("referrer") || getCookie("vibn_referrer"),
|
||||
};
|
||||
};
|
||||
|
||||
const clearAttribution = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const keys = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "referrer"];
|
||||
keys.forEach((key) => {
|
||||
sessionStorage.removeItem(key);
|
||||
deleteCookie(`vibn_${key}`);
|
||||
});
|
||||
};
|
||||
|
||||
const identify = (traits: IdentifyTraits) => {
|
||||
if (typeof window !== "undefined" && window.umami) {
|
||||
window.umami.identify(traits);
|
||||
console.log(`[VibnTracker] User identified in Umami: ${traits.email}`);
|
||||
}
|
||||
};
|
||||
|
||||
const track = (eventName: string, properties?: Record<string, any>) => {
|
||||
if (typeof window !== "undefined" && window.umami) {
|
||||
window.umami.track(eventName, properties);
|
||||
console.log(`[VibnTracker] Event tracked: "${eventName}"`, properties || "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VibnTrackerContext.Provider
|
||||
value={{ getStoredAttribution, clearAttribution, identify, track }}
|
||||
>
|
||||
{children}
|
||||
</VibnTrackerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CUSTOM HOOK ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useVibnTracker() {
|
||||
const context = useContext(VibnTrackerContext);
|
||||
if (!context) {
|
||||
throw new Error("useVibnTracker must be used within a VibnTrackerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
139
vibn-attribution-package/umami-bridge/umami_service.py
Normal file
139
vibn-attribution-package/umami-bridge/umami_service.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.db import connections
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VibnUmamiService:
|
||||
"""
|
||||
Vibn Umami Bridge Service.
|
||||
Supports querying Umami analytics via either its official REST API or
|
||||
performing direct, high-performance database JOINs against your self-hosted Postgres tables.
|
||||
"""
|
||||
|
||||
def __init__(self, api_url=None, token=None, website_id=None):
|
||||
self.api_url = api_url or "https://analytics.vibnai.com/api"
|
||||
self.token = token
|
||||
self.website_id = website_id
|
||||
|
||||
# ── DRIVER 1: REST API INTEGRATION ────────────────────────────────────────
|
||||
|
||||
def _get_headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def fetch_website_stats(self, start_at, end_at):
|
||||
"""
|
||||
Query website aggregated statistics (pageviews, visitors, bounce_rate)
|
||||
via Umami REST API.
|
||||
"""
|
||||
if not self.website_id or not self.token:
|
||||
logger.warning("Umami API connection details missing.")
|
||||
return None
|
||||
|
||||
url = f"{self.api_url}/websites/{self.website_id}/stats"
|
||||
params = {
|
||||
"startAt": int(start_at.timestamp() * 1000),
|
||||
"endAt": int(end_at.timestamp() * 1000),
|
||||
}
|
||||
try:
|
||||
response = requests.get(
|
||||
url, headers=self._get_headers(), params=params, timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Umami API stats: {e}")
|
||||
return None
|
||||
|
||||
# ── DRIVER 2: DIRECT POSTGRESQL READ-ONLY QUERIES ─────────────────────────
|
||||
|
||||
def get_user_session_click_trail(self, user_id, db_connection_name="umami_db"):
|
||||
"""
|
||||
Retrieves raw browsing timeline for a identified user directly from
|
||||
the self-hosted Umami DB instance using Django cross-database routing.
|
||||
"""
|
||||
if db_connection_name not in connections:
|
||||
logger.warning(
|
||||
f"Database connection '{db_connection_name}' is not configured."
|
||||
)
|
||||
return []
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
we.created_at,
|
||||
we.event_name,
|
||||
we.url_path,
|
||||
we.url_query,
|
||||
s.device,
|
||||
s.browser,
|
||||
s.country
|
||||
FROM website_event we
|
||||
JOIN session s ON we.session_id = s.session_id
|
||||
WHERE s.user_id = %s
|
||||
ORDER BY we.created_at DESC
|
||||
LIMIT 50;
|
||||
"""
|
||||
try:
|
||||
with connections[db_connection_name].cursor() as cursor:
|
||||
cursor.execute(query, [str(user_id)])
|
||||
rows = cursor.fetchall()
|
||||
|
||||
trail = []
|
||||
for r in rows:
|
||||
trail.append(
|
||||
{
|
||||
"timestamp": r[0].isoformat()
|
||||
if hasattr(r[0], "isoformat")
|
||||
else r[0],
|
||||
"event": r[1],
|
||||
"path": r[2],
|
||||
"query": r[3],
|
||||
"device": r[4],
|
||||
"browser": r[5],
|
||||
"country": r[6],
|
||||
}
|
||||
)
|
||||
return trail
|
||||
except Exception as e:
|
||||
logger.error(f"Direct Umami DB query failed: {e}")
|
||||
return []
|
||||
|
||||
def get_aggregated_funnel_for_user(self, user_id, db_connection_name="umami_db"):
|
||||
"""
|
||||
Retrieves high-level counts for pageviews, unique sessions, and first-seen dates
|
||||
directly from the raw Umami tables.
|
||||
"""
|
||||
if db_connection_name not in connections:
|
||||
return None
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COUNT(DISTINCT we.session_id),
|
||||
MIN(we.created_at)
|
||||
FROM website_event we
|
||||
JOIN session s ON we.session_id = s.session_id
|
||||
WHERE s.user_id = %s;
|
||||
"""
|
||||
try:
|
||||
with connections[db_connection_name].cursor() as cursor:
|
||||
cursor.execute(query, [str(user_id)])
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {
|
||||
"total_interactions": row[0],
|
||||
"total_sessions": row[1],
|
||||
"first_seen_at": row[2].isoformat()
|
||||
if hasattr(row[2], "isoformat")
|
||||
else row[2],
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch aggregated user funnel: {e}")
|
||||
return None
|
||||
1
vibn-code
Submodule
1
vibn-code
Submodule
Submodule vibn-code added at 5543bf9264
46
vibn-frontend/app/api/auth/me/route.ts
Normal file
46
vibn-frontend/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
*
|
||||
* Exposes a profile endpoint for the VibnCode desktop app.
|
||||
* Resolves the Bearer vibn_sk_... Workspace API key, queries the database,
|
||||
* and returns the corresponding owner's user details.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
|
||||
import { queryOne } from "@/lib/db-postgres";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// 1. Authenticate the Workspace API key or Browser Session
|
||||
const principal = await requireWorkspacePrincipal(request);
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
try {
|
||||
// 2. Query user details from fs_users
|
||||
const user = await queryOne<{
|
||||
id: string;
|
||||
data: any;
|
||||
}>(
|
||||
`SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`,
|
||||
[principal.userId]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 3. Return user profile compatible with the desktop client's User expectations
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.data?.name || user.data?.display_name || "Vibn Owner",
|
||||
email: user.data?.email || "",
|
||||
photoUrl: user.data?.image || user.data?.photo_url || null,
|
||||
workspace: principal.workspace.slug,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[api/auth/me GET]", err);
|
||||
return NextResponse.json({ error: "Failed to resolve profile" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
61
vibn-frontend/app/api/auth/token/route.ts
Normal file
61
vibn-frontend/app/api/auth/token/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GET /api/auth/token
|
||||
*
|
||||
* Secure endpoint called by the browser during desktop SSO.
|
||||
* Verifies the user has a valid NextAuth browser session, resolves or mints
|
||||
* a Workspace API key, and returns it.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
import { queryOne } from "@/lib/db-postgres";
|
||||
import { getWorkspaceByOwner } from "@/lib/workspaces";
|
||||
import { mintWorkspaceApiKey, listWorkspaceApiKeys, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth";
|
||||
|
||||
export async function GET() {
|
||||
// 1. Verify caller has an active NextAuth browser session cookie
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Fetch the corresponding Postgres user
|
||||
const user = await queryOne<{ id: string }>(
|
||||
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||
[session.user.email]
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 3. Get the user's active workspace
|
||||
const workspace = await getWorkspaceByOwner(user.id);
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. Try to reuse their existing, active workspace API key to avoid key bloating
|
||||
const keys = await listWorkspaceApiKeys(workspace.id);
|
||||
const activeKey = keys.find((k) => !k.revoked_at);
|
||||
|
||||
if (activeKey) {
|
||||
const revealed = await revealWorkspaceApiKey(workspace.id, activeKey.id);
|
||||
if (revealed) {
|
||||
return NextResponse.json({ token: revealed.token });
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Otherwise, mint a fresh key for the desktop client
|
||||
const minted = await mintWorkspaceApiKey({
|
||||
workspaceId: workspace.id,
|
||||
name: "VibnCode Desktop SSO",
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({ token: minted.token });
|
||||
} catch (err) {
|
||||
console.error("[api/auth/token GET]", err);
|
||||
return NextResponse.json({ error: "Failed to resolve workspace token" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,39 @@ function AuthPageInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [ssoProcessing, setSsoProcessing] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated" && session?.user?.email) {
|
||||
const isVibnCodeSSO = searchParams?.get("vibncode") === "true";
|
||||
|
||||
if (isVibnCodeSSO) {
|
||||
setSsoProcessing(true);
|
||||
// Call our new secure token endpoint
|
||||
fetch("/api/auth/token")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.token) {
|
||||
// Deep-link redirect back to the VibnCode desktop app
|
||||
window.location.href = `vibncode://auth/callback?token=${data.token}`;
|
||||
} else {
|
||||
console.error("SSO Token missing from response", data);
|
||||
setSsoProcessing(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Desktop SSO failed:", err);
|
||||
setSsoProcessing(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = deriveWorkspace(session.user.email);
|
||||
|
||||
|
||||
// Check if user has projects. If 0, go to onboarding, else go to projects.
|
||||
fetch("/api/projects")
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.projects && d.projects.length > 0) {
|
||||
router.push(`/${workspace}/projects`);
|
||||
} else {
|
||||
@@ -39,7 +64,7 @@ function AuthPageInner() {
|
||||
}
|
||||
}, [status, session, router, searchParams]);
|
||||
|
||||
if (status === "loading") {
|
||||
if (status === "loading" || ssoProcessing) {
|
||||
return (
|
||||
<div
|
||||
className="new-site-wrapper"
|
||||
@@ -80,7 +105,9 @@ function AuthPageInner() {
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Checking session
|
||||
{ssoProcessing
|
||||
? "Authorizing VibnCode Desktop..."
|
||||
: "Checking session"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user