Compare commits
7 Commits
0a2944ebb6
...
b919c9060c
| Author | SHA1 | Date | |
|---|---|---|---|
| b919c9060c | |||
| 480c8f026b | |||
| 71923b1ef2 | |||
| a57960df02 | |||
| 616977f304 | |||
| 57ead2087c | |||
| 79e71f0244 |
53
CLAUDE.md
53
CLAUDE.md
@@ -2,36 +2,44 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Internal management tool with three domains: Projects, Licenses, and Certificates. Monorepo with a Flask backend and React frontend, deployed to Kubernetes via Helm.
|
||||
Internal management tool with four domains: Projects, Users, Licenses, and Certificates. Includes a feedback system and admin panel. Monorepo with a Flask backend and React frontend, deployed to Kubernetes via Helm.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
backend/ Python Flask API
|
||||
app/
|
||||
auth/ Session-based auth (login_required decorator)
|
||||
projects/ Project CRUD with row-level filtering stub
|
||||
auth/ Session-based auth (login_required decorator, is_admin check)
|
||||
projects/ Project CRUD with row-level filtering stub, user membership management
|
||||
users/ User CRUD, ProjectUser join model with billing types and privileges
|
||||
licenses/ License CRUD with file upload/download
|
||||
certs/ Cert CRUD with encrypted key storage, format conversion
|
||||
dashboard/ Stats, expiring items, audit log
|
||||
dashboard/ Stats, expiring items, audit log (limited to 10 most recent)
|
||||
feedback/ Feedback CRUD with screenshot storage and status workflow
|
||||
settings/ App-level settings (expiry thresholds) with key-value store
|
||||
keycloak.py Keycloak SSO integration (diagnostics endpoint)
|
||||
crypto.py Fernet encrypt/decrypt helpers
|
||||
audit.py AuditLog model + log_audit() helper
|
||||
config.py All config via env vars
|
||||
run.py Entry point
|
||||
run.py Entry point (db.create_all() on startup, no migrations dir)
|
||||
|
||||
frontend/ React + TypeScript
|
||||
src/
|
||||
pages/ One file per route (login, dashboard, projects-list, etc.)
|
||||
pages/ One file per route (login, dashboard, projects-list, project-detail,
|
||||
users-list, user-detail, licenses-list, certs-list, admin)
|
||||
components/
|
||||
ui/ shadcn/ui primitives (do not edit manually)
|
||||
shared/ Reusable components (data-table, stat-card, status-badge, etc.)
|
||||
shared/ Reusable components (data-table, stat-card, status-badge, activity-feed, etc.)
|
||||
projects/ Project-specific components
|
||||
users/ User-specific components (columns, form)
|
||||
licenses/ License-specific components
|
||||
certs/ Cert-specific components
|
||||
dashboard/ Dashboard widgets
|
||||
layout/ App shell, sidebar, topbar, protected-route
|
||||
hooks/ React Query hooks (use-projects, use-licenses, use-certs)
|
||||
contexts/ Auth context provider
|
||||
dashboard/ Dashboard widgets (stats-overview, expiring-items, recent-activity)
|
||||
feedback/ Feedback modal (screenshot attach with auto-scaling)
|
||||
layout/ App shell, sidebar (with admin nav), topbar, protected-route
|
||||
hooks/ React Query hooks (use-projects, use-users, use-project-users,
|
||||
use-licenses, use-certs, use-feedback, use-settings)
|
||||
contexts/ Auth context provider (includes is_admin flag)
|
||||
lib/ API client (axios), utils, constants
|
||||
|
||||
chart/osa-suite/ Helm chart for Kubernetes deployment
|
||||
@@ -66,15 +74,19 @@ cd frontend && npm run build # Production build (also runs tsc)
|
||||
## Architecture Conventions
|
||||
|
||||
### Backend
|
||||
- Flask blueprints: one per domain (auth, projects, licenses, certs, dashboard)
|
||||
- Flask blueprints: one per domain (auth, projects, users, licenses, certs, dashboard, feedback, settings)
|
||||
- All API routes prefixed with `/api/`
|
||||
- All list endpoints return wrapped objects: `{"projects": [...]}`, `{"licenses": [...]}`, etc.
|
||||
- Single-item endpoints return: `{"project": {...}}`, etc.
|
||||
- All list endpoints return wrapped objects: `{"projects": [...]}`, `{"licenses": [...]}`, `{"feedback": [...]}`, etc.
|
||||
- Single-item endpoints return: `{"project": {...}}`, `{"feedback": {...}}`, etc.
|
||||
- `@login_required` decorator on all routes except POST /api/auth/login
|
||||
- `/api/auth/login` and `/api/auth/me` return `is_admin` flag (true for VELA project admins or hardcoded admin user)
|
||||
- Models use SQLAlchemy portable types only (no SQLite-specific features) for Postgres compatibility
|
||||
- No migrations directory — uses `db.create_all()` on startup with manual ALTER TABLE for schema changes in `run.py`
|
||||
- Audit logging via `log_audit()` on all CRUD and export operations
|
||||
- Cert private keys encrypted at rest with Fernet; cert PEM stored unencrypted (public data)
|
||||
- License and cert status computed via hybrid properties, not stored columns
|
||||
- Feedback screenshots stored as JSON array of base64 strings in the database
|
||||
- Settings stored as key-value pairs via the Setting model (e.g., expiry thresholds)
|
||||
|
||||
### Frontend
|
||||
- React Query (TanStack Query) for all server state — no Redux/Zustand
|
||||
@@ -84,7 +96,11 @@ cd frontend && npm run build # Production build (also runs tsc)
|
||||
- Axios client in `lib/api.ts` with `withCredentials: true` and 401 interceptor
|
||||
- Vite proxies `/api` to backend in dev; nginx proxies in production
|
||||
- Toast notifications (sonner) for all mutations
|
||||
- Status badges color-coded: green (active/valid), amber (expiring_soon), red (expired), indigo (perpetual)
|
||||
- Status badges color-coded: green (active/valid), amber (expiring_soon), red (expired), indigo (perpetual), blue (onboarding)
|
||||
- Feedback status badges: blue (unread), gray (read), amber (assigned), green (addressed)
|
||||
- Admin sidebar link (Settings icon) visible only when `user.is_admin` is true
|
||||
- Shared `ActivityFeed` component used on project detail and user detail pages
|
||||
- Project memberships editable inline via Select dropdowns on both project detail and user detail pages
|
||||
|
||||
### Naming
|
||||
- Backend: snake_case for Python, kebab-case for URL paths
|
||||
@@ -94,15 +110,18 @@ cd frontend && npm run build # Production build (also runs tsc)
|
||||
## Key Design Decisions
|
||||
|
||||
- **SQLite now, Postgres later** — just change `DATABASE_URL`. All types are portable.
|
||||
- **Single admin user for MVP** — `config.py` stores hashed password. Keycloak SSO planned.
|
||||
- **Single admin user for MVP** — `config.py` stores hashed password. Keycloak SSO planned (diagnostics endpoint available).
|
||||
- **Admin access** — determined by VELA project admin privilege or hardcoded admin username. Controls sidebar admin link visibility.
|
||||
- **Row-level filtering** — stubbed via `Project.for_user()`, returns all for now.
|
||||
- **Feedback system** — users submit feedback from project detail pages with optional screenshots (auto-scaled to 1200x900 max). Admins manage via admin page with status workflow (unread → read → assigned → addressed).
|
||||
- **Configurable expiry thresholds** — "expiring soon" window for licenses and certs is configurable via admin settings (stored in settings table), not hardcoded.
|
||||
- **Cert encryption** — Fernet key persists to `.fernet_key` file in dev, env var in production. Changing the key makes existing encrypted data unrecoverable.
|
||||
- **PKCS12 exported without passphrase** — explicit product decision.
|
||||
- **Backend replicas must stay at 1** while using SQLite (no concurrent writes). Scale freely after Postgres migration.
|
||||
|
||||
## Stubs (Not Yet Implemented)
|
||||
|
||||
- `components/projects/keycloak-stub.tsx` — Keycloak user/group management
|
||||
- `components/projects/keycloak-stub.tsx` — Keycloak user/group management (diagnostics available on admin page)
|
||||
- `components/certs/aws-secrets-stub.tsx` — AWS Secrets Manager sync
|
||||
- `Project.for_user()` in `backend/app/projects/models.py` — row-level access control
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ class AuditLog(db.Model):
|
||||
"user": self.user,
|
||||
"details": json.loads(self.details) if self.details else None,
|
||||
"ip_address": self.ip_address,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"timestamp": self.created_at.isoformat() if self.created_at else None,
|
||||
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
"timestamp": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -206,14 +206,19 @@ def export_der(cert):
|
||||
return cert.public_bytes(Encoding.DER)
|
||||
|
||||
|
||||
def export_pkcs12(cert, key=None, chain=None, friendly_name=b"certificate"):
|
||||
"""Export as PKCS12 bundle with no passphrase."""
|
||||
def export_pkcs12(cert, key=None, chain=None, friendly_name=b"certificate", passphrase=None):
|
||||
"""Export as PKCS12 bundle, optionally with a passphrase."""
|
||||
if passphrase:
|
||||
from cryptography.hazmat.primitives.serialization import BestAvailableEncryption
|
||||
encryption = BestAvailableEncryption(passphrase if isinstance(passphrase, bytes) else passphrase.encode())
|
||||
else:
|
||||
encryption = NoEncryption()
|
||||
return pkcs12.serialize_key_and_certificates(
|
||||
friendly_name,
|
||||
key,
|
||||
cert,
|
||||
chain,
|
||||
NoEncryption(),
|
||||
encryption,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ class Cert(db.Model):
|
||||
"status": self.status,
|
||||
"has_private_key": self.private_key_encrypted is not None,
|
||||
"has_chain": self.chain_pem is not None and len(self.chain_pem.strip()) > 0,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
|
||||
}
|
||||
if include_private_key:
|
||||
d["cert_pem"] = self.cert_pem
|
||||
|
||||
@@ -329,7 +329,11 @@ def export_cert(cert_id):
|
||||
|
||||
elif fmt == "pkcs12":
|
||||
try:
|
||||
data = export_pkcs12(cert_obj, key_obj, chain_certs or None)
|
||||
from app.settings.models import Setting
|
||||
passphrase = None
|
||||
if Setting.get_bool("pkcs12_passphrase_required"):
|
||||
passphrase = "changeit" # Standard PKCS12 passphrase
|
||||
data = export_pkcs12(cert_obj, key_obj, chain_certs or None, passphrase=passphrase)
|
||||
except Exception as e:
|
||||
logger.error("PKCS12 export failed for cert %d: %s", cert_id, e)
|
||||
return jsonify({"error": "PKCS12 export failed."}), 500
|
||||
|
||||
61
backend/app/email.py
Normal file
61
backend/app/email.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_smtp_configured():
|
||||
"""Check whether SMTP env vars are set."""
|
||||
return bool(current_app.config.get("SMTP_HOST"))
|
||||
|
||||
|
||||
def send_email(to_addresses, subject, body_text, body_html=None):
|
||||
"""Send an email via SMTP.
|
||||
|
||||
Args:
|
||||
to_addresses: list of recipient email strings
|
||||
subject: email subject
|
||||
body_text: plain-text body
|
||||
body_html: optional HTML body
|
||||
|
||||
Returns:
|
||||
True on success, raises on failure.
|
||||
"""
|
||||
host = current_app.config["SMTP_HOST"]
|
||||
port = current_app.config["SMTP_PORT"]
|
||||
username = current_app.config["SMTP_USERNAME"]
|
||||
password = current_app.config["SMTP_PASSWORD"]
|
||||
use_tls = current_app.config["SMTP_USE_TLS"]
|
||||
from_addr = current_app.config["SMTP_FROM_ADDRESS"]
|
||||
|
||||
if not host:
|
||||
raise RuntimeError("SMTP is not configured (SMTP_HOST not set)")
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = ", ".join(to_addresses)
|
||||
|
||||
msg.attach(MIMEText(body_text, "plain"))
|
||||
if body_html:
|
||||
msg.attach(MIMEText(body_html, "html"))
|
||||
|
||||
if use_tls:
|
||||
server = smtplib.SMTP(host, port, timeout=10)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP(host, port, timeout=10)
|
||||
|
||||
try:
|
||||
if username:
|
||||
server.login(username, password)
|
||||
server.sendmail(from_addr, to_addresses, msg.as_string())
|
||||
finally:
|
||||
server.quit()
|
||||
|
||||
logger.info("Email sent to %s: %s", to_addresses, subject)
|
||||
return True
|
||||
@@ -31,8 +31,8 @@ class Feedback(db.Model):
|
||||
"username": self.username,
|
||||
"message": self.message,
|
||||
"status": self.status,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
|
||||
}
|
||||
if include_screenshots:
|
||||
import json
|
||||
|
||||
405
backend/app/keycloak.py
Normal file
405
backend/app/keycloak.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Keycloak Admin REST API client.
|
||||
|
||||
Provides a thin wrapper around the Keycloak Admin API for syncing users,
|
||||
groups (projects), and group membership. All methods are designed to be
|
||||
called alongside local DB operations — Keycloak failures are logged and
|
||||
surfaced to callers via KeycloakError so routes can decide how to respond.
|
||||
|
||||
Config (all via environment / Config class):
|
||||
KEYCLOAK_URL – Base URL, e.g. https://keycloak.example.com
|
||||
KEYCLOAK_REALM – Realm name
|
||||
KEYCLOAK_CLIENT_ID – Service-account client ID
|
||||
KEYCLOAK_CLIENT_SECRET – Service-account client secret
|
||||
|
||||
When KEYCLOAK_URL is not set, the client operates in "disabled" mode —
|
||||
every public method returns None immediately and no HTTP calls are made.
|
||||
This lets the rest of the app run without Keycloak in dev.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Exceptions ────────────────────────────────────────────────────
|
||||
|
||||
class KeycloakError(Exception):
|
||||
"""Raised when a Keycloak API call fails."""
|
||||
|
||||
def __init__(self, message, status_code=None, response_body=None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response_body = response_body
|
||||
|
||||
|
||||
class KeycloakConnectionError(KeycloakError):
|
||||
"""Raised when we cannot reach Keycloak at all."""
|
||||
|
||||
|
||||
class KeycloakAuthError(KeycloakError):
|
||||
"""Raised on 401/403 from Keycloak (bad credentials or missing roles)."""
|
||||
|
||||
|
||||
class KeycloakConflictError(KeycloakError):
|
||||
"""Raised on 409 (duplicate user/group, etc.)."""
|
||||
|
||||
|
||||
class KeycloakNotFoundError(KeycloakError):
|
||||
"""Raised on 404 from Keycloak."""
|
||||
|
||||
|
||||
# ── Client ────────────────────────────────────────────────────────
|
||||
|
||||
class KeycloakClient:
|
||||
"""Stateless client — reads config from Flask app context each call."""
|
||||
|
||||
# Cached token + expiry (module-level so it survives across requests)
|
||||
_token: str | None = None
|
||||
_token_expires_at: float = 0
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(current_app.config.get("KEYCLOAK_URL"))
|
||||
|
||||
@property
|
||||
def _base(self) -> str:
|
||||
return current_app.config["KEYCLOAK_URL"].rstrip("/")
|
||||
|
||||
@property
|
||||
def _realm(self) -> str:
|
||||
return current_app.config["KEYCLOAK_REALM"]
|
||||
|
||||
@property
|
||||
def _admin_url(self) -> str:
|
||||
return f"{self._base}/admin/realms/{self._realm}"
|
||||
|
||||
def _get_token(self) -> str:
|
||||
"""Obtain or reuse a service-account access token."""
|
||||
now = time.time()
|
||||
if self._token and now < self._token_expires_at - 30:
|
||||
return self._token
|
||||
|
||||
token_url = (
|
||||
f"{self._base}/realms/{self._realm}/protocol/openid-connect/token"
|
||||
)
|
||||
try:
|
||||
resp = requests.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": current_app.config["KEYCLOAK_CLIENT_ID"],
|
||||
"client_secret": current_app.config["KEYCLOAK_CLIENT_SECRET"],
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
except requests.ConnectionError as exc:
|
||||
raise KeycloakConnectionError(
|
||||
f"Cannot reach Keycloak at {self._base}"
|
||||
) from exc
|
||||
except requests.Timeout as exc:
|
||||
raise KeycloakConnectionError(
|
||||
"Keycloak token request timed out"
|
||||
) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise KeycloakAuthError(
|
||||
"Failed to obtain Keycloak service-account token",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
self._token = data["access_token"]
|
||||
self._token_expires_at = now + data.get("expires_in", 300)
|
||||
return self._token
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._get_token()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs):
|
||||
"""
|
||||
Make an authenticated request to the Keycloak Admin API.
|
||||
|
||||
Raises typed KeycloakError subclasses so callers can handle
|
||||
specific failure modes (conflict, not-found, auth, connection).
|
||||
"""
|
||||
url = f"{self._admin_url}{path}"
|
||||
kwargs.setdefault("timeout", 10)
|
||||
kwargs.setdefault("headers", self._headers())
|
||||
|
||||
try:
|
||||
resp = requests.request(method, url, **kwargs)
|
||||
except requests.ConnectionError as exc:
|
||||
raise KeycloakConnectionError(
|
||||
f"Cannot reach Keycloak: {method} {path}"
|
||||
) from exc
|
||||
except requests.Timeout as exc:
|
||||
raise KeycloakConnectionError(
|
||||
f"Keycloak request timed out: {method} {path}"
|
||||
) from exc
|
||||
|
||||
if resp.status_code in (401, 403):
|
||||
# Token may have been revoked; clear cache and let caller retry
|
||||
self._token = None
|
||||
raise KeycloakAuthError(
|
||||
f"Keycloak auth failed: {method} {path}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise KeycloakNotFoundError(
|
||||
f"Keycloak resource not found: {method} {path}",
|
||||
status_code=404,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
if resp.status_code == 409:
|
||||
raise KeycloakConflictError(
|
||||
f"Keycloak conflict: {method} {path}",
|
||||
status_code=409,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
if resp.status_code >= 400:
|
||||
raise KeycloakError(
|
||||
f"Keycloak error {resp.status_code}: {method} {path}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
# ── User operations ───────────────────────────────────────────
|
||||
|
||||
def create_user(self, email: str, name: str) -> str | None:
|
||||
"""
|
||||
Create a user in Keycloak. Returns the Keycloak user ID,
|
||||
or None if Keycloak is disabled.
|
||||
|
||||
Raises KeycloakConflictError if email/username already exists.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
# Use email as username for simplicity
|
||||
payload = {
|
||||
"username": email,
|
||||
"email": email,
|
||||
"firstName": name.split()[0] if name else "",
|
||||
"lastName": " ".join(name.split()[1:]) if name and " " in name else "",
|
||||
"enabled": True,
|
||||
"emailVerified": True,
|
||||
}
|
||||
|
||||
resp = self._request("POST", "/users", json=payload)
|
||||
|
||||
# 201 Created — extract ID from Location header
|
||||
location = resp.headers.get("Location", "")
|
||||
kc_id = location.rsplit("/", 1)[-1] if location else None
|
||||
|
||||
if not kc_id:
|
||||
# Fallback: look up by email
|
||||
kc_id = self.find_user_by_email(email)
|
||||
|
||||
logger.info("Keycloak user created: %s (kc_id=%s)", email, kc_id)
|
||||
return kc_id
|
||||
|
||||
def update_user(self, keycloak_id: str, email: str, name: str, enabled: bool = True):
|
||||
"""Update user attributes in Keycloak."""
|
||||
if not self.enabled or not keycloak_id:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"username": email,
|
||||
"email": email,
|
||||
"firstName": name.split()[0] if name else "",
|
||||
"lastName": " ".join(name.split()[1:]) if name and " " in name else "",
|
||||
"enabled": enabled,
|
||||
}
|
||||
|
||||
self._request("PUT", f"/users/{keycloak_id}", json=payload)
|
||||
logger.info("Keycloak user updated: %s", keycloak_id)
|
||||
|
||||
def disable_user(self, keycloak_id: str):
|
||||
"""
|
||||
Disable a user in Keycloak (never delete — local data must persist).
|
||||
"""
|
||||
if not self.enabled or not keycloak_id:
|
||||
return
|
||||
|
||||
self._request("PUT", f"/users/{keycloak_id}", json={"enabled": False})
|
||||
logger.info("Keycloak user disabled: %s", keycloak_id)
|
||||
|
||||
def find_user_by_email(self, email: str) -> str | None:
|
||||
"""Look up a Keycloak user ID by email. Returns None if not found."""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
resp = self._request("GET", "/users", params={"email": email, "exact": "true"})
|
||||
users = resp.json()
|
||||
if users:
|
||||
return users[0]["id"]
|
||||
return None
|
||||
|
||||
def get_user(self, keycloak_id: str) -> dict | None:
|
||||
"""Fetch full user representation from Keycloak."""
|
||||
if not self.enabled or not keycloak_id:
|
||||
return None
|
||||
|
||||
resp = self._request("GET", f"/users/{keycloak_id}")
|
||||
return resp.json()
|
||||
|
||||
# ── Group operations (groups == projects) ─────────────────────
|
||||
|
||||
def create_group(self, name: str) -> str | None:
|
||||
"""
|
||||
Create a group in Keycloak. Returns the Keycloak group ID,
|
||||
or None if Keycloak is disabled.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
resp = self._request("POST", "/groups", json={"name": name})
|
||||
|
||||
location = resp.headers.get("Location", "")
|
||||
group_id = location.rsplit("/", 1)[-1] if location else None
|
||||
|
||||
if not group_id:
|
||||
group_id = self.find_group_by_name(name)
|
||||
|
||||
logger.info("Keycloak group created: %s (id=%s)", name, group_id)
|
||||
return group_id
|
||||
|
||||
def update_group(self, group_id: str, name: str):
|
||||
"""Rename a group in Keycloak."""
|
||||
if not self.enabled or not group_id:
|
||||
return
|
||||
|
||||
self._request("PUT", f"/groups/{group_id}", json={"name": name})
|
||||
logger.info("Keycloak group updated: %s", group_id)
|
||||
|
||||
def find_group_by_name(self, name: str) -> str | None:
|
||||
"""Look up a Keycloak group ID by exact name."""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
resp = self._request("GET", "/groups", params={"search": name, "exact": "true"})
|
||||
groups = resp.json()
|
||||
for g in groups:
|
||||
if g["name"] == name:
|
||||
return g["id"]
|
||||
return None
|
||||
|
||||
def get_group_members(self, group_id: str) -> list[dict]:
|
||||
"""List all members of a Keycloak group."""
|
||||
if not self.enabled or not group_id:
|
||||
return []
|
||||
|
||||
resp = self._request("GET", f"/groups/{group_id}/members")
|
||||
return resp.json()
|
||||
|
||||
# ── Group membership ──────────────────────────────────────────
|
||||
|
||||
def add_user_to_group(self, keycloak_user_id: str, keycloak_group_id: str):
|
||||
"""
|
||||
Add a user to a group in Keycloak. Idempotent (PUT).
|
||||
"""
|
||||
if not self.enabled or not keycloak_user_id or not keycloak_group_id:
|
||||
return
|
||||
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/users/{keycloak_user_id}/groups/{keycloak_group_id}",
|
||||
)
|
||||
logger.info(
|
||||
"Keycloak: user %s added to group %s",
|
||||
keycloak_user_id,
|
||||
keycloak_group_id,
|
||||
)
|
||||
|
||||
def remove_user_from_group(self, keycloak_user_id: str, keycloak_group_id: str):
|
||||
"""Remove a user from a group in Keycloak."""
|
||||
if not self.enabled or not keycloak_user_id or not keycloak_group_id:
|
||||
return
|
||||
|
||||
self._request(
|
||||
"DELETE",
|
||||
f"/users/{keycloak_user_id}/groups/{keycloak_group_id}",
|
||||
)
|
||||
logger.info(
|
||||
"Keycloak: user %s removed from group %s",
|
||||
keycloak_user_id,
|
||||
keycloak_group_id,
|
||||
)
|
||||
|
||||
# ── Role operations (privileges) ──────────────────────────────
|
||||
|
||||
def get_realm_roles(self) -> list[dict]:
|
||||
"""List all realm-level roles."""
|
||||
if not self.enabled:
|
||||
return []
|
||||
|
||||
resp = self._request("GET", "/roles")
|
||||
return resp.json()
|
||||
|
||||
def assign_realm_roles_to_group(self, group_id: str, roles: list[dict]):
|
||||
"""
|
||||
Assign realm roles to a group.
|
||||
|
||||
`roles` should be a list of role representations:
|
||||
[{"id": "...", "name": "admin"}, ...]
|
||||
"""
|
||||
if not self.enabled or not group_id or not roles:
|
||||
return
|
||||
|
||||
self._request(
|
||||
"POST",
|
||||
f"/groups/{group_id}/role-mappings/realm",
|
||||
json=roles,
|
||||
)
|
||||
role_names = [r["name"] for r in roles]
|
||||
logger.info(
|
||||
"Keycloak: assigned roles %s to group %s",
|
||||
role_names,
|
||||
group_id,
|
||||
)
|
||||
|
||||
def remove_realm_roles_from_group(self, group_id: str, roles: list[dict]):
|
||||
"""Remove realm roles from a group."""
|
||||
if not self.enabled or not group_id or not roles:
|
||||
return
|
||||
|
||||
self._request(
|
||||
"DELETE",
|
||||
f"/groups/{group_id}/role-mappings/realm",
|
||||
json=roles,
|
||||
)
|
||||
role_names = [r["name"] for r in roles]
|
||||
logger.info(
|
||||
"Keycloak: removed roles %s from group %s",
|
||||
role_names,
|
||||
group_id,
|
||||
)
|
||||
|
||||
def get_group_realm_roles(self, group_id: str) -> list[dict]:
|
||||
"""Get realm roles currently assigned to a group."""
|
||||
if not self.enabled or not group_id:
|
||||
return []
|
||||
|
||||
resp = self._request("GET", f"/groups/{group_id}/role-mappings/realm")
|
||||
return resp.json()
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
keycloak = KeycloakClient()
|
||||
@@ -72,8 +72,8 @@ class License(db.Model):
|
||||
"project_id": self.project_id,
|
||||
"archived": self.archived,
|
||||
"status": self.status,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
|
||||
}
|
||||
if include_file_info:
|
||||
d["file_name"] = self.file_name
|
||||
|
||||
@@ -54,9 +54,9 @@ class Project(db.Model):
|
||||
"admin_email": self.admin_email,
|
||||
"rate": float(self.rate) if self.rate is not None else None,
|
||||
"status": self.status,
|
||||
"archived_at": self.archived_at.isoformat() if self.archived_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"archived_at": (self.archived_at.isoformat() + "Z") if self.archived_at else None,
|
||||
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
|
||||
}
|
||||
if include_counts:
|
||||
d["license_count"] = self.licenses.count()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -6,10 +7,13 @@ from flask import request, jsonify, session
|
||||
from app.auth.decorators import login_required
|
||||
from app.audit import log_audit, AuditLog
|
||||
from app.extensions import db
|
||||
from app.keycloak import keycloak, KeycloakError
|
||||
from app.projects import projects_bp
|
||||
from app.projects.models import Project
|
||||
from app.users.models import User, ProjectUser, BILLING_TYPES, PRIVILEGE_LEVELS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@projects_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
@@ -91,7 +95,18 @@ def create_project():
|
||||
|
||||
log_audit("project", project.id, "created", {"key": project.key, "name": project.name})
|
||||
|
||||
return jsonify(project.to_dict()), 201
|
||||
# Create corresponding Keycloak group
|
||||
kc_warning = None
|
||||
try:
|
||||
keycloak.create_group(project.key)
|
||||
except KeycloakError as exc:
|
||||
logger.warning("Keycloak sync failed on project create: %s", exc)
|
||||
kc_warning = f"Project created locally but Keycloak group sync failed: {exc}"
|
||||
|
||||
result = project.to_dict()
|
||||
if kc_warning:
|
||||
result["keycloak_warning"] = kc_warning
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@projects_bp.route("/<string:key>", methods=["GET"])
|
||||
@@ -208,7 +223,21 @@ def add_project_user(key):
|
||||
"billing_type": billing_type,
|
||||
})
|
||||
|
||||
return jsonify(membership.to_dict()), 201
|
||||
# Sync group membership to Keycloak
|
||||
kc_warning = None
|
||||
if user.keycloak_id:
|
||||
try:
|
||||
kc_group_id = keycloak.find_group_by_name(project.key)
|
||||
if kc_group_id:
|
||||
keycloak.add_user_to_group(user.keycloak_id, kc_group_id)
|
||||
except KeycloakError as exc:
|
||||
logger.warning("Keycloak sync failed on add_project_user: %s", exc)
|
||||
kc_warning = f"Membership created locally but Keycloak sync failed: {exc}"
|
||||
|
||||
result = membership.to_dict()
|
||||
if kc_warning:
|
||||
result["keycloak_warning"] = kc_warning
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@projects_bp.route("/<string:key>/users/<int:user_id>", methods=["PUT"])
|
||||
@@ -313,6 +342,7 @@ def remove_project_user(key, user_id):
|
||||
}), 409
|
||||
|
||||
user_name = membership.user.name
|
||||
keycloak_id = membership.user.keycloak_id
|
||||
db.session.delete(membership)
|
||||
db.session.commit()
|
||||
|
||||
@@ -323,7 +353,21 @@ def remove_project_user(key, user_id):
|
||||
"project_name": project.name,
|
||||
})
|
||||
|
||||
return jsonify({"message": f"User '{user_name}' removed from project"}), 200
|
||||
# Sync group membership removal to Keycloak
|
||||
kc_warning = None
|
||||
if keycloak_id:
|
||||
try:
|
||||
kc_group_id = keycloak.find_group_by_name(project.key)
|
||||
if kc_group_id:
|
||||
keycloak.remove_user_from_group(keycloak_id, kc_group_id)
|
||||
except KeycloakError as exc:
|
||||
logger.warning("Keycloak sync failed on remove_project_user: %s", exc)
|
||||
kc_warning = f"Membership removed locally but Keycloak sync failed: {exc}"
|
||||
|
||||
result = {"message": f"User '{user_name}' removed from project"}
|
||||
if kc_warning:
|
||||
result["keycloak_warning"] = kc_warning
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
# ── Project audit history ─────────────────────────────────────────
|
||||
|
||||
@@ -11,6 +11,21 @@ class Setting(db.Model):
|
||||
DEFAULTS = {
|
||||
"license_expiry_threshold_days": "30",
|
||||
"cert_expiry_threshold_days": "30",
|
||||
"default_project_rate": "300",
|
||||
"session_timeout_hours": "8",
|
||||
"audit_log_retention_days": "0", # 0 = keep forever
|
||||
"email_notifications_enabled": "false",
|
||||
"email_notifications_address": "",
|
||||
"notify_license_expiring": "true",
|
||||
"notify_cert_expiring": "true",
|
||||
"notify_license_expired": "true",
|
||||
"notify_cert_expired": "true",
|
||||
"notify_new_feedback": "false",
|
||||
"notify_user_changes": "false",
|
||||
"default_billing_type": "core",
|
||||
"pkcs12_passphrase_required": "false",
|
||||
"max_upload_size_mb": "50",
|
||||
"auto_archive_days": "0", # 0 = disabled
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -27,6 +42,12 @@ class Setting(db.Model):
|
||||
val = cls.get(key)
|
||||
return int(val) if val is not None else None
|
||||
|
||||
@classmethod
|
||||
def get_bool(cls, key):
|
||||
"""Get a setting value as a boolean."""
|
||||
val = cls.get(key)
|
||||
return val is not None and val.lower() in ("true", "1", "yes")
|
||||
|
||||
@classmethod
|
||||
def set(cls, key, value):
|
||||
"""Set a setting value (upsert)."""
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from flask import jsonify, request
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import jsonify, request, current_app, send_file
|
||||
|
||||
from app.auth.decorators import login_required
|
||||
from app.settings import settings_bp
|
||||
from app.settings.models import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@settings_bp.route("/expiry-thresholds", methods=["GET"])
|
||||
@login_required
|
||||
@@ -30,3 +39,309 @@ def update_expiry_thresholds():
|
||||
"license_expiry_threshold_days": Setting.get_int("license_expiry_threshold_days"),
|
||||
"cert_expiry_threshold_days": Setting.get_int("cert_expiry_threshold_days"),
|
||||
}), 200
|
||||
|
||||
|
||||
@settings_bp.route("/all", methods=["GET"])
|
||||
@login_required
|
||||
def get_all_settings():
|
||||
result = {}
|
||||
for key in Setting.DEFAULTS:
|
||||
result[key] = Setting.get(key)
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@settings_bp.route("/all", methods=["PUT"])
|
||||
@login_required
|
||||
def update_all_settings():
|
||||
data = request.get_json() or {}
|
||||
|
||||
VALIDATORS = {
|
||||
"default_project_rate": lambda v: isinstance(v, (int, float)) and v >= 0,
|
||||
"session_timeout_hours": lambda v: isinstance(v, (int, float)) and 1 <= v <= 168,
|
||||
"audit_log_retention_days": lambda v: isinstance(v, int) and 0 <= v <= 3650,
|
||||
"email_notifications_enabled": lambda v: isinstance(v, bool),
|
||||
"email_notifications_address": lambda v: isinstance(v, str) and len(v) <= 500,
|
||||
"notify_license_expiring": lambda v: isinstance(v, bool),
|
||||
"notify_cert_expiring": lambda v: isinstance(v, bool),
|
||||
"notify_license_expired": lambda v: isinstance(v, bool),
|
||||
"notify_cert_expired": lambda v: isinstance(v, bool),
|
||||
"notify_new_feedback": lambda v: isinstance(v, bool),
|
||||
"notify_user_changes": lambda v: isinstance(v, bool),
|
||||
"default_billing_type": lambda v: v in ("core", "collaborator", "standing"),
|
||||
"pkcs12_passphrase_required": lambda v: isinstance(v, bool),
|
||||
"max_upload_size_mb": lambda v: isinstance(v, int) and 1 <= v <= 500,
|
||||
"auto_archive_days": lambda v: isinstance(v, int) and 0 <= v <= 3650,
|
||||
}
|
||||
|
||||
for key, value in data.items():
|
||||
if key not in Setting.DEFAULTS:
|
||||
continue
|
||||
if key in VALIDATORS and not VALIDATORS[key](value):
|
||||
return jsonify({"error": f"Invalid value for {key}"}), 400
|
||||
# Convert booleans to string
|
||||
if isinstance(value, bool):
|
||||
Setting.set(key, "true" if value else "false")
|
||||
else:
|
||||
Setting.set(key, value)
|
||||
|
||||
# Return all settings
|
||||
result = {}
|
||||
for key in Setting.DEFAULTS:
|
||||
result[key] = Setting.get(key)
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@settings_bp.route("/backup", methods=["GET"])
|
||||
@login_required
|
||||
def download_backup():
|
||||
"""Download a copy of the SQLite database."""
|
||||
db_uri = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
||||
if not db_uri.startswith("sqlite"):
|
||||
return jsonify({"error": "Backup only available for SQLite databases"}), 400
|
||||
|
||||
# Extract path from sqlite:///path or sqlite:////abs/path
|
||||
db_path = db_uri.replace("sqlite:///", "", 1)
|
||||
if not os.path.isabs(db_path):
|
||||
db_path = os.path.join(current_app.instance_path, db_path)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
return jsonify({"error": "Database file not found"}), 404
|
||||
|
||||
from app.audit import log_audit
|
||||
log_audit("system", None, "database_backup")
|
||||
|
||||
from datetime import date
|
||||
filename = f"osa-backup-{date.today().isoformat()}.db"
|
||||
return send_file(db_path, as_attachment=True, download_name=filename)
|
||||
|
||||
|
||||
# ── SMTP ─────────────────────────────────────────────────────
|
||||
|
||||
@settings_bp.route("/smtp-status", methods=["GET"])
|
||||
@login_required
|
||||
def smtp_status():
|
||||
"""Return SMTP configuration status (no secrets)."""
|
||||
host = current_app.config.get("SMTP_HOST", "")
|
||||
return jsonify({
|
||||
"configured": bool(host),
|
||||
"host": host,
|
||||
"port": current_app.config.get("SMTP_PORT", 587),
|
||||
"use_tls": current_app.config.get("SMTP_USE_TLS", True),
|
||||
"from_address": current_app.config.get("SMTP_FROM_ADDRESS", ""),
|
||||
"has_credentials": bool(current_app.config.get("SMTP_USERNAME")),
|
||||
}), 200
|
||||
|
||||
|
||||
@settings_bp.route("/test-email", methods=["POST"])
|
||||
@login_required
|
||||
def test_email():
|
||||
"""Send a test email to verify SMTP configuration."""
|
||||
data = request.get_json() or {}
|
||||
to = data.get("to", "").strip()
|
||||
if not to:
|
||||
return jsonify({"error": "Recipient email address is required"}), 400
|
||||
|
||||
from app.email import is_smtp_configured, send_email
|
||||
if not is_smtp_configured():
|
||||
return jsonify({"error": "SMTP is not configured. Set SMTP_HOST environment variable."}), 400
|
||||
|
||||
try:
|
||||
send_email(
|
||||
[to],
|
||||
"OSA Suite — Test Email",
|
||||
"This is a test email from OSA Suite. If you received this, SMTP is configured correctly.",
|
||||
"<p>This is a test email from <strong>OSA Suite</strong>.</p>"
|
||||
"<p>If you received this, SMTP is configured correctly.</p>",
|
||||
)
|
||||
return jsonify({"message": f"Test email sent to {to}"}), 200
|
||||
except Exception as exc:
|
||||
logger.error("Test email failed: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
# ── Keycloak diagnostics ──────────────────────────────────────
|
||||
|
||||
def _check_dns(hostname):
|
||||
"""Resolve hostname to IP addresses."""
|
||||
start = time.time()
|
||||
try:
|
||||
addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
ips = list({addr[4][0] for addr in addrs})
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {"status": "ok", "ips": ips, "ms": elapsed}
|
||||
except socket.gaierror as exc:
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {"status": "error", "error": str(exc), "ms": elapsed}
|
||||
|
||||
|
||||
def _check_tcp(hostname, port):
|
||||
"""Test TCP connectivity."""
|
||||
start = time.time()
|
||||
try:
|
||||
sock = socket.create_connection((hostname, port), timeout=5)
|
||||
sock.close()
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {"status": "ok", "ms": elapsed}
|
||||
except (socket.timeout, OSError) as exc:
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {"status": "error", "error": str(exc), "ms": elapsed}
|
||||
|
||||
|
||||
def _check_tls(hostname, port):
|
||||
"""Validate TLS certificate."""
|
||||
start = time.time()
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
with socket.create_connection((hostname, port), timeout=5) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
|
||||
cert = ssock.getpeercert()
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {
|
||||
"status": "ok",
|
||||
"subject": dict(x[0] for x in cert.get("subject", ())),
|
||||
"issuer": dict(x[0] for x in cert.get("issuer", ())),
|
||||
"expires": cert.get("notAfter"),
|
||||
"ms": elapsed,
|
||||
}
|
||||
except (ssl.SSLError, OSError) as exc:
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {"status": "error", "error": str(exc), "ms": elapsed}
|
||||
|
||||
|
||||
def _check_auth(keycloak_url, realm, client_id, client_secret):
|
||||
"""Attempt service-account token acquisition."""
|
||||
import requests as req
|
||||
|
||||
start = time.time()
|
||||
token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
|
||||
try:
|
||||
resp = req.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return {
|
||||
"status": "ok",
|
||||
"token_type": data.get("token_type"),
|
||||
"expires_in": data.get("expires_in"),
|
||||
"ms": elapsed,
|
||||
}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"HTTP {resp.status_code}",
|
||||
"detail": resp.text[:200],
|
||||
"ms": elapsed,
|
||||
}
|
||||
except Exception as exc:
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {"status": "error", "error": str(exc), "ms": elapsed}
|
||||
|
||||
|
||||
def _check_realm(keycloak_url, realm, token):
|
||||
"""Verify admin API access by hitting the users endpoint."""
|
||||
import requests as req
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
resp = req.get(
|
||||
f"{keycloak_url}/admin/realms/{realm}/users",
|
||||
params={"max": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
if resp.status_code == 200:
|
||||
return {"status": "ok", "ms": elapsed}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"HTTP {resp.status_code}",
|
||||
"detail": resp.text[:200],
|
||||
"ms": elapsed,
|
||||
}
|
||||
except Exception as exc:
|
||||
elapsed = round((time.time() - start) * 1000)
|
||||
return {"status": "error", "error": str(exc), "ms": elapsed}
|
||||
|
||||
|
||||
@settings_bp.route("/keycloak-status", methods=["GET"])
|
||||
@login_required
|
||||
def keycloak_status():
|
||||
kc_url = current_app.config.get("KEYCLOAK_URL", "")
|
||||
realm = current_app.config.get("KEYCLOAK_REALM", "")
|
||||
client_id = current_app.config.get("KEYCLOAK_CLIENT_ID", "")
|
||||
client_secret = current_app.config.get("KEYCLOAK_CLIENT_SECRET", "")
|
||||
|
||||
if not kc_url:
|
||||
return jsonify({
|
||||
"enabled": False,
|
||||
"message": "Keycloak is not configured (KEYCLOAK_URL not set)",
|
||||
}), 200
|
||||
|
||||
parsed = urlparse(kc_url)
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
is_https = parsed.scheme == "https"
|
||||
|
||||
checks = {}
|
||||
|
||||
# 1. DNS
|
||||
checks["dns"] = _check_dns(hostname)
|
||||
|
||||
# 2. TCP — only if DNS passed
|
||||
if checks["dns"]["status"] == "ok":
|
||||
checks["tcp"] = _check_tcp(hostname, port)
|
||||
else:
|
||||
checks["tcp"] = {"status": "skipped", "reason": "DNS failed"}
|
||||
|
||||
# 3. TLS — only if TCP passed and using HTTPS
|
||||
if not is_https:
|
||||
checks["tls"] = {"status": "skipped", "reason": "Not using HTTPS"}
|
||||
elif checks["tcp"]["status"] == "ok":
|
||||
checks["tls"] = _check_tls(hostname, port)
|
||||
else:
|
||||
checks["tls"] = {"status": "skipped", "reason": "TCP failed"}
|
||||
|
||||
# 4. Auth — only if connectivity is good
|
||||
tcp_ok = checks["tcp"]["status"] == "ok"
|
||||
tls_ok = checks.get("tls", {}).get("status") in ("ok", "skipped")
|
||||
if tcp_ok and tls_ok:
|
||||
checks["auth"] = _check_auth(kc_url, realm, client_id, client_secret)
|
||||
else:
|
||||
checks["auth"] = {"status": "skipped", "reason": "Connectivity failed"}
|
||||
|
||||
# 5. Realm — only if auth passed
|
||||
if checks["auth"]["status"] == "ok":
|
||||
import requests as req
|
||||
token_url = f"{kc_url}/realms/{realm}/protocol/openid-connect/token"
|
||||
try:
|
||||
tresp = req.post(token_url, data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}, timeout=10)
|
||||
token = tresp.json().get("access_token", "")
|
||||
except Exception:
|
||||
token = ""
|
||||
checks["realm"] = _check_realm(kc_url, realm, token)
|
||||
else:
|
||||
checks["realm"] = {"status": "skipped", "reason": "Auth failed"}
|
||||
|
||||
all_ok = all(
|
||||
c.get("status") in ("ok", "skipped")
|
||||
for c in checks.values()
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"enabled": True,
|
||||
"url": kc_url,
|
||||
"realm": realm,
|
||||
"overall": "ok" if all_ok else "error",
|
||||
"checks": checks,
|
||||
}), 200
|
||||
|
||||
@@ -30,8 +30,8 @@ class User(db.Model):
|
||||
"email": self.email,
|
||||
"active": self.active,
|
||||
"keycloak_id": self.keycloak_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
|
||||
}
|
||||
if include_projects:
|
||||
d["projects"] = [m.to_dict() for m in self.project_memberships]
|
||||
@@ -78,6 +78,6 @@ class ProjectUser(db.Model):
|
||||
"user_email": self.user.email if self.user else None,
|
||||
"billing_type": self.billing_type,
|
||||
"privileges": self.privileges,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
|
||||
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, jsonify
|
||||
@@ -5,9 +6,12 @@ from flask import request, jsonify
|
||||
from app.auth.decorators import login_required
|
||||
from app.audit import log_audit, AuditLog
|
||||
from app.extensions import db
|
||||
from app.keycloak import keycloak, KeycloakError
|
||||
from app.users import users_bp
|
||||
from app.users.models import User, ProjectUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── User CRUD ──────────────────────────────────────────────────
|
||||
|
||||
@@ -68,7 +72,22 @@ def create_user():
|
||||
|
||||
log_audit("user", user.id, "created", {"name": user.name, "email": user.email})
|
||||
|
||||
return jsonify(user.to_dict(include_projects=True)), 201
|
||||
# Sync to Keycloak (non-blocking — local user is already persisted)
|
||||
kc_warning = None
|
||||
if not user.keycloak_id:
|
||||
try:
|
||||
kc_id = keycloak.create_user(email=user.email, name=user.name)
|
||||
if kc_id:
|
||||
user.keycloak_id = kc_id
|
||||
db.session.commit()
|
||||
except KeycloakError as exc:
|
||||
logger.warning("Keycloak sync failed on user create: %s", exc)
|
||||
kc_warning = f"User created locally but Keycloak sync failed: {exc}"
|
||||
|
||||
result = user.to_dict(include_projects=True)
|
||||
if kc_warning:
|
||||
result["keycloak_warning"] = kc_warning
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@users_bp.route("/<int:user_id>", methods=["GET"])
|
||||
@@ -112,7 +131,24 @@ def update_user(user_id):
|
||||
|
||||
log_audit("user", user.id, "updated", changes)
|
||||
|
||||
return jsonify(user.to_dict(include_projects=True)), 200
|
||||
# Sync changes to Keycloak
|
||||
kc_warning = None
|
||||
if user.keycloak_id and changes:
|
||||
try:
|
||||
keycloak.update_user(
|
||||
keycloak_id=user.keycloak_id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
enabled=user.active,
|
||||
)
|
||||
except KeycloakError as exc:
|
||||
logger.warning("Keycloak sync failed on user update: %s", exc)
|
||||
kc_warning = f"User updated locally but Keycloak sync failed: {exc}"
|
||||
|
||||
result = user.to_dict(include_projects=True)
|
||||
if kc_warning:
|
||||
result["keycloak_warning"] = kc_warning
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@users_bp.route("/<int:user_id>", methods=["DELETE"])
|
||||
@@ -123,6 +159,8 @@ def delete_user(user_id):
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
user_name = user.name
|
||||
keycloak_id = user.keycloak_id
|
||||
|
||||
# Remove all project memberships first
|
||||
ProjectUser.query.filter_by(user_id=user_id).delete()
|
||||
db.session.delete(user)
|
||||
@@ -130,7 +168,19 @@ def delete_user(user_id):
|
||||
|
||||
log_audit("user", user_id, "deleted", {"name": user_name})
|
||||
|
||||
return jsonify({"message": f"User '{user_name}' deleted"}), 200
|
||||
# Disable in Keycloak (never delete — local data must persist)
|
||||
kc_warning = None
|
||||
if keycloak_id:
|
||||
try:
|
||||
keycloak.disable_user(keycloak_id)
|
||||
except KeycloakError as exc:
|
||||
logger.warning("Keycloak sync failed on user delete: %s", exc)
|
||||
kc_warning = f"User deleted locally but Keycloak disable failed: {exc}"
|
||||
|
||||
result = {"message": f"User '{user_name}' deleted"}
|
||||
if kc_warning:
|
||||
result["keycloak_warning"] = kc_warning
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
# ── User audit history ─────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +41,20 @@ class Config:
|
||||
# Upload limit
|
||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
# SMTP (disabled when SMTP_HOST is empty)
|
||||
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
|
||||
SMTP_FROM_ADDRESS = os.environ.get("SMTP_FROM_ADDRESS", "osa@example.com")
|
||||
|
||||
# Keycloak (disabled when KEYCLOAK_URL is empty)
|
||||
KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "")
|
||||
KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "osa")
|
||||
KEYCLOAK_CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "osa-admin-client")
|
||||
KEYCLOAK_CLIENT_SECRET = os.environ.get("KEYCLOAK_CLIENT_SECRET", "")
|
||||
|
||||
@staticmethod
|
||||
def get_fernet_key():
|
||||
key = os.environ.get("FERNET_KEY")
|
||||
|
||||
@@ -5,3 +5,4 @@ Flask-CORS>=4.0
|
||||
cryptography>=42.0
|
||||
python-dotenv>=1.0
|
||||
gunicorn>=22.0
|
||||
requests>=2.31
|
||||
|
||||
@@ -12,3 +12,11 @@ stringData:
|
||||
{{- if .Values.auth.passwordHash }}
|
||||
AUTH_PASSWORD_HASH: {{ .Values.auth.passwordHash | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.host }}
|
||||
SMTP_HOST: {{ .Values.smtp.host | quote }}
|
||||
SMTP_PORT: {{ .Values.smtp.port | default 587 | quote }}
|
||||
SMTP_USERNAME: {{ .Values.smtp.username | quote }}
|
||||
SMTP_PASSWORD: {{ .Values.smtp.password | quote }}
|
||||
SMTP_USE_TLS: {{ .Values.smtp.useTls | default true | quote }}
|
||||
SMTP_FROM_ADDRESS: {{ .Values.smtp.fromAddress | default "osa@example.com" | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -46,6 +46,14 @@ secrets:
|
||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
fernetKey: ""
|
||||
|
||||
smtp:
|
||||
host: ""
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
useTls: true
|
||||
fromAddress: "osa@example.com"
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
|
||||
182
docs/keycloak-setup.md
Normal file
182
docs/keycloak-setup.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Keycloak Setup Guide
|
||||
|
||||
OSA Suite integrates with Keycloak for SSO user/group management. Keycloak is **optional** — the app runs fine without it. When `KEYCLOAK_URL` is not set, all Keycloak operations are silently skipped.
|
||||
|
||||
## Keycloak Requirements
|
||||
|
||||
OSA Suite uses a **service-account client** with Admin API access. You need:
|
||||
|
||||
1. A Keycloak realm (default: `osa`)
|
||||
2. A client with **Service Accounts Enabled** and the `realm-admin` role
|
||||
3. The client ID and secret
|
||||
|
||||
### Creating the Client in Keycloak
|
||||
|
||||
1. Go to your realm > **Clients** > **Create client**
|
||||
2. Set **Client ID** to `osa-admin-client`
|
||||
3. Set **Client authentication** to **On**
|
||||
4. Under **Service account roles**, enable **Service accounts roles**
|
||||
5. Save, then go to the **Service account roles** tab
|
||||
6. Click **Assign role** > filter by clients > assign `realm-management` > `realm-admin`
|
||||
7. Go to the **Credentials** tab and copy the **Client secret**
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `KEYCLOAK_URL` | *(empty — disabled)* | Base URL, e.g. `https://keycloak.example.com` |
|
||||
| `KEYCLOAK_REALM` | `osa` | Realm name |
|
||||
| `KEYCLOAK_CLIENT_ID` | `osa-admin-client` | Service-account client ID |
|
||||
| `KEYCLOAK_CLIENT_SECRET` | *(empty)* | Service-account client secret |
|
||||
|
||||
## Local Development
|
||||
|
||||
Add the variables to `backend/.env`:
|
||||
|
||||
```bash
|
||||
# backend/.env
|
||||
KEYCLOAK_URL=http://localhost:8180
|
||||
KEYCLOAK_REALM=osa
|
||||
KEYCLOAK_CLIENT_ID=osa-admin-client
|
||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
To run a local Keycloak instance for testing:
|
||||
|
||||
```bash
|
||||
docker run -d --name keycloak \
|
||||
-p 8180:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:26.2 start-dev
|
||||
```
|
||||
|
||||
Then open `http://localhost:8180`, log in as `admin`/`admin`, create the `osa` realm and service-account client as described above.
|
||||
|
||||
Start the app normally:
|
||||
|
||||
```bash
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
Check the **Admin > Connection Status** panel to verify connectivity.
|
||||
|
||||
### Seeding Sample Data
|
||||
|
||||
A standalone script populates Keycloak with the same seed data used by the app (users, groups for each project, and memberships):
|
||||
|
||||
```bash
|
||||
# Local Keycloak with defaults (localhost:8180, admin/admin)
|
||||
python scripts/seed-keycloak.py
|
||||
|
||||
# Custom instance
|
||||
python scripts/seed-keycloak.py \
|
||||
--url https://keycloak.example.com \
|
||||
--admin-user admin \
|
||||
--admin-password changeme
|
||||
|
||||
# Env vars work too
|
||||
KEYCLOAK_URL=http://keycloak:8080 python scripts/seed-keycloak.py
|
||||
```
|
||||
|
||||
The script creates the realm, a service-account client (`osa-admin-client` with `realm-admin` role), all users, project groups, and group memberships. It prints the connection env vars at the end.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Add the Keycloak env vars to the backend service and optionally add a Keycloak container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.2
|
||||
container_name: keycloak
|
||||
command: start-dev
|
||||
ports:
|
||||
- "8180:8080"
|
||||
environment:
|
||||
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: osa-backend
|
||||
ports:
|
||||
- "5001:5001"
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
- DATABASE_URL=sqlite:////app/osa.db
|
||||
- KEYCLOAK_URL=http://keycloak:8080
|
||||
- KEYCLOAK_REALM=osa
|
||||
- KEYCLOAK_CLIENT_ID=osa-admin-client
|
||||
- KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
depends_on:
|
||||
- keycloak
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
# ... (unchanged)
|
||||
```
|
||||
|
||||
Note: inside the Docker network, the backend reaches Keycloak at `http://keycloak:8080` (container name, internal port), not `localhost:8180`.
|
||||
|
||||
## Helm (Kubernetes)
|
||||
|
||||
### 1. Add Keycloak values
|
||||
|
||||
Add the following to your `values.yaml` or pass via `--set`:
|
||||
|
||||
```yaml
|
||||
keycloak:
|
||||
url: "https://keycloak.example.com"
|
||||
realm: "osa"
|
||||
clientId: "osa-admin-client"
|
||||
clientSecret: "your-client-secret"
|
||||
```
|
||||
|
||||
### 2. Add to the secret template
|
||||
|
||||
In `chart/osa-suite/templates/secret.yaml`, add the Keycloak fields:
|
||||
|
||||
```yaml
|
||||
stringData:
|
||||
# ... existing keys ...
|
||||
{{- if .Values.keycloak.url }}
|
||||
KEYCLOAK_URL: {{ .Values.keycloak.url | quote }}
|
||||
KEYCLOAK_REALM: {{ .Values.keycloak.realm | default "osa" | quote }}
|
||||
KEYCLOAK_CLIENT_ID: {{ .Values.keycloak.clientId | default "osa-admin-client" | quote }}
|
||||
KEYCLOAK_CLIENT_SECRET: {{ .Values.keycloak.clientSecret | quote }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### 3. Deploy
|
||||
|
||||
```bash
|
||||
helm install osa ./chart/osa-suite \
|
||||
--set secrets.fernetKey=... \
|
||||
--set secrets.secretKey=... \
|
||||
--set keycloak.url=https://keycloak.example.com \
|
||||
--set keycloak.clientSecret=your-client-secret
|
||||
```
|
||||
|
||||
Or to deploy without Keycloak (default), just omit the `keycloak.*` values.
|
||||
|
||||
## Verifying the Connection
|
||||
|
||||
Once configured, go to the **Admin** page in OSA Suite. The **Connection Status** panel runs a diagnostic chain:
|
||||
|
||||
1. **DNS** — resolves the Keycloak hostname
|
||||
2. **TCP** — connects to the port
|
||||
3. **TLS** — validates the certificate (if HTTPS)
|
||||
4. **Auth** — obtains a service-account token
|
||||
5. **Realm** — calls the Admin API to verify access
|
||||
|
||||
Each step shows pass/fail with latency. If a step fails, the error message and details are shown below the chain.
|
||||
|
||||
## How the Integration Works
|
||||
|
||||
- **Users**: when a user is created/updated in OSA Suite, a corresponding Keycloak user is created/updated (matched by email). The Keycloak user ID is stored in the `keycloak_id` column.
|
||||
- **Groups**: Keycloak groups map to OSA projects. When a user is added to a project, they are added to the corresponding Keycloak group.
|
||||
- **Disabled mode**: when `KEYCLOAK_URL` is empty, all Keycloak client methods return `None` immediately. No HTTP calls are made. The app works identically, just without SSO sync.
|
||||
@@ -2,25 +2,25 @@
|
||||
|
||||
|
||||
## UI changes
|
||||
- [ ] Remove Attention needed card on dashboard
|
||||
- [ ] Add an Archived state for certs/licenses (not just expired)
|
||||
- [ ] Add expired in card for licenses and certs on Dashboard
|
||||
- [ ] Projects overview page- 1 column for people, make their names pills
|
||||
- [ ] fix tooltip email copy functionality
|
||||
- [ ] more padding on right edge for at a glance
|
||||
- [ ] cards within cards like screenshot
|
||||
- [ x ] Remove Attention needed card on dashboard
|
||||
- [ x ] Add an Archived state for certs/licenses (not just expired)
|
||||
- [ x ] Add expired in card for licenses and certs on Dashboard
|
||||
- [ x ] Projects overview page- 1 column for people, make their names pills
|
||||
- [ x ] fix tooltip email copy functionality
|
||||
- [ x ] more padding on right edge for at a glance
|
||||
- [ x ] cards within cards like screenshot
|
||||
|
||||
|
||||
## New work
|
||||
- [ ] add concept of paid, unpaid, non-billable user for that project
|
||||
- [ x ] add concept of paid, unpaid, non-billable user for that project
|
||||
core users, collaborators, standing
|
||||
- [ ] change from core to collaborator
|
||||
- [ x ] change from core to collaborator
|
||||
first check if someone else is paying for them
|
||||
or flag as standing
|
||||
- [ ] new Users page
|
||||
- [ ] Shows all groups person is attached to
|
||||
- [ ] show history of project movement/permissions modified
|
||||
- [ ] projects should be clickable
|
||||
- [ x ] new Users page
|
||||
- [ x ] Shows all groups person is attached to
|
||||
- [ x ] show history of project movement/permissions modified
|
||||
- [ x ] projects should be clickable
|
||||
|
||||
|
||||
## Integrations
|
||||
|
||||
@@ -158,10 +158,12 @@ export function useDashboardStats() {
|
||||
const res = await api.get("/dashboard/stats");
|
||||
return res.data as {
|
||||
projects_count: number;
|
||||
onboarding_projects: number;
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
licenses_count: number;
|
||||
certs_count: number;
|
||||
freeloaders: number;
|
||||
expiring_licenses_30d: number;
|
||||
expiring_certs_30d: number;
|
||||
expired_licenses: number;
|
||||
|
||||
@@ -16,6 +16,118 @@ export function useExpiryThresholds() {
|
||||
});
|
||||
}
|
||||
|
||||
interface KeycloakCheck {
|
||||
status: "ok" | "error" | "skipped";
|
||||
ms?: number;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
reason?: string;
|
||||
ips?: string[];
|
||||
subject?: Record<string, string>;
|
||||
issuer?: Record<string, string>;
|
||||
expires?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export interface KeycloakStatus {
|
||||
enabled: boolean;
|
||||
message?: string;
|
||||
url?: string;
|
||||
realm?: string;
|
||||
overall?: "ok" | "error";
|
||||
checks?: {
|
||||
dns: KeycloakCheck;
|
||||
tcp: KeycloakCheck;
|
||||
tls: KeycloakCheck;
|
||||
auth: KeycloakCheck;
|
||||
realm: KeycloakCheck;
|
||||
};
|
||||
}
|
||||
|
||||
export function useKeycloakStatus() {
|
||||
return useQuery<KeycloakStatus>({
|
||||
queryKey: ["settings", "keycloak-status"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/settings/keycloak-status");
|
||||
return data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
interface AllSettings {
|
||||
default_project_rate: string;
|
||||
session_timeout_hours: string;
|
||||
audit_log_retention_days: string;
|
||||
email_notifications_enabled: string;
|
||||
email_notifications_address: string;
|
||||
notify_license_expiring: string;
|
||||
notify_cert_expiring: string;
|
||||
notify_license_expired: string;
|
||||
notify_cert_expired: string;
|
||||
notify_new_feedback: string;
|
||||
notify_user_changes: string;
|
||||
default_billing_type: string;
|
||||
pkcs12_passphrase_required: string;
|
||||
max_upload_size_mb: string;
|
||||
auto_archive_days: string;
|
||||
license_expiry_threshold_days: string;
|
||||
cert_expiry_threshold_days: string;
|
||||
}
|
||||
|
||||
export function useAllSettings() {
|
||||
return useQuery<AllSettings>({
|
||||
queryKey: ["settings", "all"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/settings/all");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Record<string, unknown>) => {
|
||||
const { data } = await api.put("/settings/all", settings);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface SmtpStatus {
|
||||
configured: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
use_tls: boolean;
|
||||
from_address: string;
|
||||
has_credentials: boolean;
|
||||
}
|
||||
|
||||
export function useSmtpStatus() {
|
||||
return useQuery<SmtpStatus>({
|
||||
queryKey: ["settings", "smtp-status"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/settings/smtp-status");
|
||||
return data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestEmail() {
|
||||
return useMutation({
|
||||
mutationFn: async (to: string) => {
|
||||
const { data } = await api.post("/settings/test-email", { to });
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateExpiryThresholds() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Settings } from "lucide-react";
|
||||
import {
|
||||
Settings,
|
||||
Shield,
|
||||
Globe,
|
||||
Plug,
|
||||
Lock,
|
||||
KeyRound,
|
||||
Database,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Mail,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -39,6 +54,11 @@ import {
|
||||
import {
|
||||
useExpiryThresholds,
|
||||
useUpdateExpiryThresholds,
|
||||
useKeycloakStatus,
|
||||
useAllSettings,
|
||||
useUpdateSettings,
|
||||
useSmtpStatus,
|
||||
useSendTestEmail,
|
||||
} from "@/hooks/use-settings";
|
||||
import {
|
||||
FEEDBACK_STATUS_COLORS,
|
||||
@@ -151,6 +171,541 @@ function ExpiryThresholdsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const CHECK_STEPS = [
|
||||
{ key: "dns", label: "DNS", sublabel: "Resolution", icon: Globe },
|
||||
{ key: "tcp", label: "TCP", sublabel: "Connection", icon: Plug },
|
||||
{ key: "tls", label: "TLS", sublabel: "Certificate", icon: Lock },
|
||||
{ key: "auth", label: "Auth", sublabel: "Credentials", icon: KeyRound },
|
||||
{ key: "realm", label: "Realm", sublabel: "Admin API", icon: Database },
|
||||
] as const;
|
||||
|
||||
function KeycloakDiagnosticsSection() {
|
||||
const { data, isLoading, refetch, isFetching } = useKeycloakStatus();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.enabled) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Connection Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keycloak is not configured. Set <code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">KEYCLOAK_URL</code> to enable.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const checks = data.checks!;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Connection Status
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data.url} — realm <span className="font-mono">{data.realm}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
Re-check
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Connection chain */}
|
||||
<div className="flex items-center gap-0">
|
||||
{CHECK_STEPS.map((step, i) => {
|
||||
const check = checks[step.key];
|
||||
const isOk = check.status === "ok";
|
||||
const isErr = check.status === "error";
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
{/* Step */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-12 items-center justify-center rounded-full border-2",
|
||||
isOk && "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30",
|
||||
isErr && "border-red-500 bg-red-50 dark:bg-red-950/30",
|
||||
!isOk && !isErr && "border-border bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<step.icon
|
||||
className={cn(
|
||||
"size-5",
|
||||
isOk && "text-emerald-600 dark:text-emerald-400",
|
||||
isErr && "text-red-600 dark:text-red-400",
|
||||
!isOk && !isErr && "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold">{step.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{step.sublabel}
|
||||
</p>
|
||||
{check.ms !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{check.ms}ms
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connector line */}
|
||||
{i < CHECK_STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 h-0.5 w-8 sm:w-12",
|
||||
isOk ? "bg-emerald-400" : "bg-border"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error details */}
|
||||
{data.overall === "error" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{CHECK_STEPS.map((step) => {
|
||||
const check = checks[step.key];
|
||||
if (check.status !== "error") return null;
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-900 dark:bg-red-950/20"
|
||||
>
|
||||
<XCircle className="mt-0.5 size-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-medium text-red-800 dark:text-red-300">
|
||||
{step.label} check failed
|
||||
</p>
|
||||
<p className="text-red-700 dark:text-red-400">
|
||||
{check.error}
|
||||
</p>
|
||||
{check.detail && (
|
||||
<p className="mt-1 font-mono text-xs text-red-600 dark:text-red-500">
|
||||
{check.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.overall === "ok" && (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||
<p className="font-medium text-emerald-800 dark:text-emerald-300">
|
||||
All checks passed — Keycloak connection is healthy
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ key: "notify_license_expiring", label: "License expiring soon", desc: "When a license enters the expiry warning window" },
|
||||
{ key: "notify_cert_expiring", label: "Certificate expiring soon", desc: "When a certificate enters the expiry warning window" },
|
||||
{ key: "notify_license_expired", label: "License expired", desc: "When a license has passed its expiration date" },
|
||||
{ key: "notify_cert_expired", label: "Certificate expired", desc: "When a certificate has passed its expiration date" },
|
||||
{ key: "notify_new_feedback", label: "New feedback submitted", desc: "When a user submits feedback from a project page" },
|
||||
{ key: "notify_user_changes", label: "User changes", desc: "When users are added, removed, or deactivated" },
|
||||
] as const;
|
||||
|
||||
function NotificationsSection({
|
||||
form,
|
||||
updateField,
|
||||
}: {
|
||||
form: Record<string, string>;
|
||||
updateField: (key: string, value: string) => void;
|
||||
}) {
|
||||
const { data: smtp, isLoading: smtpLoading } = useSmtpStatus();
|
||||
const sendTest = useSendTestEmail();
|
||||
const [testEmail, setTestEmail] = useState("");
|
||||
|
||||
function handleSendTest() {
|
||||
const to = testEmail.trim();
|
||||
if (!to) {
|
||||
toast.error("Enter a recipient email address");
|
||||
return;
|
||||
}
|
||||
sendTest.mutate(to, {
|
||||
onSuccess: (data) => toast.success(data.message),
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || "Failed to send test email";
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = form.email_notifications_enabled === "true";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Email Notifications</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure which events trigger email alerts and who receives them.
|
||||
SMTP connection is configured via environment variables.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* SMTP Status */}
|
||||
{smtpLoading ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : smtp?.configured ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-500" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-emerald-800 dark:text-emerald-300">SMTP configured</p>
|
||||
<p className="text-emerald-700 dark:text-emerald-400">
|
||||
{smtp.host}:{smtp.port} {smtp.use_tls ? "(TLS)" : ""} · From: {smtp.from_address}
|
||||
{smtp.has_credentials ? "" : " · No credentials"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="test@example.com"
|
||||
className="w-48 h-8 text-xs"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSendTest}
|
||||
disabled={sendTest.isPending}
|
||||
>
|
||||
{sendTest.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-3.5" />
|
||||
)}
|
||||
<span className="ml-1.5">Test</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<Mail className="mt-0.5 size-4 shrink-0 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300">SMTP not configured</p>
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
Set <code className="rounded bg-amber-100 px-1 py-0.5 text-xs font-mono dark:bg-amber-900/50">SMTP_HOST</code>,{" "}
|
||||
<code className="rounded bg-amber-100 px-1 py-0.5 text-xs font-mono dark:bg-amber-900/50">SMTP_USERNAME</code>, and{" "}
|
||||
<code className="rounded bg-amber-100 px-1 py-0.5 text-xs font-mono dark:bg-amber-900/50">SMTP_PASSWORD</code>{" "}
|
||||
environment variables to enable email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Master toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="email-enabled" className="text-sm">Notifications</Label>
|
||||
<Select value={form.email_notifications_enabled ?? "false"} onValueChange={(v) => v && updateField("email_notifications_enabled", v)}>
|
||||
<SelectTrigger className="w-36 h-8" id="email-enabled">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">Disabled</SelectItem>
|
||||
<SelectItem value="true">Enabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{enabled && !smtp?.configured && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">SMTP required</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert types */}
|
||||
<div className={cn(!enabled && "opacity-50 pointer-events-none")}>
|
||||
<p className="text-sm font-medium mb-3">Alert Types</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{ALERT_TYPES.map(({ key, label, desc }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4 rounded border-gray-300 text-primary accent-primary"
|
||||
checked={form[key] === "true"}
|
||||
onChange={(e) => updateField(key, e.target.checked ? "true" : "false")}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">{label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipients */}
|
||||
<div className={cn(!enabled && "opacity-50 pointer-events-none")}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email-address">Recipients</Label>
|
||||
<Input
|
||||
id="email-address"
|
||||
type="text"
|
||||
className="max-w-md"
|
||||
placeholder="admin@example.com, team@example.com"
|
||||
value={form.email_notifications_address ?? ""}
|
||||
onChange={(e) => updateField("email_notifications_address", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Comma-separated list of email addresses that will receive alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AllSettingsSection() {
|
||||
const { data: settings, isLoading } = useAllSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [form, setForm] = useState<Record<string, string>>({});
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setForm({
|
||||
default_project_rate: settings.default_project_rate,
|
||||
session_timeout_hours: settings.session_timeout_hours,
|
||||
audit_log_retention_days: settings.audit_log_retention_days,
|
||||
email_notifications_enabled: settings.email_notifications_enabled,
|
||||
email_notifications_address: settings.email_notifications_address,
|
||||
notify_license_expiring: settings.notify_license_expiring,
|
||||
notify_cert_expiring: settings.notify_cert_expiring,
|
||||
notify_license_expired: settings.notify_license_expired,
|
||||
notify_cert_expired: settings.notify_cert_expired,
|
||||
notify_new_feedback: settings.notify_new_feedback,
|
||||
notify_user_changes: settings.notify_user_changes,
|
||||
default_billing_type: settings.default_billing_type,
|
||||
pkcs12_passphrase_required: settings.pkcs12_passphrase_required,
|
||||
max_upload_size_mb: settings.max_upload_size_mb,
|
||||
auto_archive_days: settings.auto_archive_days,
|
||||
});
|
||||
setDirty(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
function updateField(key: string, value: string) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
// Convert types for the API
|
||||
const payload: Record<string, unknown> = {
|
||||
default_project_rate: parseFloat(form.default_project_rate),
|
||||
session_timeout_hours: parseFloat(form.session_timeout_hours),
|
||||
audit_log_retention_days: parseInt(form.audit_log_retention_days, 10),
|
||||
email_notifications_enabled: form.email_notifications_enabled === "true",
|
||||
email_notifications_address: form.email_notifications_address,
|
||||
notify_license_expiring: form.notify_license_expiring === "true",
|
||||
notify_cert_expiring: form.notify_cert_expiring === "true",
|
||||
notify_license_expired: form.notify_license_expired === "true",
|
||||
notify_cert_expired: form.notify_cert_expired === "true",
|
||||
notify_new_feedback: form.notify_new_feedback === "true",
|
||||
notify_user_changes: form.notify_user_changes === "true",
|
||||
default_billing_type: form.default_billing_type,
|
||||
pkcs12_passphrase_required: form.pkcs12_passphrase_required === "true",
|
||||
max_upload_size_mb: parseInt(form.max_upload_size_mb, 10),
|
||||
auto_archive_days: parseInt(form.auto_archive_days, 10),
|
||||
};
|
||||
updateSettings.mutate(payload, {
|
||||
onSuccess: () => { toast.success("Settings saved"); setDirty(false); },
|
||||
onError: () => toast.error("Failed to save settings"),
|
||||
});
|
||||
}
|
||||
|
||||
function handleBackup() {
|
||||
window.open("/api/settings/backup", "_blank");
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Card><CardContent className="py-6"><Skeleton className="h-5 w-full" /></CardContent></Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* General */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">General</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="default-rate">Default Project Rate ($)</Label>
|
||||
<Input
|
||||
id="default-rate"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="w-32"
|
||||
value={form.default_project_rate ?? ""}
|
||||
onChange={(e) => updateField("default_project_rate", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Default hourly rate for new projects</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="default-billing">Default Billing Type</Label>
|
||||
<Select value={form.default_billing_type ?? "core"} onValueChange={(v) => v && updateField("default_billing_type", v)}>
|
||||
<SelectTrigger className="w-40" id="default-billing">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="core">Core</SelectItem>
|
||||
<SelectItem value="collaborator">Collaborator</SelectItem>
|
||||
<SelectItem value="standing">Standing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Default billing type when adding users to projects</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Security</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="session-timeout">Session Timeout (hours)</Label>
|
||||
<Input
|
||||
id="session-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
className="w-28"
|
||||
value={form.session_timeout_hours ?? ""}
|
||||
onChange={(e) => updateField("session_timeout_hours", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Requires server restart to take effect</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pkcs12-passphrase">PKCS12 Export Passphrase</Label>
|
||||
<Select value={form.pkcs12_passphrase_required ?? "false"} onValueChange={(v) => v && updateField("pkcs12_passphrase_required", v)}>
|
||||
<SelectTrigger className="w-40" id="pkcs12-passphrase">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">No passphrase</SelectItem>
|
||||
<SelectItem value="true">Require passphrase</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Whether PKCS12 exports include a passphrase</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max-upload">Max Upload Size (MB)</Label>
|
||||
<Input
|
||||
id="max-upload"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
className="w-28"
|
||||
value={form.max_upload_size_mb ?? ""}
|
||||
onChange={(e) => updateField("max_upload_size_mb", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Maximum file upload size. Requires server restart to take effect.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Maintenance */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Maintenance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-retention">Audit Log Retention (days)</Label>
|
||||
<Input
|
||||
id="audit-retention"
|
||||
type="number"
|
||||
min={0}
|
||||
max={3650}
|
||||
className="w-28"
|
||||
value={form.audit_log_retention_days ?? ""}
|
||||
onChange={(e) => updateField("audit_log_retention_days", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Auto-purge audit logs older than this. 0 = keep forever.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="auto-archive">Auto-Archive Expired Items (days)</Label>
|
||||
<Input
|
||||
id="auto-archive"
|
||||
type="number"
|
||||
min={0}
|
||||
max={3650}
|
||||
className="w-28"
|
||||
value={form.auto_archive_days ?? ""}
|
||||
onChange={(e) => updateField("auto_archive_days", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Archive expired licenses/certs after this many days. 0 = disabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationsSection form={form} updateField={updateField} />
|
||||
|
||||
{/* Save + Backup */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button onClick={handleSave} disabled={!dirty || updateSettings.isPending}>
|
||||
{updateSettings.isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleBackup}>
|
||||
<Database className="size-4 mr-2" />
|
||||
Download Database Backup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { data: feedbackList, isLoading } = useFeedbackList();
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
@@ -174,73 +729,110 @@ export default function AdminPage() {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Admin</h1>
|
||||
</div>
|
||||
|
||||
{/* Expiry Thresholds */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold">Settings</h2>
|
||||
<ExpiryThresholdsSection />
|
||||
</section>
|
||||
<Tabs defaultValue="settings">
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="feedback">Feedback</TabsTrigger>
|
||||
<TabsTrigger value="keycloak">Keycloak</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Feedback Table */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TabsContent value="settings">
|
||||
<div className="space-y-4">
|
||||
<ExpiryThresholdsSection />
|
||||
<AllSettingsSection />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
) : !feedbackList || feedbackList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
No feedback submitted yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
feedbackList.map((fb) => (
|
||||
<TableRow
|
||||
key={fb.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setSelectedId(fb.id)}
|
||||
>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{fb.created_at
|
||||
? format(parseISO(fb.created_at), "MMM d, yyyy h:mm a")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{fb.project_key}
|
||||
</TableCell>
|
||||
<TableCell>{fb.username ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{fb.message}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FeedbackStatusBadge status={fb.status} />
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : !feedbackList || feedbackList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
No feedback submitted yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
feedbackList.map((fb) => (
|
||||
<TableRow
|
||||
key={fb.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => setSelectedId(fb.id)}
|
||||
>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{fb.created_at
|
||||
? format(parseISO(fb.created_at), "MMM d, yyyy h:mm a")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{fb.project_key}
|
||||
</TableCell>
|
||||
<TableCell>{fb.username ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{fb.message}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FeedbackStatusBadge status={fb.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="keycloak">
|
||||
<div className="space-y-4">
|
||||
<KeycloakDiagnosticsSection />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border-2 border-dashed border-border bg-muted/20 p-8">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Shield className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-medium">
|
||||
Discrepancy Management
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
|
||||
Coming Soon
|
||||
</span>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Compare local users and project memberships against Keycloak
|
||||
to identify sync discrepancies — users missing from Keycloak,
|
||||
group memberships out of sync, or stale Keycloak entries with
|
||||
no local match. Resolve conflicts without deleting local data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Feedback Detail Dialog */}
|
||||
<Dialog
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { ActivityFeed } from "@/components/shared/activity-feed";
|
||||
import { useUser, useUpdateUser, useUserHistory, useAddUserToProject, useUpdateMembership, type User } from "@/hooks/use-users";
|
||||
import { useUser, useUpdateUser, useUserHistory, useAddUserToProject, useUpdateMembership, type User, type ProjectMembership } from "@/hooks/use-users";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
|
||||
const BILLING_LABELS: Record<string, { label: string; className: string }> = {
|
||||
@@ -64,25 +64,6 @@ const PRIVILEGE_LABELS: Record<string, { label: string; className: string }> = {
|
||||
},
|
||||
};
|
||||
|
||||
function BillingBadge({ type }: { type: string }) {
|
||||
const style = BILLING_LABELS[type] ?? BILLING_LABELS.standing;
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${style.className}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivilegeBadge({ level }: { level: string }) {
|
||||
const style = PRIVILEGE_LABELS[level];
|
||||
if (!style) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${style.className}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUserEntry(entry: {
|
||||
action: string;
|
||||
user: string;
|
||||
@@ -172,7 +153,7 @@ export default function UserDetailPage() {
|
||||
(p) => !user.projects.some((m) => m.project_key === p.key)
|
||||
) ?? [];
|
||||
|
||||
function handleBillingChange(m: (typeof user.projects)[number], newType: string) {
|
||||
function handleBillingChange(m: ProjectMembership, newType: string) {
|
||||
if (newType === m.billing_type || !userId) return;
|
||||
updateMembership.mutate(
|
||||
{ project_key: m.project_key, user_id: userId, billing_type: newType },
|
||||
@@ -217,7 +198,7 @@ export default function UserDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function handlePrivilegeChange(m: (typeof user.projects)[number], newPrivilege: string) {
|
||||
function handlePrivilegeChange(m: ProjectMembership, newPrivilege: string) {
|
||||
if (newPrivilege === m.privileges || !userId) return;
|
||||
updateMembership.mutate(
|
||||
{ project_key: m.project_key, user_id: userId, privileges: newPrivilege },
|
||||
|
||||
550
scripts/seed-keycloak.py
Executable file
550
scripts/seed-keycloak.py
Executable file
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed a Keycloak realm with OSA Suite sample data.
|
||||
|
||||
Creates the realm (if needed), a service-account client, users, groups
|
||||
(one per project), and group memberships matching the local seed data.
|
||||
|
||||
Usage:
|
||||
# Against local Keycloak (defaults)
|
||||
python scripts/seed-keycloak.py
|
||||
|
||||
# Against a remote instance
|
||||
python scripts/seed-keycloak.py \
|
||||
--url https://keycloak.example.com \
|
||||
--admin-user admin \
|
||||
--admin-password changeme \
|
||||
--realm osa
|
||||
|
||||
# Using environment variables
|
||||
KEYCLOAK_URL=http://localhost:8180 python scripts/seed-keycloak.py
|
||||
|
||||
Prerequisites:
|
||||
pip install requests
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
# ── Seed data (imported from the main seed file) ─────────────────
|
||||
|
||||
# Inline the data we need so this script is fully standalone.
|
||||
PROJECTS = [
|
||||
{"key": "ACME", "name": "Acme Retail Platform"},
|
||||
{"key": "ATLAS", "name": "Atlas HR Suite"},
|
||||
{"key": "BEACN", "name": "Beacon CRM"},
|
||||
{"key": "CITDL", "name": "Citadel Security Portal"},
|
||||
{"key": "DELTA", "name": "Delta Logistics Tracker"},
|
||||
{"key": "ECHO", "name": "Echo Learning Platform"},
|
||||
{"key": "FORGE", "name": "Forge Developer Tools"},
|
||||
{"key": "GLACR", "name": "Glacier Backup Service"},
|
||||
{"key": "HARBR", "name": "Harbor API Gateway"},
|
||||
{"key": "IRIS", "name": "Iris Analytics Dashboard"},
|
||||
{"key": "JADE", "name": "Jade Communications Hub"},
|
||||
{"key": "KYSTO", "name": "Keystone Identity Management"},
|
||||
{"key": "LUNAR", "name": "Lunar Financial Reporting"},
|
||||
{"key": "METOR", "name": "Meteor Cloud Operations"},
|
||||
{"key": "NOVA", "name": "Nova Mobile App"},
|
||||
]
|
||||
|
||||
USERS = [
|
||||
{"name": "Priya Patel", "email": "priya.patel@acme.com"},
|
||||
{"name": "Michael Chen", "email": "michael.chen@acme.com"},
|
||||
{"name": "David Nguyen", "email": "david.nguyen@acme.com"},
|
||||
{"name": "Laura Kim", "email": "laura.kim@atlas.io"},
|
||||
{"name": "James Rodriguez", "email": "james.r@atlas.io"},
|
||||
{"name": "Sarah Thompson", "email": "sarah.t@atlas.io"},
|
||||
{"name": "Tom Bradley", "email": "tom.bradley@beacon.co"},
|
||||
{"name": "Nina Vasquez", "email": "nina.v@beacon.co"},
|
||||
{"name": "Chris Lee", "email": "chris.lee@beacon.co"},
|
||||
{"name": "Angela Morris", "email": "amorris@citadel.net"},
|
||||
{"name": "Raj Kapoor", "email": "raj.kapoor@citadel.net"},
|
||||
{"name": "Emily Sato", "email": "emily.sato@citadel.net"},
|
||||
{"name": "Frank Gomez", "email": "fgomez@delta.com"},
|
||||
{"name": "Karen Wu", "email": "karen.wu@delta.com"},
|
||||
{"name": "Marcus Johnson", "email": "marcus.j@delta.com"},
|
||||
{"name": "Diana Ross", "email": "diana.ross@echo.edu"},
|
||||
{"name": "Steve Park", "email": "steve.park@echo.edu"},
|
||||
{"name": "Omar Hassan", "email": "omar.h@forge.dev"},
|
||||
{"name": "Rachel Green", "email": "rachel.g@forge.dev"},
|
||||
{"name": "Kevin Brown", "email": "kevin.b@forge.dev"},
|
||||
{"name": "Helen Zhang", "email": "helen.z@glacier.io"},
|
||||
{"name": "Brian Miller", "email": "brian.m@glacier.io"},
|
||||
{"name": "Paul Davis", "email": "paul.d@harbor.com"},
|
||||
{"name": "Michelle Tanaka", "email": "michelle.t@harbor.com"},
|
||||
{"name": "Sandra Kowalski", "email": "sandra.k@iris.co"},
|
||||
{"name": "Derek Olson", "email": "derek.o@iris.co"},
|
||||
{"name": "Victor Lam", "email": "victor.l@jade.com"},
|
||||
{"name": "Julia Fischer", "email": "julia.f@jade.com"},
|
||||
{"name": "Natalie Brooks", "email": "natalie.b@keystone.sec"},
|
||||
{"name": "Ryan Cooper", "email": "ryan.c@keystone.sec"},
|
||||
{"name": "Patricia Novak", "email": "patricia.n@meteor.ops"},
|
||||
{"name": "Andrew Kim", "email": "andrew.k@meteor.ops"},
|
||||
{"name": "Henry Tran", "email": "henry.t@nova.app"},
|
||||
{"name": "Rebecca Stone", "email": "rebecca.s@nova.app"},
|
||||
{"name": "Jason Lim", "email": "jason.l@nova.app"},
|
||||
{"name": "Aiden Walsh", "email": "aiden.w@acme.com"},
|
||||
{"name": "Sofia Reyes", "email": "sofia.r@acme.com"},
|
||||
{"name": "Liam O'Connor", "email": "liam.oc@acme.com"},
|
||||
{"name": "Mia Johannsen", "email": "mia.j@atlas.io"},
|
||||
{"name": "Ethan Blake", "email": "ethan.b@atlas.io"},
|
||||
{"name": "Chloe Dubois", "email": "chloe.d@beacon.co"},
|
||||
{"name": "Noah Pham", "email": "noah.p@beacon.co"},
|
||||
{"name": "Ava Petrov", "email": "ava.p@citadel.net"},
|
||||
{"name": "Lucas Ferreira", "email": "lucas.f@citadel.net"},
|
||||
{"name": "Zara Okafor", "email": "zara.o@delta.com"},
|
||||
{"name": "Isaac Yamamoto", "email": "isaac.y@echo.edu"},
|
||||
{"name": "Ella Johansson", "email": "ella.j@echo.edu"},
|
||||
{"name": "Oliver Katz", "email": "oliver.k@forge.dev"},
|
||||
{"name": "Harper Singh", "email": "harper.s@glacier.io"},
|
||||
{"name": "William Torres", "email": "william.t@harbor.com"},
|
||||
{"name": "Grace Nakamura", "email": "grace.n@iris.co"},
|
||||
{"name": "Benjamin Cho", "email": "benjamin.c@jade.com"},
|
||||
{"name": "Lily Andersen", "email": "lily.a@keystone.sec"},
|
||||
{"name": "Jack Morales", "email": "jack.m@meteor.ops"},
|
||||
{"name": "Aria Bianchi", "email": "aria.b@nova.app"},
|
||||
]
|
||||
|
||||
# (user_email, project_idx, billing_type, privileges)
|
||||
PROJECT_USER_ASSIGNMENTS = [
|
||||
("priya.patel@acme.com", 0, "standing", ""),
|
||||
("michael.chen@acme.com", 0, "standing", ""),
|
||||
("david.nguyen@acme.com", 0, "standing", ""),
|
||||
("aiden.w@acme.com", 0, "core", "admin"),
|
||||
("sofia.r@acme.com", 0, "core", "write"),
|
||||
("liam.oc@acme.com", 0, "core", "write"),
|
||||
("oliver.k@forge.dev", 0, "collaborator", "read"),
|
||||
("laura.kim@atlas.io", 1, "standing", ""),
|
||||
("james.r@atlas.io", 1, "standing", ""),
|
||||
("sarah.t@atlas.io", 1, "standing", ""),
|
||||
("mia.j@atlas.io", 1, "core", "admin"),
|
||||
("ethan.b@atlas.io", 1, "core", "write"),
|
||||
("sofia.r@acme.com", 1, "collaborator", "read"),
|
||||
("tom.bradley@beacon.co", 2, "standing", ""),
|
||||
("nina.v@beacon.co", 2, "standing", ""),
|
||||
("chris.lee@beacon.co", 2, "standing", ""),
|
||||
("chloe.d@beacon.co", 2, "core", "admin"),
|
||||
("noah.p@beacon.co", 2, "core", "write"),
|
||||
("grace.n@iris.co", 2, "collaborator", "read"),
|
||||
("amorris@citadel.net", 3, "standing", ""),
|
||||
("raj.kapoor@citadel.net", 3, "standing", ""),
|
||||
("emily.sato@citadel.net", 3, "standing", ""),
|
||||
("ava.p@citadel.net", 3, "core", "admin"),
|
||||
("lucas.f@citadel.net", 3, "core", "write"),
|
||||
("fgomez@delta.com", 4, "standing", ""),
|
||||
("karen.wu@delta.com", 4, "standing", ""),
|
||||
("marcus.j@delta.com", 4, "standing", ""),
|
||||
("zara.o@delta.com", 4, "core", "admin"),
|
||||
("diana.ross@echo.edu", 5, "standing", ""),
|
||||
("steve.park@echo.edu", 5, "standing", ""),
|
||||
("isaac.y@echo.edu", 5, "core", "admin"),
|
||||
("ella.j@echo.edu", 5, "core", "write"),
|
||||
("omar.h@forge.dev", 6, "standing", ""),
|
||||
("rachel.g@forge.dev", 6, "standing", ""),
|
||||
("kevin.b@forge.dev", 6, "standing", ""),
|
||||
("oliver.k@forge.dev", 6, "core", "admin"),
|
||||
("liam.oc@acme.com", 6, "collaborator", "read"),
|
||||
("helen.z@glacier.io", 7, "standing", ""),
|
||||
("brian.m@glacier.io", 7, "standing", ""),
|
||||
("harper.s@glacier.io", 7, "core", "admin"),
|
||||
("paul.d@harbor.com", 8, "standing", ""),
|
||||
("michelle.t@harbor.com", 8, "standing", ""),
|
||||
("william.t@harbor.com", 8, "core", "admin"),
|
||||
("lucas.f@citadel.net", 8, "collaborator", "write"),
|
||||
("sandra.k@iris.co", 9, "standing", ""),
|
||||
("derek.o@iris.co", 9, "standing", ""),
|
||||
("grace.n@iris.co", 9, "core", "admin"),
|
||||
("victor.l@jade.com", 10, "standing", ""),
|
||||
("julia.f@jade.com", 10, "standing", ""),
|
||||
("benjamin.c@jade.com", 10, "core", "admin"),
|
||||
("natalie.b@keystone.sec", 11, "standing", ""),
|
||||
("ryan.c@keystone.sec", 11, "standing", ""),
|
||||
("lily.a@keystone.sec", 11, "core", "admin"),
|
||||
("patricia.n@meteor.ops", 13, "standing", ""),
|
||||
("andrew.k@meteor.ops", 13, "standing", ""),
|
||||
("jack.m@meteor.ops", 13, "core", "admin"),
|
||||
("henry.t@nova.app", 14, "standing", ""),
|
||||
("rebecca.s@nova.app", 14, "standing", ""),
|
||||
("jason.l@nova.app", 14, "standing", ""),
|
||||
("aria.b@nova.app", 14, "core", "write"),
|
||||
("chloe.d@beacon.co", 14, "collaborator", "read"),
|
||||
]
|
||||
|
||||
SERVICE_CLIENT_ID = "osa-admin-client"
|
||||
|
||||
|
||||
# ── Keycloak API helpers ─────────────────────────────────────────
|
||||
|
||||
class KeycloakSeeder:
|
||||
def __init__(self, base_url, admin_user, admin_password, realm):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.admin_user = admin_user
|
||||
self.admin_password = admin_password
|
||||
self.realm = realm
|
||||
self._token = None
|
||||
|
||||
def _get_master_token(self):
|
||||
"""Get an admin token from the master realm."""
|
||||
if self._token:
|
||||
return self._token
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/realms/master/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "admin-cli",
|
||||
"username": self.admin_user,
|
||||
"password": self.admin_password,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(f"ERROR: Failed to authenticate as {self.admin_user}: {resp.status_code}")
|
||||
print(resp.text)
|
||||
sys.exit(1)
|
||||
self._token = resp.json()["access_token"]
|
||||
return self._token
|
||||
|
||||
def _headers(self):
|
||||
return {
|
||||
"Authorization": f"Bearer {self._get_master_token()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _admin_url(self, path=""):
|
||||
return f"{self.base_url}/admin/realms/{self.realm}{path}"
|
||||
|
||||
# ── Realm ────────────────────────────────────────────────────
|
||||
|
||||
def ensure_realm(self):
|
||||
"""Create the realm if it doesn't exist."""
|
||||
resp = requests.get(
|
||||
f"{self.base_url}/admin/realms/{self.realm}",
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print(f" Realm '{self.realm}' already exists")
|
||||
return
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/admin/realms",
|
||||
headers=self._headers(),
|
||||
json={
|
||||
"realm": self.realm,
|
||||
"enabled": True,
|
||||
"displayName": "OSA Suite",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
print(f" Realm '{self.realm}' created")
|
||||
elif resp.status_code == 409:
|
||||
print(f" Realm '{self.realm}' already exists")
|
||||
else:
|
||||
print(f" ERROR creating realm: {resp.status_code} {resp.text}")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Service-account client ───────────────────────────────────
|
||||
|
||||
def ensure_service_client(self):
|
||||
"""Create the osa-admin-client with service account and realm-admin role."""
|
||||
resp = requests.get(
|
||||
self._admin_url(f"/clients?clientId={SERVICE_CLIENT_ID}"),
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
clients = resp.json()
|
||||
if clients:
|
||||
client_id = clients[0]["id"]
|
||||
print(f" Client '{SERVICE_CLIENT_ID}' already exists (id={client_id[:8]}...)")
|
||||
else:
|
||||
resp = requests.post(
|
||||
self._admin_url("/clients"),
|
||||
headers=self._headers(),
|
||||
json={
|
||||
"clientId": SERVICE_CLIENT_ID,
|
||||
"name": "OSA Suite Admin Client",
|
||||
"enabled": True,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"serviceAccountsEnabled": True,
|
||||
"publicClient": False,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": False,
|
||||
"directAccessGrantsEnabled": False,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code not in (201, 409):
|
||||
print(f" ERROR creating client: {resp.status_code} {resp.text}")
|
||||
return
|
||||
# Fetch the created client
|
||||
resp = requests.get(
|
||||
self._admin_url(f"/clients?clientId={SERVICE_CLIENT_ID}"),
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
clients = resp.json()
|
||||
client_id = clients[0]["id"]
|
||||
print(f" Client '{SERVICE_CLIENT_ID}' created (id={client_id[:8]}...)")
|
||||
|
||||
# Get the client secret
|
||||
resp = requests.get(
|
||||
self._admin_url(f"/clients/{client_id}/client-secret"),
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
secret = resp.json().get("value", "")
|
||||
|
||||
# Assign realm-admin role to the service account
|
||||
self._assign_realm_admin(client_id)
|
||||
|
||||
return secret
|
||||
|
||||
def _assign_realm_admin(self, client_uuid):
|
||||
"""Give the service account the realm-admin role from realm-management."""
|
||||
# Get service account user
|
||||
resp = requests.get(
|
||||
self._admin_url(f"/clients/{client_uuid}/service-account-user"),
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(" WARN: Could not find service account user")
|
||||
return
|
||||
sa_user_id = resp.json()["id"]
|
||||
|
||||
# Find the realm-management client
|
||||
resp = requests.get(
|
||||
self._admin_url("/clients?clientId=realm-management"),
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
rm_clients = resp.json()
|
||||
if not rm_clients:
|
||||
print(" WARN: realm-management client not found")
|
||||
return
|
||||
rm_id = rm_clients[0]["id"]
|
||||
|
||||
# Get the realm-admin role
|
||||
resp = requests.get(
|
||||
self._admin_url(f"/clients/{rm_id}/roles/realm-admin"),
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(" WARN: realm-admin role not found")
|
||||
return
|
||||
role = resp.json()
|
||||
|
||||
# Assign it
|
||||
resp = requests.post(
|
||||
self._admin_url(f"/users/{sa_user_id}/role-mappings/clients/{rm_id}"),
|
||||
headers=self._headers(),
|
||||
json=[role],
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 204:
|
||||
print(" Service account granted realm-admin role")
|
||||
elif resp.status_code == 409:
|
||||
print(" Service account already has realm-admin role")
|
||||
else:
|
||||
print(f" WARN: Could not assign realm-admin: {resp.status_code}")
|
||||
|
||||
# ── Users ────────────────────────────────────────────────────
|
||||
|
||||
def create_users(self):
|
||||
"""Create all seed users. Returns {email: keycloak_id} map."""
|
||||
user_map = {}
|
||||
created = 0
|
||||
skipped = 0
|
||||
|
||||
for u in USERS:
|
||||
email = u["email"]
|
||||
name = u["name"]
|
||||
first = name.split()[0]
|
||||
last = " ".join(name.split()[1:]) if " " in name else ""
|
||||
|
||||
resp = requests.post(
|
||||
self._admin_url("/users"),
|
||||
headers=self._headers(),
|
||||
json={
|
||||
"username": email,
|
||||
"email": email,
|
||||
"firstName": first,
|
||||
"lastName": last,
|
||||
"enabled": True,
|
||||
"emailVerified": True,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if resp.status_code == 201:
|
||||
location = resp.headers.get("Location", "")
|
||||
kc_id = location.rsplit("/", 1)[-1] if location else None
|
||||
if kc_id:
|
||||
user_map[email] = kc_id
|
||||
created += 1
|
||||
elif resp.status_code == 409:
|
||||
# Already exists — look up by email
|
||||
kc_id = self._find_user(email)
|
||||
if kc_id:
|
||||
user_map[email] = kc_id
|
||||
skipped += 1
|
||||
else:
|
||||
print(f" WARN: Failed to create user {email}: {resp.status_code}")
|
||||
|
||||
print(f" Users: {created} created, {skipped} already existed")
|
||||
return user_map
|
||||
|
||||
def _find_user(self, email):
|
||||
resp = requests.get(
|
||||
self._admin_url("/users"),
|
||||
headers=self._headers(),
|
||||
params={"email": email, "exact": "true"},
|
||||
timeout=10,
|
||||
)
|
||||
users = resp.json()
|
||||
return users[0]["id"] if users else None
|
||||
|
||||
# ── Groups (projects) ────────────────────────────────────────
|
||||
|
||||
def create_groups(self):
|
||||
"""Create groups for each project. Returns {project_key: group_id} map."""
|
||||
group_map = {}
|
||||
created = 0
|
||||
skipped = 0
|
||||
|
||||
for p in PROJECTS:
|
||||
group_name = f"{p['key']} - {p['name']}"
|
||||
|
||||
resp = requests.post(
|
||||
self._admin_url("/groups"),
|
||||
headers=self._headers(),
|
||||
json={"name": group_name},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if resp.status_code == 201:
|
||||
location = resp.headers.get("Location", "")
|
||||
group_id = location.rsplit("/", 1)[-1] if location else None
|
||||
if group_id:
|
||||
group_map[p["key"]] = group_id
|
||||
created += 1
|
||||
elif resp.status_code == 409:
|
||||
group_id = self._find_group(group_name)
|
||||
if group_id:
|
||||
group_map[p["key"]] = group_id
|
||||
skipped += 1
|
||||
else:
|
||||
print(f" WARN: Failed to create group {group_name}: {resp.status_code}")
|
||||
|
||||
print(f" Groups: {created} created, {skipped} already existed")
|
||||
return group_map
|
||||
|
||||
def _find_group(self, name):
|
||||
resp = requests.get(
|
||||
self._admin_url("/groups"),
|
||||
headers=self._headers(),
|
||||
params={"search": name, "exact": "true"},
|
||||
timeout=10,
|
||||
)
|
||||
for g in resp.json():
|
||||
if g["name"] == name:
|
||||
return g["id"]
|
||||
return None
|
||||
|
||||
# ── Group memberships ────────────────────────────────────────
|
||||
|
||||
def assign_memberships(self, user_map, group_map):
|
||||
"""Add users to their project groups."""
|
||||
assigned = 0
|
||||
skipped = 0
|
||||
|
||||
for email, proj_idx, _billing, _priv in PROJECT_USER_ASSIGNMENTS:
|
||||
if proj_idx >= len(PROJECTS):
|
||||
continue
|
||||
project_key = PROJECTS[proj_idx]["key"]
|
||||
user_id = user_map.get(email)
|
||||
group_id = group_map.get(project_key)
|
||||
|
||||
if not user_id or not group_id:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
resp = requests.put(
|
||||
self._admin_url(f"/users/{user_id}/groups/{group_id}"),
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 204:
|
||||
assigned += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
print(f" Memberships: {assigned} assigned, {skipped} skipped")
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Seed a Keycloak realm with OSA Suite sample data."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
default=os.environ.get("KEYCLOAK_URL", "http://localhost:8180"),
|
||||
help="Keycloak base URL (default: $KEYCLOAK_URL or http://localhost:8180)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--admin-user",
|
||||
default=os.environ.get("KEYCLOAK_ADMIN", "admin"),
|
||||
help="Keycloak admin username (default: $KEYCLOAK_ADMIN or admin)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--admin-password",
|
||||
default=os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin"),
|
||||
help="Keycloak admin password (default: $KEYCLOAK_ADMIN_PASSWORD or admin)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--realm",
|
||||
default=os.environ.get("KEYCLOAK_REALM", "osa"),
|
||||
help="Realm name to create/seed (default: $KEYCLOAK_REALM or osa)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Seeding Keycloak at {args.url}, realm '{args.realm}'")
|
||||
print()
|
||||
|
||||
seeder = KeycloakSeeder(args.url, args.admin_user, args.admin_password, args.realm)
|
||||
|
||||
print("[1/4] Realm")
|
||||
seeder.ensure_realm()
|
||||
|
||||
print("[2/4] Service client")
|
||||
secret = seeder.ensure_service_client()
|
||||
|
||||
print("[3/4] Users")
|
||||
user_map = seeder.create_users()
|
||||
|
||||
print("[4/4] Groups & memberships")
|
||||
group_map = seeder.create_groups()
|
||||
seeder.assign_memberships(user_map, group_map)
|
||||
|
||||
print()
|
||||
print("Done! To connect OSA Suite to this Keycloak instance:")
|
||||
print()
|
||||
print(f" KEYCLOAK_URL={args.url}")
|
||||
print(f" KEYCLOAK_REALM={args.realm}")
|
||||
print(f" KEYCLOAK_CLIENT_ID={SERVICE_CLIENT_ID}")
|
||||
if secret:
|
||||
print(f" KEYCLOAK_CLIENT_SECRET={secret}")
|
||||
else:
|
||||
print(f" KEYCLOAK_CLIENT_SECRET=<check Keycloak admin console>")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user