feat: Add trusted origins management UI at /users

- Create trusted_origins table for DB-backed origin management
- Add API routes for CRUD operations on trusted origins
- Add tabbed interface on /users page with Users and Trusted Origins tabs
- Seeds default trusted origins (cannaiq.co, findadispo.com, findagram.co, etc.)
- Fix TypeScript error in WorkersDashboard fingerprint type

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-13 19:54:26 -07:00
parent eb5b2a876e
commit b456fe5097
6 changed files with 643 additions and 161 deletions

View File

@@ -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';

View File

@@ -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)

View File

@@ -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' });
}
});

View File

@@ -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<{

View File

@@ -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<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -29,6 +51,16 @@ export function Users() {
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Trusted Origins state
const [origins, setOrigins] = useState<TrustedOrigin[]>([]);
const [originsLoading, setOriginsLoading] = useState(true);
const [originsError, setOriginsError] = useState<string | null>(null);
const [showOriginModal, setShowOriginModal] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<TrustedOrigin | null>(null);
const [originFormData, setOriginFormData] = useState<OriginFormData>({ name: '', origin_type: 'domain', origin_value: '', description: '' });
const [originFormError, setOriginFormError] = useState<string | null>(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() {
<div className="flex items-center gap-3">
<UsersIcon className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500">Manage system users and their roles</p>
<h1 className="text-2xl font-bold text-gray-900">Access Management</h1>
<p className="text-sm text-gray-500">Manage users and trusted origins</p>
</div>
</div>
<button
onClick={() => {
setShowCreateModal(true);
setFormError(null);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Add User
</button>
{activeTab === 'users' ? (
<button
onClick={() => { setShowCreateModal(true); setFormError(null); }}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Add User
</button>
) : (
<button
onClick={() => { setShowOriginModal(true); setOriginFormError(null); }}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
>
<Plus className="w-4 h-4" />
Add Origin
</button>
)}
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500" />
<p className="text-red-700">{error}</p>
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('users')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'users'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<UsersIcon className="w-4 h-4" />
Users ({users.length})
</button>
<button
onClick={() => setActiveTab('origins')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'origins'
? 'border-emerald-500 text-emerald-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Shield className="w-4 h-4" />
Trusted Origins ({origins.length})
</button>
</nav>
</div>
{/* Users Table */}
{/* Users Tab Content */}
{activeTab === 'users' && (
<>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500" />
<p className="text-red-700">{error}</p>
</div>
)}
{/* Users Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-8 text-center">
@@ -304,9 +470,124 @@ export function Users() {
</div>
</div>
</div>
</>
)}
{/* Origins Tab Content */}
{activeTab === 'origins' && (
<>
{originsError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500" />
<p className="text-red-700">{originsError}</p>
</div>
)}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{originsLoading ? (
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600 mx-auto"></div>
<p className="mt-2 text-gray-500">Loading trusted origins...</p>
</div>
) : origins.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No trusted origins configured
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{origins.map((origin) => (
<tr key={origin.id} className={`hover:bg-gray-50 ${!origin.active ? 'opacity-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<p className="text-sm font-medium text-gray-900">{origin.name}</p>
{origin.description && (
<p className="text-xs text-gray-500">{origin.description}</p>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getOriginTypeBadge(origin.origin_type)}`}>
{origin.origin_type}
</span>
</td>
<td className="px-6 py-4">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono break-all">
{origin.origin_value}
</code>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleOrigin(origin)}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full ${
origin.active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}
>
{origin.active ? <Power className="w-3 h-3" /> : <PowerOff className="w-3 h-3" />}
{origin.active ? 'Active' : 'Inactive'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditOriginModal(origin)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteOrigin(origin)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Origin Types Legend */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Origin Types</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getOriginTypeBadge('domain')}`}>
domain
</span>
<p className="mt-1 text-gray-500">Full URL (e.g., https://cannaiq.co)</p>
</div>
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getOriginTypeBadge('ip')}`}>
ip
</span>
<p className="mt-1 text-gray-500">IP address (e.g., 127.0.0.1)</p>
</div>
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getOriginTypeBadge('pattern')}`}>
pattern
</span>
<p className="mt-1 text-gray-500">Regex pattern (e.g., ^https://.*\.cannabrands\.app$)</p>
</div>
</div>
</div>
</>
)}
</div>
{/* Create/Edit Modal */}
{/* Create/Edit User Modal */}
{(showCreateModal || editingUser) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
@@ -408,6 +689,108 @@ export function Users() {
</div>
</div>
)}
{/* Create/Edit Origin Modal */}
{(showOriginModal || editingOrigin) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
{editingOrigin ? 'Edit Trusted Origin' : 'Add Trusted Origin'}
</h2>
<button onClick={closeOriginModal} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
{originFormError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<p className="text-sm text-red-700">{originFormError}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={originFormData.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Type</label>
<select
value={originFormData.origin_type}
onChange={(e) => setOriginFormData({ ...originFormData, origin_type: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="domain">Domain (full URL)</option>
<option value="ip">IP Address</option>
<option value="pattern">Regex Pattern</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Value</label>
<input
type="text"
value={originFormData.origin_value}
onChange={(e) => 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$'
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<input
type="text"
value={originFormData.description}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
<button
onClick={closeOriginModal}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
disabled={originSaving}
>
Cancel
</button>
<button
onClick={editingOrigin ? handleUpdateOrigin : handleCreateOrigin}
disabled={originSaving}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50"
>
{originSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Saving...
</>
) : (
<>
<Check className="w-4 h-4" />
{editingOrigin ? 'Update' : 'Add'}
</>
)}
</button>
</div>
</div>
</div>
)}
</Layout>
);
}

View File

@@ -73,6 +73,10 @@ interface Worker {
automationControlled?: boolean;
};
productsReturned?: number;
detectedLocation?: {
city?: string;
region?: string;
};
};
is_qualified?: boolean;
// Geo session fields