feat: access request UI — catalog badges, request modal, admin approval page

Backend:
- access_requests table in DuckDB schema
- AccessRequestRepository with create/approve/deny/list
- API: POST/GET /api/access-requests (submit, my requests, pending, approve, deny)

UI:
- Catalog: lock icon on private tables, "Request Access" button + modal
- Catalog: "Pending" badge for tables with pending requests
- Admin permissions page (/admin/permissions): approve/deny requests,
  grant/revoke permissions, view all user permissions
- Cross-navigation between admin/tables and admin/permissions

733 tests passing.
This commit is contained in:
ZdenekSrotyr 2026-03-31 12:45:29 +02:00
parent 1074d5ec49
commit 2e7d5d1fe9
8 changed files with 1726 additions and 7 deletions

View file

@ -0,0 +1,97 @@
"""Access request API — users request access, admins approve/deny."""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import duckdb
from app.auth.dependencies import get_current_user, require_role, Role, _get_db
from src.repositories.access_requests import AccessRequestRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/access-requests", tags=["access-requests"])
class AccessRequestCreate(BaseModel):
table_id: str
reason: Optional[str] = ""
@router.post("", status_code=201)
async def create_request(
request: AccessRequestCreate,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Submit an access request for a table."""
repo = AccessRequestRepository(conn)
# Check for duplicate pending request
if repo.has_pending_request(user["id"], request.table_id):
raise HTTPException(status_code=409, detail="You already have a pending request for this table")
req_id = repo.create(
user_id=user["id"],
user_email=user.get("email", ""),
table_id=request.table_id,
reason=request.reason or "",
)
return {"id": req_id, "status": "pending", "table_id": request.table_id}
@router.get("/my")
async def my_requests(
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""List current user's access requests."""
repo = AccessRequestRepository(conn)
requests = repo.list_by_user(user["id"])
# Serialize timestamps
for r in requests:
for k in ("created_at", "reviewed_at"):
if r.get(k) and hasattr(r[k], "isoformat"):
r[k] = r[k].isoformat()
return {"requests": requests}
@router.get("/pending")
async def pending_requests(
user: dict = Depends(require_role(Role.ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""List all pending access requests (admin only)."""
repo = AccessRequestRepository(conn)
requests = repo.list_pending()
for r in requests:
for k in ("created_at", "reviewed_at"):
if r.get(k) and hasattr(r[k], "isoformat"):
r[k] = r[k].isoformat()
return {"requests": requests, "count": len(requests)}
@router.post("/{request_id}/approve")
async def approve_request(
request_id: str,
user: dict = Depends(require_role(Role.ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Approve an access request (admin only). Auto-grants permission."""
repo = AccessRequestRepository(conn)
if repo.approve(request_id, reviewed_by=user.get("email", "")):
return {"status": "approved", "id": request_id}
raise HTTPException(status_code=404, detail="Request not found or already processed")
@router.post("/{request_id}/deny")
async def deny_request(
request_id: str,
user: dict = Depends(require_role(Role.ADMIN)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Deny an access request (admin only)."""
repo = AccessRequestRepository(conn)
if repo.deny(request_id, reviewed_by=user.get("email", "")):
return {"status": "denied", "id": request_id}
raise HTTPException(status_code=404, detail="Request not found or already processed")

View file

@ -24,6 +24,7 @@ from app.api.catalog import router as catalog_router
from app.api.telegram import router as telegram_router
from app.api.admin import router as admin_router
from app.api.permissions import router as permissions_router
from app.api.access_requests import router as access_requests_router
from app.web.router import router as web_router
logger = logging.getLogger(__name__)
@ -103,6 +104,7 @@ def create_app() -> FastAPI:
app.include_router(telegram_router)
app.include_router(admin_router)
app.include_router(permissions_router)
app.include_router(access_requests_router)
# Web UI router (must be last — has catch-all routes)
app.include_router(web_router)

View file

@ -26,6 +26,7 @@ from src.repositories.sync_settings import SyncSettingsRepository, DatasetPermis
from src.repositories.knowledge import KnowledgeRepository
from src.repositories.users import UserRepository
from src.repositories.profiles import ProfileRepository
from src.repositories.access_requests import AccessRequestRepository
logger = logging.getLogger(__name__)
router = APIRouter(tags=["web"])
@ -240,27 +241,45 @@ async def catalog(
try:
from src.repositories.table_registry import TableRegistryRepository
table_repo = TableRegistryRepository(conn)
perm_repo = DatasetPermissionRepository(conn)
access_repo = AccessRequestRepository(conn)
registered = table_repo.list_all()
# Pre-fetch user's pending access requests
user_id = user.get("id", "")
user_requests = access_repo.list_by_user(user_id)
pending_request_table_ids = {
r["table_id"] for r in user_requests if r.get("status") == "pending"
}
tables = []
for tc in registered:
table_id = tc.get("id", "")
is_public = tc.get("is_public", True)
has_access = is_public or perm_repo.has_access(user_id, table_id)
table_data = {
"id": tc.get("id", ""),
"id": table_id,
"name": tc.get("name", ""),
"description": tc.get("description", ""),
"dataset": tc.get("bucket"),
"sync_strategy": tc.get("sync_strategy", "full_refresh"),
"query_mode": tc.get("query_mode", "local"),
"profile": all_profiles.get(tc.get("id", "")),
"profile": all_profiles.get(table_id),
"is_public": is_public,
"has_access": has_access,
"pending_request": table_id in pending_request_table_ids,
}
# Add sync state
for state in all_states:
if state["table_id"] == tc.get("id"):
if state["table_id"] == table_id:
table_data["last_sync"] = state.get("last_sync")
table_data["rows"] = state.get("rows")
break
tables.append(table_data)
except Exception as e:
tables = []
pending_request_table_ids = set()
logger.warning(f"Could not load catalog: {e}")
# Build data_stats for catalog template
@ -281,13 +300,20 @@ async def catalog(
categories[ds] = {"name": ds, "tables": []}
categories[ds]["tables"].append(t)
# Add count to each category (template expects .count)
catalog_data = []
for cat in categories.values():
cat["count"] = len(cat["tables"])
catalog_data.append(cat)
ctx = _build_context(
request, user=user,
tables=tables,
datasets=datasets,
enabled_datasets=enabled_datasets,
data_stats=data_stats,
categories=list(categories.values()),
categories=catalog_data,
catalog_data=catalog_data,
metrics_data=[],
sync_states=all_states,
folder_mapping={},
@ -391,3 +417,14 @@ async def admin_tables(
tables = repo.list_all()
ctx = _build_context(request, user=user, registered_tables=tables)
return templates.TemplateResponse(request, "admin_tables.html", ctx)
@router.get("/admin/permissions", response_class=HTMLResponse)
async def admin_permissions_page(
request: Request,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Admin page for managing permissions and access requests."""
ctx = _build_context(request, user=user)
return templates.TemplateResponse(request, "admin_permissions.html", ctx)

File diff suppressed because it is too large Load diff

View file

@ -750,7 +750,8 @@
</div>
</div>
<div class="header-right">
Admin
<a href="/admin/permissions" style="font-size: 12px; font-weight: 500; color: var(--primary); text-decoration: none; padding: 6px 12px; border-radius: 6px; transition: all 0.15s ease;">Permissions</a>
<span>Admin</span>
</div>
</header>

View file

@ -373,6 +373,158 @@
background: rgba(243, 244, 246, 0.5);
}
.table-row-locked {
opacity: 0.75;
}
.table-row-locked:hover {
background: rgba(234, 88, 12, 0.04);
}
.access-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
margin-left: 6px;
vertical-align: middle;
font-family: var(--font-primary);
}
.access-badge.locked {
font-size: 13px;
padding: 0 2px;
}
.access-badge.pending {
background: #FFF7ED;
color: #EA580C;
border: 1px solid #FDBA74;
}
.btn-request-access {
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
border-radius: 6px;
background: var(--primary-light);
color: var(--primary);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn-request-access:hover {
background: var(--primary);
color: #fff;
}
/* Request Access Modal */
.request-access-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1100;
padding: 40px 24px;
}
.request-access-overlay.active {
display: flex;
align-items: center;
justify-content: center;
}
.request-access-modal {
max-width: 500px;
width: 100%;
background: var(--surface);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.request-access-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
}
.request-access-modal .modal-header h3 {
font-size: 16px;
font-weight: 600;
}
.request-access-modal .modal-body {
padding: 20px 24px;
}
.request-access-modal .modal-body label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-top: 16px;
margin-bottom: 6px;
}
.request-access-modal .modal-body textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-family: var(--font-primary);
font-size: 13px;
resize: vertical;
}
.request-access-modal .modal-body textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.request-access-modal .modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px 20px;
border-top: 1px solid var(--border);
}
.request-access-modal .btn-secondary {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: var(--text-primary);
}
.request-access-modal .btn-primary {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.request-access-modal .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.table-row-left {
flex: 1;
min-width: 0;
@ -1414,10 +1566,17 @@
</button>
<div class="accordion-content">
{% for table in category.tables %}
<div class="table-row" {% if table.query_mode != 'remote' %}onclick="openProfiler('{{ table.name }}')"{% endif %}>
<div class="table-row{% if not table.is_public and not table.has_access %} table-row-locked{% endif %}" {% if not table.is_public and not table.has_access %}{% if table.pending_request %}title="Access request pending"{% else %}onclick="openRequestModal('{{ table.id }}', '{{ table.name }}')"{% endif %}{% elif table.query_mode != 'remote' %}onclick="openProfiler('{{ table.name }}')"{% endif %}>
<div class="table-row-left">
<div class="table-row-name">
{{ table.name }}
{% if not table.is_public and not table.has_access %}
{% if table.pending_request %}
<span class="access-badge pending">Pending</span>
{% else %}
<span class="access-badge locked" title="Private table - request access">&#128274;</span>
{% endif %}
{% endif %}
{% if table.query_mode == 'remote' %}
<span class="query-mode-badge live">Live</span>
{% else %}
@ -1426,7 +1585,9 @@
</div>
<div class="table-row-desc">{{ table.description }}</div>
<div class="table-sync-info">
{% if table.query_mode == 'remote' %}
{% if not table.is_public and not table.has_access and not table.pending_request %}
<span style="color: var(--text-secondary);">Click to request access</span>
{% elif table.query_mode == 'remote' %}
<span class="live-dot"></span> Queried directly from BigQuery
{% elif table.last_sync %}
Synced {{ table.last_sync }}
@ -1434,6 +1595,13 @@
</div>
</div>
<div class="table-row-right">
{% if not table.is_public and not table.has_access %}
{% if table.pending_request %}
<span class="rows-badge" style="background: #FFF7ED; color: #EA580C;">Awaiting review</span>
{% else %}
<span class="btn-request-access" onclick="event.stopPropagation(); openRequestModal('{{ table.id }}', '{{ table.name }}')">Request Access</span>
{% endif %}
{% else %}
<span class="rows-badge{{ ' large' if table.rows_large }}">{{ table.rows_display }}</span>
{% if table.query_mode != 'remote' %}
<span class="profile-link">
@ -1441,6 +1609,7 @@
Profile
</span>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
@ -2450,6 +2619,77 @@ document.addEventListener('keydown', e => {
})();
</script>
<!-- ═══════════════ REQUEST ACCESS MODAL ═══════════════ -->
<div id="requestAccessOverlay" class="request-access-overlay" onclick="if(event.target===this)closeRequestModal()">
<div class="request-access-modal">
<div class="modal-header">
<h3>Request Access</h3>
<button class="btn-secondary" style="border:none;font-size:18px;padding:4px 8px;line-height:1;" onclick="closeRequestModal()">&times;</button>
</div>
<div class="modal-body">
<p>Table: <strong id="requestTableName"></strong></p>
<label for="requestReason">Reason (optional)</label>
<textarea id="requestReason" rows="3" placeholder="Why do you need access to this table?"></textarea>
<p id="requestStatus" style="margin-top:12px;display:none;font-size:13px;"></p>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeRequestModal()">Cancel</button>
<button class="btn-primary" id="submitRequestBtn" onclick="submitAccessRequest()">Request Access</button>
</div>
</div>
</div>
<script>
let requestTableId = '';
function openRequestModal(tableId, tableName) {
requestTableId = tableId;
document.getElementById('requestTableName').textContent = tableName;
document.getElementById('requestReason').value = '';
document.getElementById('requestStatus').style.display = 'none';
document.getElementById('submitRequestBtn').disabled = false;
document.getElementById('submitRequestBtn').textContent = 'Request Access';
document.getElementById('requestAccessOverlay').classList.add('active');
}
function closeRequestModal() {
document.getElementById('requestAccessOverlay').classList.remove('active');
}
async function submitAccessRequest() {
const reason = document.getElementById('requestReason').value;
const btn = document.getElementById('submitRequestBtn');
const status = document.getElementById('requestStatus');
btn.disabled = true;
try {
const resp = await fetch('/api/access-requests', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({table_id: requestTableId, reason: reason})
});
if (resp.ok) {
status.textContent = 'Request submitted! An admin will review it.';
status.style.color = 'var(--success)';
status.style.display = 'block';
btn.textContent = 'Submitted';
} else if (resp.status === 409) {
status.textContent = 'You already have a pending request for this table.';
status.style.color = 'var(--warning)';
status.style.display = 'block';
} else {
throw new Error('Request failed');
}
} catch(e) {
status.textContent = 'Error submitting request. Please try again.';
status.style.color = 'var(--error)';
status.style.display = 'block';
btn.disabled = false;
}
}
</script>
<!-- ═══════════════ METRIC MODAL ═══════════════ -->
<div id="metricModalOverlay" class="metric-modal-overlay">
<div id="metricModal" class="metric-modal" onclick="event.stopPropagation()">

View file

@ -149,6 +149,18 @@ CREATE TABLE IF NOT EXISTS dataset_permissions (
access VARCHAR DEFAULT 'read',
PRIMARY KEY (user_id, dataset)
);
CREATE TABLE IF NOT EXISTS access_requests (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
user_email VARCHAR NOT NULL,
table_id VARCHAR NOT NULL,
reason TEXT,
status VARCHAR DEFAULT 'pending',
reviewed_by VARCHAR,
reviewed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT current_timestamp
);
"""

View file

@ -0,0 +1,90 @@
"""Repository for access requests."""
import uuid
from datetime import datetime, timezone
from typing import Any, Optional, List, Dict
import duckdb
class AccessRequestRepository:
def __init__(self, conn: duckdb.DuckDBPyConnection):
self.conn = conn
def create(self, user_id: str, user_email: str, table_id: str, reason: str = "") -> str:
"""Create a new access request. Returns request ID."""
req_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
self.conn.execute(
"""INSERT INTO access_requests (id, user_id, user_email, table_id, reason, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?)""",
[req_id, user_id, user_email, table_id, reason, now],
)
return req_id
def get(self, request_id: str) -> Optional[Dict[str, Any]]:
result = self.conn.execute("SELECT * FROM access_requests WHERE id = ?", [request_id]).fetchone()
if not result:
return None
columns = [desc[0] for desc in self.conn.description]
return dict(zip(columns, result))
def list_pending(self) -> List[Dict[str, Any]]:
"""List all pending requests (for admin)."""
results = self.conn.execute(
"SELECT * FROM access_requests WHERE status = 'pending' ORDER BY created_at DESC"
).fetchall()
if not results:
return []
columns = [desc[0] for desc in self.conn.description]
return [dict(zip(columns, row)) for row in results]
def list_by_user(self, user_id: str) -> List[Dict[str, Any]]:
"""List all requests by a user."""
results = self.conn.execute(
"SELECT * FROM access_requests WHERE user_id = ? ORDER BY created_at DESC",
[user_id],
).fetchall()
if not results:
return []
columns = [desc[0] for desc in self.conn.description]
return [dict(zip(columns, row)) for row in results]
def approve(self, request_id: str, reviewed_by: str) -> bool:
"""Approve request and grant access."""
req = self.get(request_id)
if not req or req["status"] != "pending":
return False
now = datetime.now(timezone.utc)
self.conn.execute(
"UPDATE access_requests SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?",
[reviewed_by, now, request_id],
)
# Auto-grant permission
from src.repositories.sync_settings import DatasetPermissionRepository
DatasetPermissionRepository(self.conn).grant(req["user_id"], req["table_id"], "read")
return True
def deny(self, request_id: str, reviewed_by: str) -> bool:
"""Deny a request."""
req = self.get(request_id)
if not req or req["status"] != "pending":
return False
now = datetime.now(timezone.utc)
self.conn.execute(
"UPDATE access_requests SET status = 'denied', reviewed_by = ?, reviewed_at = ? WHERE id = ?",
[reviewed_by, now, request_id],
)
return True
def has_pending_request(self, user_id: str, table_id: str) -> bool:
"""Check if user already has a pending request for this table."""
result = self.conn.execute(
"SELECT id FROM access_requests WHERE user_id = ? AND table_id = ? AND status = 'pending'",
[user_id, table_id],
).fetchone()
return result is not None