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:
92
backend/migrations/110_trusted_origins.sql
Normal file
92
backend/migrations/110_trusted_origins.sql
Normal 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';
|
||||
@@ -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)
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ interface Worker {
|
||||
automationControlled?: boolean;
|
||||
};
|
||||
productsReturned?: number;
|
||||
detectedLocation?: {
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
};
|
||||
is_qualified?: boolean;
|
||||
// Geo session fields
|
||||
|
||||
Reference in New Issue
Block a user