diff --git a/backend/app/api/gateway_routes.py b/backend/app/api/gateway_routes.py index 9d297df..6e83bbf 100644 --- a/backend/app/api/gateway_routes.py +++ b/backend/app/api/gateway_routes.py @@ -11,28 +11,33 @@ import httpx from fastapi import APIRouter, HTTPException, Request +from app.auth import ensure_https from app.models import GatewayConfig, GatewayCreateRequest, GatewayUpdateRequest +from app.api.auth_helpers import extract_bearer_token from app.api.config_store import get_effective_setting, get_overrides, update_overrides from app.config import get_settings import app.services.database as _db +from app.services.rbac import resolve_role, role_gte logger = logging.getLogger(__name__) gateway_router = APIRouter() settings = get_settings() -def _get_token(req: Request) -> str: - """Extract bearer token from request headers.""" - token = req.headers.get("X-Forwarded-Access-Token") - if not token: - auth = req.headers.get("Authorization", "") - if auth.startswith("Bearer "): - token = auth[7:] - if not token: - token = get_effective_setting("lakebase_service_token") or settings.databricks_token - if not token: - raise HTTPException(status_code=401, detail="No authentication token available") - return token + +async def _require_role(req: Request, min_role: str): + """Resolve caller's effective role and raise 403 if below min_role. + Uses extract_bearer_token (user OBO token only — no service-token fallback). + """ + token = extract_bearer_token(req) + identity = req.headers.get("X-Forwarded-Email", "") + host = _get_host() + role = await resolve_role(identity, token, host) + if not role_gte(role, min_role): + raise HTTPException( + status_code=403, + detail=f"Role '{min_role}' required. You have '{role}'." + ) def _get_host() -> str: @@ -40,9 +45,7 @@ def _get_host() -> str: host = get_effective_setting("databricks_host") or settings.databricks_host if not host: raise HTTPException(status_code=500, detail="DATABRICKS_HOST not configured") - if not host.startswith("http"): - host = f"https://{host}" - return host + return ensure_https(host) # --- Gateway CRUD --- @@ -69,7 +72,8 @@ async def list_gateways(): @gateway_router.post("/gateways", status_code=201) async def create_gateway(body: GatewayCreateRequest, req: Request): - """Create a new gateway configuration.""" + """Create a new gateway configuration. Owner only.""" + await _require_role(req, "owner") try: now = datetime.now(timezone.utc) user_email = req.headers.get("X-Forwarded-Email") @@ -132,8 +136,9 @@ async def get_gateway(gateway_id: str): @gateway_router.put("/gateways/{gateway_id}") -async def update_gateway(gateway_id: str, body: GatewayUpdateRequest): - """Update gateway fields.""" +async def update_gateway(gateway_id: str, body: GatewayUpdateRequest, req: Request): + """Update gateway fields. Manage or above.""" + await _require_role(req, "manage") try: updates = body.model_dump(exclude_none=True) if not updates: @@ -153,8 +158,9 @@ async def update_gateway(gateway_id: str, body: GatewayUpdateRequest): @gateway_router.delete("/gateways/{gateway_id}") -async def delete_gateway(gateway_id: str): - """Delete a gateway.""" +async def delete_gateway(gateway_id: str, req: Request): + """Delete a gateway. Owner only.""" + await _require_role(req, "owner") try: deleted = await _db.db_service.delete_gateway(gateway_id) if not deleted: @@ -218,8 +224,9 @@ async def get_gateway_cache(gateway_id: str): @gateway_router.delete("/gateways/{gateway_id}/cache") -async def clear_gateway_cache(gateway_id: str): - """Clear all cached entries for a specific gateway.""" +async def clear_gateway_cache(gateway_id: str, req: Request): + """Clear all cached entries for a specific gateway. Manage or above.""" + await _require_role(req, "manage") try: gw = await _db.db_service.get_gateway(gateway_id) if not gw: @@ -256,7 +263,7 @@ async def get_gateway_logs(gateway_id: str, limit: int = 50): async def list_genie_spaces(req: Request): """List available Genie Spaces from the workspace.""" try: - token = _get_token(req) + token = extract_bearer_token(req) host = _get_host() url = f"{host}/api/2.0/genie/spaces" @@ -280,7 +287,7 @@ async def list_genie_spaces(req: Request): async def list_warehouses(req: Request): """List available SQL warehouses from the workspace.""" try: - token = _get_token(req) + token = extract_bearer_token(req) host = _get_host() url = f"{host}/api/2.0/sql/warehouses" @@ -304,7 +311,7 @@ async def list_warehouses(req: Request): async def list_serving_endpoints(req: Request): """List available serving endpoints from the workspace.""" try: - token = _get_token(req) + token = extract_bearer_token(req) host = _get_host() url = f"{host}/api/2.0/serving-endpoints" @@ -439,8 +446,9 @@ class SettingsUpdateRequest(GatewayUpdateRequest): @gateway_router.put("/settings") -async def update_settings_endpoint(body: SettingsUpdateRequest): - """Update server configuration.""" +async def update_settings_endpoint(body: SettingsUpdateRequest, req: Request): + """Update server configuration. Owner only.""" + await _require_role(req, "owner") batch = {} updated = {} for field, value in body.model_dump(exclude_none=True).items(): diff --git a/backend/app/api/rbac_routes.py b/backend/app/api/rbac_routes.py new file mode 100644 index 0000000..20b321d --- /dev/null +++ b/backend/app/api/rbac_routes.py @@ -0,0 +1,84 @@ +"""RBAC management endpoints for user/role administration.""" + +import logging +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app.auth import ensure_https +from app.api.auth_helpers import extract_bearer_token +from app.api.config_store import get_effective_setting +from app.config import get_settings +from app.services.rbac import resolve_role, role_gte, ROLES, invalidate_role_cache + +logger = logging.getLogger(__name__) +rbac_router = APIRouter() +_settings = get_settings() + + +def _get_host() -> str: + host = get_effective_setting("databricks_host") or _settings.databricks_host or "" + return ensure_https(host) if host else host + + +async def _resolve_caller(req: Request): + """Extract and resolve the calling user's identity and effective role.""" + token = extract_bearer_token(req) + identity = req.headers.get("X-Forwarded-Email", "") + role = await resolve_role(identity, token, _get_host()) + return identity, token, role + + +async def _require_role(req: Request, min_role: str): + identity, token, role = await _resolve_caller(req) + if not role_gte(role, min_role): + raise HTTPException( + status_code=403, + detail=f"Role '{min_role}' required. You have '{role}'." + ) + return identity, token, role + + +@rbac_router.get("/users/me") +async def get_my_role(req: Request): + """Return the current user's identity and effective role.""" + identity, _, role = await _resolve_caller(req) + return {"identity": identity, "role": role} + + +@rbac_router.get("/users") +async def list_users(req: Request): + """List all explicit role assignments. Manage or above.""" + await _require_role(req, "manage") + import app.services.database as _db + return await _db.db_service.list_user_roles() + + +class RoleAssignment(BaseModel): + role: str + + +@rbac_router.post("/users/{email}/role", status_code=200) +async def assign_role(email: str, body: RoleAssignment, req: Request): + """Assign a role to a user. Manage or above.""" + identity, _, _ = await _require_role(req, "manage") + if body.role not in ROLES: + raise HTTPException( + status_code=400, + detail=f"Invalid role '{body.role}'. Valid roles: {ROLES}" + ) + import app.services.database as _db + await _db.db_service.set_user_role(email, body.role, granted_by=identity) + invalidate_role_cache(email) + logger.info("Role assigned: %s → %s by %s", email, body.role, identity) + return {"identity": email, "role": body.role} + + +@rbac_router.delete("/users/{email}") +async def remove_user_role(email: str, req: Request): + """Remove explicit role assignment (reverts to default 'use'). Manage or above.""" + identity, _, _ = await _require_role(req, "manage") + import app.services.database as _db + await _db.db_service.delete_user_role(email) + invalidate_role_cache(email) + logger.info("Role removed: %s by %s", email, identity) + return {"success": True} diff --git a/backend/app/main.py b/backend/app/main.py index 26cb091..b8d09c2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from app.api.genie_clone_routes import genie_clone_router from app.api.gateway_routes import gateway_router from app.api.mcp_routes import mcp_router +from app.api.rbac_routes import rbac_router from app.config import get_settings logger = logging.getLogger(__name__) @@ -78,6 +79,7 @@ async def _token_refresh_loop(): app.include_router(router, prefix="/api") app.include_router(gateway_router, prefix="/api") +app.include_router(rbac_router, prefix="/api") app.include_router(genie_clone_router, prefix="/api/2.0/genie") app.include_router(mcp_router, prefix="/api/2.0/mcp") diff --git a/backend/app/services/database.py b/backend/app/services/database.py index 8e6709b..3da97cd 100644 --- a/backend/app/services/database.py +++ b/backend/app/services/database.py @@ -157,3 +157,17 @@ async def delete_gateway(self, gateway_id: str) -> bool: async def get_gateway_stats(self, gateway_id: str) -> dict: return await self.backend.get_gateway_stats(gateway_id) + + # --- User roles --- + + async def get_user_role(self, identity: str): + return await self.backend.get_user_role(identity) + + async def set_user_role(self, identity: str, role: str, granted_by: str = None): + return await self.backend.set_user_role(identity, role, granted_by) + + async def list_user_roles(self) -> list: + return await self.backend.list_user_roles() + + async def delete_user_role(self, identity: str): + return await self.backend.delete_user_role(identity) diff --git a/backend/app/services/rbac.py b/backend/app/services/rbac.py new file mode 100644 index 0000000..68c9894 --- /dev/null +++ b/backend/app/services/rbac.py @@ -0,0 +1,106 @@ +""" +Role-based access control for Genie Cache Gateway. + +Roles (lowest → highest privilege): + use — query only: submit questions, view results + manage — configure gateways, view/clear cache, manage users + owner — full control: create/delete gateways, configure settings + +Workspace admins are always treated as owner regardless of the user_roles table. +Unassigned users default to 'use'. +""" + +import logging +import time + +import httpx + +from app.auth import ensure_https + +logger = logging.getLogger(__name__) + +ROLES = ['use', 'manage', 'owner'] +ROLE_HIERARCHY = {'use': 1, 'manage': 2, 'owner': 3} +DEFAULT_ROLE = 'use' + +# Shared HTTP client — avoids per-call TCP+TLS handshake overhead +_http_client = httpx.AsyncClient(timeout=5.0) + +# Short-lived in-process caches to avoid hammering SCIM and DB on every request. +# Keys: token (admin check) and identity (role lookup). TTLs are conservative — +# role changes take effect within the TTL window without a restart. +_ADMIN_CACHE_TTL = 60.0 # seconds +_ROLE_CACHE_TTL = 120.0 # seconds +_admin_cache: dict[str, tuple[bool, float]] = {} # token → (is_admin, expires_at) +_role_cache: dict[str, tuple[str, float]] = {} # identity → (role, expires_at) + + +def role_gte(a: str, b: str) -> bool: + """Return True if role a >= role b in the privilege hierarchy.""" + return ROLE_HIERARCHY.get(a, 0) >= ROLE_HIERARCHY.get(b, 0) + + +def invalidate_role_cache(identity: str) -> None: + """Evict a cached role so the next request re-reads from the database. + Call this immediately after any set_user_role / delete_user_role write. + """ + _role_cache.pop(identity, None) + + +async def is_workspace_admin(token: str, host: str) -> bool: + """Check if the token owner is a Databricks workspace admin via SCIM /Me. + Result is cached for _ADMIN_CACHE_TTL seconds to avoid per-request SCIM calls. + """ + if not token or not host: + return False + host = ensure_https(host) + + now = time.monotonic() + cached = _admin_cache.get(token) + if cached is not None: + result, expires_at = cached + if now < expires_at: + return result + + result = False + try: + resp = await _http_client.get( + f"{host}/api/2.0/preview/scim/v2/Me", + headers={"Authorization": f"Bearer {token}"} + ) + if resp.status_code == 200: + groups = resp.json().get("groups", []) + result = any(g.get("display") == "admins" for g in groups) + except Exception as e: + logger.debug("Workspace admin check failed: %s", e) + + _admin_cache[token] = (result, now + _ADMIN_CACHE_TTL) + return result + + +async def resolve_role(identity: str, token: str, host: str) -> str: + """ + Resolve the effective role for a user: + 1. Workspace admins → 'owner' (checked via Databricks SCIM API, cached 60 s) + 2. Explicit assignment in user_roles table (cached 120 s, invalidated on write) + 3. Default → 'use' + """ + import app.services.database as _db + + if await is_workspace_admin(token, host): + return 'owner' + + now = time.monotonic() + cached = _role_cache.get(identity) + if cached is not None: + role, expires_at = cached + if now < expires_at: + return role + + assigned = None + if _db.db_service and identity: + assigned = await _db.db_service.get_user_role(identity) + + role = assigned or DEFAULT_ROLE + _role_cache[identity] = (role, now + _ROLE_CACHE_TTL) + return role diff --git a/backend/app/services/storage_dynamic.py b/backend/app/services/storage_dynamic.py index 02a97ba..1c2e6e5 100644 --- a/backend/app/services/storage_dynamic.py +++ b/backend/app/services/storage_dynamic.py @@ -342,3 +342,29 @@ async def get_gateway_stats(self, gateway_id: str) -> dict: if hasattr(backend, 'pool'): return await backend.get_gateway_stats(gateway_id) return backend.get_gateway_stats(gateway_id) + + # --- User roles CRUD (delegates to default backend) --- + + async def get_user_role(self, identity: str): + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.get_user_role(identity) + return backend.get_user_role(identity) + + async def set_user_role(self, identity: str, role: str, granted_by: str = None): + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.set_user_role(identity, role, granted_by) + return backend.set_user_role(identity, role, granted_by) + + async def list_user_roles(self) -> list: + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.list_user_roles() + return backend.list_user_roles() + + async def delete_user_role(self, identity: str): + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.delete_user_role(identity) + return backend.delete_user_role(identity) diff --git a/backend/app/services/storage_local.py b/backend/app/services/storage_local.py index 28fc26b..0a7fedf 100644 --- a/backend/app/services/storage_local.py +++ b/backend/app/services/storage_local.py @@ -30,6 +30,7 @@ def __init__(self, cache_file: str, embeddings_file: str, cache_ttl_hours: int = self.embeddings_file = embeddings_file self.cache_ttl_hours = cache_ttl_hours self._gateways: Dict = {} + self._user_roles: Dict = {} # identity -> {role, granted_by, granted_at} self._ensure_data_dir() self._load_data() @@ -218,6 +219,26 @@ def delete_gateway(self, gateway_id: str) -> bool: logger.info("Gateway deleted: id=%s", gateway_id) return True + # --- User roles CRUD --- + + def get_user_role(self, identity: str): + entry = self._user_roles.get(identity) + return entry["role"] if entry else None + + def set_user_role(self, identity: str, role: str, granted_by: str = None): + self._user_roles[identity] = { + "identity": identity, + "role": role, + "granted_by": granted_by, + "granted_at": datetime.now().isoformat() + "Z", + } + + def list_user_roles(self) -> list: + return sorted(self._user_roles.values(), key=lambda r: r.get("granted_at", ""), reverse=True) + + def delete_user_role(self, identity: str): + self._user_roles.pop(identity, None) + def get_gateway_stats(self, gateway_id: str) -> Dict: """Get cache and query stats for a gateway.""" gw = self._gateways.get(gateway_id) diff --git a/backend/app/services/storage_pgvector.py b/backend/app/services/storage_pgvector.py index ba07479..407a6c0 100644 --- a/backend/app/services/storage_pgvector.py +++ b/backend/app/services/storage_pgvector.py @@ -71,6 +71,7 @@ def __init__( # Gateway table in same schema as cache table schema_prefix = self.table_name.rsplit('.', 1)[0] if '.' in self.table_name else 'public' self.gateway_table_name = f"{schema_prefix}.gateway_configs" + self.user_roles_table_name = f"{schema_prefix}.user_roles" def _normalize_table_name(self, table_name: str) -> str: """Convert Databricks catalog.schema.table to PostgreSQL schema.table format.""" @@ -172,6 +173,7 @@ async def initialize(self): await self._ensure_table(conn) await self._ensure_query_log_table(conn) await self._ensure_gateway_table(conn) + await self._ensure_user_roles_table(conn) await self._migrate_genie_space_id_columns(conn) await self._migrate_original_query_text(conn) await self._migrate_caching_enabled(conn) @@ -459,6 +461,18 @@ async def _ensure_query_log_table(self, conn): logger.info("Query log table '%s' initialized", self.query_log_table_name) + async def _ensure_user_roles_table(self, conn): + """Create the user_roles table if it does not exist.""" + await conn.execute(f""" + CREATE TABLE IF NOT EXISTS {self.user_roles_table_name} ( + identity TEXT PRIMARY KEY, + role TEXT NOT NULL, + granted_by TEXT, + granted_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + logger.info("User roles table '%s' initialized", self.user_roles_table_name) + async def _ensure_gateway_table(self, conn): """Create the gateway_configs table if it does not exist.""" await conn.execute(f""" @@ -962,6 +976,61 @@ async def get_gateway_stats(self, gateway_id: str) -> dict: return {"cache_count": cache_count, "query_count_7d": query_count, "cache_hits_7d": cache_hits} + # --- User roles CRUD --- + + async def get_user_role(self, identity: str) -> Optional[str]: + """Return the explicit role for a user, or None if not assigned.""" + if not self.pool: + return None + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + f"SELECT role FROM {self.user_roles_table_name} WHERE identity = $1", + identity + ) + return row["role"] if row else None + + async def set_user_role(self, identity: str, role: str, granted_by: str = None): + """Insert or update a user's role assignment.""" + if not self.pool: + return + async with self.pool.acquire() as conn: + await conn.execute(f""" + INSERT INTO {self.user_roles_table_name} (identity, role, granted_by, granted_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (identity) DO UPDATE + SET role = EXCLUDED.role, + granted_by = EXCLUDED.granted_by, + granted_at = NOW() + """, identity, role, granted_by) + + async def list_user_roles(self) -> list: + """Return all explicit role assignments.""" + if not self.pool: + return [] + async with self.pool.acquire() as conn: + rows = await conn.fetch( + f"SELECT identity, role, granted_by, granted_at FROM {self.user_roles_table_name} ORDER BY granted_at DESC" + ) + return [ + { + "identity": r["identity"], + "role": r["role"], + "granted_by": r["granted_by"], + "granted_at": _to_utc_iso(r["granted_at"]), + } + for r in rows + ] + + async def delete_user_role(self, identity: str): + """Remove an explicit role assignment.""" + if not self.pool: + return + async with self.pool.acquire() as conn: + await conn.execute( + f"DELETE FROM {self.user_roles_table_name} WHERE identity = $1", + identity + ) + def _row_to_gateway_dict(self, row) -> dict: """Convert a database row to a gateway dict.""" return { diff --git a/frontend/src/components/gateways/GatewayDetailPage.jsx b/frontend/src/components/gateways/GatewayDetailPage.jsx index b011a16..3eb4cb1 100644 --- a/frontend/src/components/gateways/GatewayDetailPage.jsx +++ b/frontend/src/components/gateways/GatewayDetailPage.jsx @@ -2,23 +2,25 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { Trash2, Play, Copy, Loader2 } from 'lucide-react' import { api } from '../../services/api' +import { useRole } from '../../context/RoleContext' import GatewayOverviewTab from './GatewayOverviewTab' import GatewayMetricsTab from './GatewayMetricsTab' import GatewayCacheTab from './GatewayCacheTab' import GatewayLogsTab from './GatewayLogsTab' import GatewaySettingsTab from './GatewaySettingsTab' -const TABS = [ +const ALL_TABS = [ { id: 'overview', label: 'Overview' }, { id: 'metrics', label: 'Metrics' }, { id: 'cache', label: 'Cache' }, { id: 'logs', label: 'Logs' }, - { id: 'settings', label: 'Settings' }, + { id: 'settings', label: 'Settings', minRole: 'manage' }, ] export default function GatewayDetailPage() { const { id } = useParams() const navigate = useNavigate() + const { isOwner, isManage } = useRole() const [gateway, setGateway] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -63,6 +65,8 @@ export default function GatewayDetailPage() { navigator.clipboard.writeText(text) } + const TABS = ALL_TABS.filter((t) => !t.minRole || isManage) + if (loading) { return (
@@ -125,14 +129,16 @@ export default function GatewayDetailPage() { Test in Playground - + {isOwner && ( + + )}
diff --git a/frontend/src/components/gateways/GatewayListPage.jsx b/frontend/src/components/gateways/GatewayListPage.jsx index a2af5ae..441d6f9 100644 --- a/frontend/src/components/gateways/GatewayListPage.jsx +++ b/frontend/src/components/gateways/GatewayListPage.jsx @@ -6,9 +6,11 @@ import DataTable from '../shared/DataTable' import StatusBadge from '../shared/StatusBadge' import EmptyState from '../shared/EmptyState' import GatewayCreateModal from './GatewayCreateModal' +import { useRole } from '../../context/RoleContext' export default function GatewayListPage() { const navigate = useNavigate() + const { isOwner } = useRole() const [gateways, setGateways] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -153,13 +155,15 @@ export default function GatewayListPage() { Intelligent caching gateway for Databricks Genie API

- + {isOwner && ( + + )} {/* Search bar */} @@ -198,7 +202,7 @@ export default function GatewayListPage() { icon={Layers} title="No gateways yet" description="Create a gateway to start caching Genie queries and accelerating your analytics." - action={ + action={isOwner ? ( - } + ) : null} /> ) : ( )} - {/* Create modal */} - setShowCreate(false)} - onCreated={handleCreated} - /> + {isOwner && ( + setShowCreate(false)} + onCreated={handleCreated} + /> + )} ) } diff --git a/frontend/src/components/settings/SettingsPage.jsx b/frontend/src/components/settings/SettingsPage.jsx index ee639d3..b0f0a59 100644 --- a/frontend/src/components/settings/SettingsPage.jsx +++ b/frontend/src/components/settings/SettingsPage.jsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { Pencil, Eye, EyeOff, Loader2, CheckCircle, XCircle, FlaskConical, Database, SlidersHorizontal, Palette } from 'lucide-react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { Pencil, Eye, EyeOff, Loader2, CheckCircle, XCircle, FlaskConical, Database, SlidersHorizontal, Palette, Users, Trash2 } from 'lucide-react' import { api } from '../../services/api' import { useTheme } from '../../context/ThemeContext' +import { useRole } from '../../context/RoleContext' const secondsToTtl = (seconds) => { if (!seconds || seconds === 0) return { value: '0', unit: 'hours' } @@ -125,7 +126,7 @@ function EndpointSelect({ value, onChange, endpoints, loading, placeholder, filt } /* ── Sidebar structure ── */ -const SIDEBAR = [ +const SIDEBAR_BASE = [ { category: 'Preferences', icon: Palette, items: [{ id: 'appearance', label: 'Appearance' }] }, { category: 'Connection', icon: Database, items: [{ id: 'general', label: 'General' }] }, { category: 'Gateway Defaults', icon: SlidersHorizontal, items: [ @@ -134,11 +135,23 @@ const SIDEBAR = [ { id: 'ai-pipeline', label: 'AI Pipeline' }, ]}, ] +const SIDEBAR_MANAGE = [ + { category: 'Access Control', icon: Users, items: [{ id: 'users', label: 'Users' }] }, +] /* ── Main component ── */ export default function SettingsPage() { const { themeMode, setThemeMode } = useTheme() - const [activeSection, setActiveSection] = useState('general') + const { isOwner, isManage } = useRole() + const [activeSection, setActiveSection] = useState('appearance') + + // Users management state (manage role and above) + const [users, setUsers] = useState([]) + const [usersLoading, setUsersLoading] = useState(false) + const [newUserEmail, setNewUserEmail] = useState('') + const [newUserRole, setNewUserRole] = useState('use') + const [userSaving, setUserSaving] = useState(false) + const usersLoadedRef = useRef(false) const [config, setConfig] = useState({ storage_backend: 'lakebase', lakebase_service_token: '', lakebase_instance_name: '', lakebase_catalog: 'default', lakebase_schema: 'public', @@ -198,6 +211,46 @@ export default function SettingsPage() { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } }, []) + // Load users once when manage/owner navigates to the users section + useEffect(() => { + if (isManage && activeSection === 'users' && !usersLoadedRef.current) { + usersLoadedRef.current = true + setUsersLoading(true) + api.listUsers() + .then(setUsers) + .catch(() => setUsers([])) + .finally(() => setUsersLoading(false)) + } + }, [isManage, activeSection]) + + const handleAddUser = async () => { + if (!newUserEmail.trim()) return + setUserSaving(true) + try { + const saved = await api.setUserRole(newUserEmail.trim(), newUserRole) + // Optimistic update — upsert locally using the response, no second round-trip + setUsers(prev => { + const without = prev.filter(u => u.identity !== saved.identity) + return [...without, saved] + }) + setNewUserEmail('') + setNewUserRole('use') + } catch { /* ignore */ } + finally { setUserSaving(false) } + } + + const handleRemoveUser = async (email) => { + try { + await api.deleteUserRole(email) + setUsers(prev => prev.filter(u => u.identity !== email)) + } catch { /* ignore */ } + } + + const SIDEBAR = useMemo( + () => (isManage ? [...SIDEBAR_BASE, ...SIDEBAR_MANAGE] : SIDEBAR_BASE), + [isManage] + ) + const persistSettings = useCallback(async () => { const c = configRef.current setSaveStatus('saving') @@ -460,6 +513,122 @@ export default function SettingsPage() { + {/* ── Users (manage+) ── */} + {isManage && ( +
sectionRefs.current['users'] = el} className="mb-10"> +

