diff --git a/backend/migrations/110_trusted_origins.sql b/backend/migrations/110_trusted_origins.sql new file mode 100644 index 00000000..fd7bc6fa --- /dev/null +++ b/backend/migrations/110_trusted_origins.sql @@ -0,0 +1,92 @@ +-- Migration: 110_trusted_origins.sql +-- Description: Trusted origins for API access without token +-- Created: 2024-12-14 +-- +-- Manages which domains, IPs, and patterns can access the API without a Bearer token. +-- Used by auth middleware to grant 'internal' role to trusted requests. + +-- ============================================================ +-- TRUSTED ORIGINS TABLE +-- ============================================================ +CREATE TABLE IF NOT EXISTS trusted_origins ( + id SERIAL PRIMARY KEY, + + -- Origin identification + name VARCHAR(100) NOT NULL, -- Friendly name (e.g., "CannaIQ Production") + origin_type VARCHAR(20) NOT NULL, -- 'domain', 'ip', or 'pattern' + origin_value VARCHAR(255) NOT NULL, -- The actual value to match + + -- Metadata + description TEXT, -- Optional notes + active BOOLEAN DEFAULT TRUE, + + -- Tracking + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by INTEGER REFERENCES users(id), + + -- Constraints + CONSTRAINT valid_origin_type CHECK (origin_type IN ('domain', 'ip', 'pattern')), + UNIQUE(origin_type, origin_value) +); + +-- Index for active lookups (used by auth middleware) +CREATE INDEX IF NOT EXISTS idx_trusted_origins_active + ON trusted_origins(active) WHERE active = TRUE; + +-- Updated at trigger +CREATE OR REPLACE FUNCTION update_trusted_origins_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trusted_origins_updated_at ON trusted_origins; +CREATE TRIGGER trusted_origins_updated_at + BEFORE UPDATE ON trusted_origins + FOR EACH ROW + EXECUTE FUNCTION update_trusted_origins_updated_at(); + +-- ============================================================ +-- SEED DEFAULT TRUSTED ORIGINS +-- These match the hardcoded fallbacks in middleware.ts +-- ============================================================ + +-- Production domains +INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES + ('CannaIQ Production', 'domain', 'https://cannaiq.co', 'Main CannaIQ dashboard'), + ('CannaIQ Production (www)', 'domain', 'https://www.cannaiq.co', 'Main CannaIQ dashboard with www'), + ('FindADispo Production', 'domain', 'https://findadispo.com', 'Consumer dispensary finder'), + ('FindADispo Production (www)', 'domain', 'https://www.findadispo.com', 'Consumer dispensary finder with www'), + ('Findagram Production', 'domain', 'https://findagram.co', 'Instagram-style cannabis discovery'), + ('Findagram Production (www)', 'domain', 'https://www.findagram.co', 'Instagram-style cannabis discovery with www') +ON CONFLICT (origin_type, origin_value) DO NOTHING; + +-- Wildcard patterns +INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES + ('CannaBrands Subdomains', 'pattern', '^https://.*\\.cannabrands\\.app$', 'All *.cannabrands.app subdomains'), + ('CannaIQ Subdomains', 'pattern', '^https://.*\\.cannaiq\\.co$', 'All *.cannaiq.co subdomains') +ON CONFLICT (origin_type, origin_value) DO NOTHING; + +-- Local development +INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES + ('Local API', 'domain', 'http://localhost:3010', 'Local backend API'), + ('Local Admin', 'domain', 'http://localhost:8080', 'Local admin dashboard'), + ('Local Vite Dev', 'domain', 'http://localhost:5173', 'Vite dev server') +ON CONFLICT (origin_type, origin_value) DO NOTHING; + +-- Trusted IPs (localhost) +INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES + ('Localhost IPv4', 'ip', '127.0.0.1', 'Local machine'), + ('Localhost IPv6', 'ip', '::1', 'Local machine IPv6'), + ('Localhost IPv6 Mapped', 'ip', '::ffff:127.0.0.1', 'IPv6-mapped IPv4 localhost') +ON CONFLICT (origin_type, origin_value) DO NOTHING; + +-- ============================================================ +-- COMMENTS +-- ============================================================ +COMMENT ON TABLE trusted_origins IS 'Domains, IPs, and patterns that can access API without token'; +COMMENT ON COLUMN trusted_origins.origin_type IS 'domain = exact URL match, ip = IP address, pattern = regex pattern'; +COMMENT ON COLUMN trusted_origins.origin_value IS 'For domain: full URL. For ip: IP address. For pattern: regex string'; diff --git a/backend/src/index.ts b/backend/src/index.ts index 9a238810..e5618362 100755 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -88,6 +88,7 @@ import versionRoutes from './routes/version'; import deployStatusRoutes from './routes/deploy-status'; import publicApiRoutes from './routes/public-api'; import usersRoutes from './routes/users'; +import trustedOriginsRoutes from './routes/trusted-origins'; import staleProcessesRoutes from './routes/stale-processes'; import orchestratorAdminRoutes from './routes/orchestrator-admin'; import proxyAdminRoutes from './routes/proxy-admin'; @@ -122,7 +123,6 @@ import workerRegistryRoutes from './routes/worker-registry'; // Per TASK_WORKFLOW_2024-12-10.md: Raw payload access API import payloadsRoutes from './routes/payloads'; import k8sRoutes from './routes/k8s'; -import trustedOriginsRoutes from './routes/trusted-origins'; // Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com) diff --git a/backend/src/routes/trusted-origins.ts b/backend/src/routes/trusted-origins.ts index 56dfd438..467d1623 100644 --- a/backend/src/routes/trusted-origins.ts +++ b/backend/src/routes/trusted-origins.ts @@ -1,223 +1,185 @@ -/** - * Trusted Origins Admin Routes - * - * Manage IPs and domains that bypass API key authentication. - * Available at /api/admin/trusted-origins - */ - -import { Router, Response } from 'express'; +import { Router } from 'express'; import { pool } from '../db/pool'; -import { AuthRequest, authMiddleware, requireRole, clearTrustedOriginsCache } from '../auth/middleware'; +import { authMiddleware, requireRole, AuthRequest, clearTrustedOriginsCache } from '../auth/middleware'; const router = Router(); -// All routes require admin auth +// All routes require authentication and admin/superadmin role router.use(authMiddleware); router.use(requireRole('admin', 'superadmin')); -/** - * GET /api/admin/trusted-origins - * List all trusted origins - */ -router.get('/', async (req: AuthRequest, res: Response) => { +// Get all trusted origins +router.get('/', async (req: AuthRequest, res) => { try { const result = await pool.query(` SELECT - id, - origin_type, - origin_value, - description, - active, - created_at, - updated_at - FROM trusted_origins - ORDER BY origin_type, origin_value + t.*, + u.email as created_by_email + FROM trusted_origins t + LEFT JOIN users u ON t.created_by = u.id + ORDER BY t.origin_type, t.name `); - - res.json({ - success: true, - origins: result.rows, - counts: { - total: result.rows.length, - active: result.rows.filter(r => r.active).length, - ips: result.rows.filter(r => r.origin_type === 'ip').length, - domains: result.rows.filter(r => r.origin_type === 'domain').length, - patterns: result.rows.filter(r => r.origin_type === 'pattern').length, - }, - }); - } catch (error: any) { - console.error('[TrustedOrigins] List error:', error.message); - res.status(500).json({ success: false, error: error.message }); + res.json({ origins: result.rows }); + } catch (error) { + console.error('Error fetching trusted origins:', error); + res.status(500).json({ error: 'Failed to fetch trusted origins' }); } }); -/** - * POST /api/admin/trusted-origins - * Add a new trusted origin - */ -router.post('/', async (req: AuthRequest, res: Response) => { +// Get single trusted origin +router.get('/:id', async (req: AuthRequest, res) => { try { - const { origin_type, origin_value, description } = req.body; + const { id } = req.params; + const result = await pool.query(` + SELECT + t.*, + u.email as created_by_email + FROM trusted_origins t + LEFT JOIN users u ON t.created_by = u.id + WHERE t.id = $1 + `, [id]); - if (!origin_type || !origin_value) { - return res.status(400).json({ - success: false, - error: 'origin_type and origin_value are required', - }); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Trusted origin not found' }); } - if (!['ip', 'domain', 'pattern'].includes(origin_type)) { - return res.status(400).json({ - success: false, - error: 'origin_type must be: ip, domain, or pattern', - }); + res.json({ origin: result.rows[0] }); + } catch (error) { + console.error('Error fetching trusted origin:', error); + res.status(500).json({ error: 'Failed to fetch trusted origin' }); + } +}); + +// Create trusted origin +router.post('/', async (req: AuthRequest, res) => { + try { + const { name, origin_type, origin_value, description, active } = req.body; + + if (!name || !origin_type || !origin_value) { + return res.status(400).json({ error: 'Name, origin_type, and origin_value are required' }); } - // Validate pattern if regex + const validTypes = ['domain', 'ip', 'pattern']; + if (!validTypes.includes(origin_type)) { + return res.status(400).json({ error: 'origin_type must be domain, ip, or pattern' }); + } + + // Validate pattern if provided if (origin_type === 'pattern') { try { new RegExp(origin_value); } catch { - return res.status(400).json({ - success: false, - error: 'Invalid regex pattern', - }); + return res.status(400).json({ error: 'Invalid regex pattern' }); } } const result = await pool.query(` - INSERT INTO trusted_origins (origin_type, origin_value, description, created_by) - VALUES ($1, $2, $3, $4) - RETURNING id, origin_type, origin_value, description, active, created_at - `, [origin_type, origin_value, description || null, req.user?.id || null]); + INSERT INTO trusted_origins (name, origin_type, origin_value, description, active, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [name, origin_type, origin_value, description || null, active !== false, req.user?.id || null]); - // Invalidate cache + // Clear cache so middleware picks up new origin clearTrustedOriginsCache(); - res.json({ - success: true, - origin: result.rows[0], - }); + res.status(201).json({ origin: result.rows[0] }); } catch (error: any) { if (error.code === '23505') { - return res.status(409).json({ - success: false, - error: 'This origin already exists', - }); + return res.status(400).json({ error: 'This origin already exists' }); } - console.error('[TrustedOrigins] Add error:', error.message); - res.status(500).json({ success: false, error: error.message }); + console.error('Error creating trusted origin:', error); + res.status(500).json({ error: 'Failed to create trusted origin' }); } }); -/** - * PUT /api/admin/trusted-origins/:id - * Update a trusted origin - */ -router.put('/:id', async (req: AuthRequest, res: Response) => { +// Update trusted origin +router.put('/:id', async (req: AuthRequest, res) => { try { - const id = parseInt(req.params.id); - const { origin_type, origin_value, description, active } = req.body; + const { id } = req.params; + const { name, origin_type, origin_value, description, active } = req.body; - // Validate pattern if regex + // Validate pattern if provided if (origin_type === 'pattern' && origin_value) { try { new RegExp(origin_value); } catch { - return res.status(400).json({ - success: false, - error: 'Invalid regex pattern', - }); + return res.status(400).json({ error: 'Invalid regex pattern' }); } } const result = await pool.query(` UPDATE trusted_origins SET - origin_type = COALESCE($1, origin_type), - origin_value = COALESCE($2, origin_value), - description = COALESCE($3, description), - active = COALESCE($4, active), - updated_at = NOW() - WHERE id = $5 - RETURNING id, origin_type, origin_value, description, active, updated_at - `, [origin_type, origin_value, description, active, id]); + name = COALESCE($1, name), + origin_type = COALESCE($2, origin_type), + origin_value = COALESCE($3, origin_value), + description = COALESCE($4, description), + active = COALESCE($5, active) + WHERE id = $6 + RETURNING * + `, [name, origin_type, origin_value, description, active, id]); if (result.rows.length === 0) { - return res.status(404).json({ success: false, error: 'Origin not found' }); + return res.status(404).json({ error: 'Trusted origin not found' }); } - // Invalidate cache + // Clear cache so middleware picks up changes clearTrustedOriginsCache(); - res.json({ - success: true, - origin: result.rows[0], - }); + res.json({ origin: result.rows[0] }); } catch (error: any) { - console.error('[TrustedOrigins] Update error:', error.message); - res.status(500).json({ success: false, error: error.message }); + if (error.code === '23505') { + return res.status(400).json({ error: 'This origin already exists' }); + } + console.error('Error updating trusted origin:', error); + res.status(500).json({ error: 'Failed to update trusted origin' }); } }); -/** - * DELETE /api/admin/trusted-origins/:id - * Delete a trusted origin - */ -router.delete('/:id', async (req: AuthRequest, res: Response) => { +// Delete trusted origin +router.delete('/:id', async (req: AuthRequest, res) => { try { - const id = parseInt(req.params.id); + const { id } = req.params; - const result = await pool.query(` - DELETE FROM trusted_origins WHERE id = $1 RETURNING id, origin_value - `, [id]); + const result = await pool.query('DELETE FROM trusted_origins WHERE id = $1 RETURNING *', [id]); if (result.rows.length === 0) { - return res.status(404).json({ success: false, error: 'Origin not found' }); + return res.status(404).json({ error: 'Trusted origin not found' }); } - // Invalidate cache + // Clear cache so middleware picks up deletion clearTrustedOriginsCache(); - res.json({ - success: true, - deleted: result.rows[0], - }); - } catch (error: any) { - console.error('[TrustedOrigins] Delete error:', error.message); - res.status(500).json({ success: false, error: error.message }); + res.json({ success: true, message: 'Trusted origin deleted' }); + } catch (error) { + console.error('Error deleting trusted origin:', error); + res.status(500).json({ error: 'Failed to delete trusted origin' }); } }); -/** - * POST /api/admin/trusted-origins/:id/toggle - * Toggle active status - */ -router.post('/:id/toggle', async (req: AuthRequest, res: Response) => { +// Toggle active status +router.patch('/:id/toggle', async (req: AuthRequest, res) => { try { - const id = parseInt(req.params.id); + const { id } = req.params; const result = await pool.query(` UPDATE trusted_origins - SET active = NOT active, updated_at = NOW() + SET active = NOT active WHERE id = $1 - RETURNING id, origin_type, origin_value, active + RETURNING * `, [id]); if (result.rows.length === 0) { - return res.status(404).json({ success: false, error: 'Origin not found' }); + return res.status(404).json({ error: 'Trusted origin not found' }); } - // Invalidate cache + // Clear cache so middleware picks up change clearTrustedOriginsCache(); - res.json({ - success: true, - origin: result.rows[0], - }); - } catch (error: any) { - console.error('[TrustedOrigins] Toggle error:', error.message); - res.status(500).json({ success: false, error: error.message }); + res.json({ origin: result.rows[0] }); + } catch (error) { + console.error('Error toggling trusted origin:', error); + res.status(500).json({ error: 'Failed to toggle trusted origin' }); } }); diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index 87b871e3..2823be16 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -1218,6 +1218,47 @@ class ApiClient { }); } + // Trusted Origins Management + async getTrustedOrigins() { + return this.request<{ origins: Array<{ + id: number; + name: string; + origin_type: 'domain' | 'ip' | 'pattern'; + origin_value: string; + description: string | null; + active: boolean; + created_at: string; + updated_at: string; + created_by_email: string | null; + }> }>('/api/admin/trusted-origins'); + } + + async createTrustedOrigin(data: { name: string; origin_type: string; origin_value: string; description?: string; active?: boolean }) { + return this.request<{ origin: any }>('/api/admin/trusted-origins', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateTrustedOrigin(id: number, data: { name?: string; origin_type?: string; origin_value?: string; description?: string; active?: boolean }) { + return this.request<{ origin: any }>(`/api/admin/trusted-origins/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteTrustedOrigin(id: number) { + return this.request<{ success: boolean }>(`/api/admin/trusted-origins/${id}`, { + method: 'DELETE', + }); + } + + async toggleTrustedOrigin(id: number) { + return this.request<{ origin: any }>(`/api/admin/trusted-origins/${id}/toggle`, { + method: 'PATCH', + }); + } + // Orchestrator Traces async getDispensaryTraceLatest(dispensaryId: number) { return this.request<{ diff --git a/cannaiq/src/pages/Users.tsx b/cannaiq/src/pages/Users.tsx index ee534230..15012a20 100644 --- a/cannaiq/src/pages/Users.tsx +++ b/cannaiq/src/pages/Users.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { Layout } from '../components/Layout'; import { api } from '../lib/api'; import { useAuthStore } from '../store/authStore'; -import { Users as UsersIcon, Plus, Pencil, Trash2, X, Check, AlertCircle } from 'lucide-react'; +import { Users as UsersIcon, Plus, Pencil, Trash2, X, Check, AlertCircle, Globe, Shield, Power, PowerOff } from 'lucide-react'; interface User { id: number; @@ -18,8 +18,30 @@ interface UserFormData { role: string; } +interface TrustedOrigin { + id: number; + name: string; + origin_type: 'domain' | 'ip' | 'pattern'; + origin_value: string; + description: string | null; + active: boolean; + created_at: string; + updated_at: string; + created_by_email: string | null; +} + +interface OriginFormData { + name: string; + origin_type: 'domain' | 'ip' | 'pattern'; + origin_value: string; + description: string; +} + export function Users() { const { user: currentUser } = useAuthStore(); + const [activeTab, setActiveTab] = useState<'users' | 'origins'>('users'); + + // Users state const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -29,6 +51,16 @@ export function Users() { const [formError, setFormError] = useState(null); const [saving, setSaving] = useState(false); + // Trusted Origins state + const [origins, setOrigins] = useState([]); + const [originsLoading, setOriginsLoading] = useState(true); + const [originsError, setOriginsError] = useState(null); + const [showOriginModal, setShowOriginModal] = useState(false); + const [editingOrigin, setEditingOrigin] = useState(null); + const [originFormData, setOriginFormData] = useState({ name: '', origin_type: 'domain', origin_value: '', description: '' }); + const [originFormError, setOriginFormError] = useState(null); + const [originSaving, setOriginSaving] = useState(false); + const fetchUsers = async () => { try { setLoading(true); @@ -42,8 +74,22 @@ export function Users() { } }; + const fetchOrigins = async () => { + try { + setOriginsLoading(true); + const response = await api.getTrustedOrigins(); + setOrigins(response.origins); + setOriginsError(null); + } catch (err: any) { + setOriginsError(err.message || 'Failed to fetch trusted origins'); + } finally { + setOriginsLoading(false); + } + }; + useEffect(() => { fetchUsers(); + fetchOrigins(); }, []); const handleCreate = async () => { @@ -125,6 +171,88 @@ export function Users() { setFormError(null); }; + // Origin handlers + const handleCreateOrigin = async () => { + if (!originFormData.name || !originFormData.origin_value) { + setOriginFormError('Name and value are required'); + return; + } + try { + setOriginSaving(true); + setOriginFormError(null); + await api.createTrustedOrigin(originFormData); + setShowOriginModal(false); + setOriginFormData({ name: '', origin_type: 'domain', origin_value: '', description: '' }); + fetchOrigins(); + } catch (err: any) { + setOriginFormError(err.message || 'Failed to create origin'); + } finally { + setOriginSaving(false); + } + }; + + const handleUpdateOrigin = async () => { + if (!editingOrigin) return; + try { + setOriginSaving(true); + setOriginFormError(null); + await api.updateTrustedOrigin(editingOrigin.id, originFormData); + setEditingOrigin(null); + setOriginFormData({ name: '', origin_type: 'domain', origin_value: '', description: '' }); + fetchOrigins(); + } catch (err: any) { + setOriginFormError(err.message || 'Failed to update origin'); + } finally { + setOriginSaving(false); + } + }; + + const handleDeleteOrigin = async (origin: TrustedOrigin) => { + if (!confirm(`Delete "${origin.name}"?`)) return; + try { + await api.deleteTrustedOrigin(origin.id); + fetchOrigins(); + } catch (err: any) { + alert(err.message || 'Failed to delete origin'); + } + }; + + const handleToggleOrigin = async (origin: TrustedOrigin) => { + try { + await api.toggleTrustedOrigin(origin.id); + fetchOrigins(); + } catch (err: any) { + alert(err.message || 'Failed to toggle origin'); + } + }; + + const openEditOriginModal = (origin: TrustedOrigin) => { + setEditingOrigin(origin); + setOriginFormData({ + name: origin.name, + origin_type: origin.origin_type, + origin_value: origin.origin_value, + description: origin.description || '' + }); + setOriginFormError(null); + }; + + const closeOriginModal = () => { + setShowOriginModal(false); + setEditingOrigin(null); + setOriginFormData({ name: '', origin_type: 'domain', origin_value: '', description: '' }); + setOriginFormError(null); + }; + + const getOriginTypeBadge = (type: string) => { + switch (type) { + case 'domain': return 'bg-blue-100 text-blue-800'; + case 'ip': return 'bg-green-100 text-green-800'; + case 'pattern': return 'bg-purple-100 text-purple-800'; + default: return 'bg-gray-100 text-gray-700'; + } + }; + const getRoleBadgeColor = (role: string) => { switch (role) { case 'superadmin': @@ -164,31 +292,69 @@ export function Users() {
-

User Management

-

Manage system users and their roles

+

Access Management

+

Manage users and trusted origins

- + {activeTab === 'users' ? ( + + ) : ( + + )} - {/* Error Message */} - {error && ( -
- -

{error}

-
- )} + {/* Tabs */} +
+ +
- {/* Users Table */} + {/* Users Tab Content */} + {activeTab === 'users' && ( + <> + {/* Error Message */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Users Table */}
{loading ? (
@@ -304,9 +470,124 @@ export function Users() {
+ + )} + + {/* Origins Tab Content */} + {activeTab === 'origins' && ( + <> + {originsError && ( +
+ +

{originsError}

+
+ )} + +
+ {originsLoading ? ( +
+
+

Loading trusted origins...

+
+ ) : origins.length === 0 ? ( +
+ No trusted origins configured +
+ ) : ( + + + + + + + + + + + + {origins.map((origin) => ( + + + + + + + + ))} + +
NameTypeValueStatusActions
+
+

{origin.name}

+ {origin.description && ( +

{origin.description}

+ )} +
+
+ + {origin.origin_type} + + + + {origin.origin_value} + + + + +
+ + +
+
+ )} +
+ + {/* Origin Types Legend */} +
+

Origin Types

+
+
+ + domain + +

Full URL (e.g., https://cannaiq.co)

+
+
+ + ip + +

IP address (e.g., 127.0.0.1)

+
+
+ + pattern + +

Regex pattern (e.g., ^https://.*\.cannabrands\.app$)

+
+
+
+ + )} - {/* Create/Edit Modal */} + {/* Create/Edit User Modal */} {(showCreateModal || editingUser) && (
@@ -408,6 +689,108 @@ export function Users() {
)} + + {/* Create/Edit Origin Modal */} + {(showOriginModal || editingOrigin) && ( +
+
+
+

+ {editingOrigin ? 'Edit Trusted Origin' : 'Add Trusted Origin'} +

+ +
+ +
+ {originFormError && ( +
+ +

{originFormError}

+
+ )} + +
+ + setOriginFormData({ ...originFormData, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500" + placeholder="e.g., CannaIQ Production" + /> +
+ +
+ + +
+ +
+ + setOriginFormData({ ...originFormData, origin_value: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 font-mono text-sm" + placeholder={ + originFormData.origin_type === 'domain' ? 'https://example.com' : + originFormData.origin_type === 'ip' ? '127.0.0.1' : + '^https://.*\\.example\\.com$' + } + /> +
+ +
+ + setOriginFormData({ ...originFormData, description: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500" + placeholder="Brief description of this origin" + /> +
+
+ +
+ + +
+
+
+ )} ); } diff --git a/cannaiq/src/pages/WorkersDashboard.tsx b/cannaiq/src/pages/WorkersDashboard.tsx index a2e0841e..6ef1842e 100644 --- a/cannaiq/src/pages/WorkersDashboard.tsx +++ b/cannaiq/src/pages/WorkersDashboard.tsx @@ -73,6 +73,10 @@ interface Worker { automationControlled?: boolean; }; productsReturned?: number; + detectedLocation?: { + city?: string; + region?: string; + }; }; is_qualified?: boolean; // Geo session fields