Users

+ +
+ Workspace admins always have Owner access regardless of this list. +
+ + {/* Role matrix */} +
+ + + + + + + + + + + + {[ + { role: 'use', q: true, c: false, cd: false, mu: false }, + { role: 'manage', q: true, c: true, cd: false, mu: true }, + { role: 'owner', q: true, c: true, cd: true, mu: true }, + ].map(({ role, q, c, cd, mu }) => ( + + + {[q, c, cd, mu].map((v, i) => ( + + ))} + + ))} + +
RoleQueryConfigureCreate/DeleteManage Users
{role} + {v ? '●' : '○'} +
+
+ + {/* User list */} + {usersLoading ? ( +
+ Loading users... +
+ ) : ( +
+ {users.length === 0 ? ( +
+ No explicit role assignments. Add users below. +
+ ) : ( + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
UserRoleGranted by +
{u.identity}{u.role}{u.granted_by || '—'} + +
+ )} +
+ )} + + {/* Add user */} +
+ setNewUserEmail(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddUser()} + className={`${inputClass} flex-1`} + style={{ maxWidth: '260px' }} + /> + + +
+
+ )} + {/* ── AI Pipeline ── */}
sectionRefs.current['ai-pipeline'] = el} className="mb-10">

AI Pipeline

diff --git a/frontend/src/context/RoleContext.jsx b/frontend/src/context/RoleContext.jsx new file mode 100644 index 0000000..a354e73 --- /dev/null +++ b/frontend/src/context/RoleContext.jsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { api } from '../services/api' + +// role: 'owner' | 'manage' | 'use' +// isOwner: role === 'owner' +// isManage: role === 'manage' || role === 'owner' + +const RoleContext = createContext({ role: 'use', isOwner: false, isManage: false, loading: true }) + +export function RoleProvider({ children }) { + const [role, setRole] = useState('use') + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.getMyRole() + .then((data) => setRole(data.role || 'use')) + .catch(() => setRole('use')) + .finally(() => setLoading(false)) + }, []) + + const value = useMemo(() => ({ + role, + loading, + isOwner: role === 'owner', + isManage: role === 'manage' || role === 'owner', + }), [role, loading]) + + return ( + + {children} + + ) +} + +export function useRole() { + return useContext(RoleContext) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index ea6ce77..67a2a64 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,13 +3,16 @@ import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { ThemeProvider } from './context/ThemeContext.jsx' +import { RoleProvider } from './context/RoleContext.jsx' import './index.css' ReactDOM.createRoot(document.getElementById('root')).render( - + + + , diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index a76079c..66d33b4 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -259,6 +259,26 @@ export const api = { return response.data; }, + getMyRole: async () => { + const response = await axios.get(`${API_BASE_URL}/users/me`); + return response.data; // { identity, role } + }, + + listUsers: async () => { + const response = await axios.get(`${API_BASE_URL}/users`); + return response.data; // [{ identity, role, granted_by, granted_at }] + }, + + setUserRole: async (email, role) => { + const response = await axios.post(`${API_BASE_URL}/users/${encodeURIComponent(email)}/role`, { role }); + return response.data; + }, + + deleteUserRole: async (email) => { + const response = await axios.delete(`${API_BASE_URL}/users/${encodeURIComponent(email)}`); + return response.data; + }, + getWorkspaceAppearance: async () => { try { const response = await axios.get(`${API_BASE_URL}/workspace-appearance`);