Add frontend access control enhancements and JWT support
- Hide New Project button for unauthenticated users, show login link - Add lock icon for private projects on home page - Show access level badges on project cards (Owner, Admin, Write, Read) - Add permission expiration date field to AccessManagement component - Add query timeout configuration for database (ORCHARD_DATABASE_QUERY_TIMEOUT) - Add JWT token validation support for external identity providers - Configurable via ORCHARD_JWT_* environment variables - Supports HS256 with secret or RS256 with JWKS - Auto-provisions users from JWT claims
This commit is contained in:
@@ -1,16 +1,20 @@
|
|||||||
"""Authentication service for Orchard.
|
"""Authentication service for Orchard.
|
||||||
|
|
||||||
Handles password hashing, session management, and API key operations.
|
Handles password hashing, session management, API key operations, and JWT validation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import User, Session as UserSession, APIKey
|
from .models import User, Session as UserSession, APIKey
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Password hashing context (bcrypt with cost factor 12)
|
# Password hashing context (bcrypt with cost factor 12)
|
||||||
@@ -374,6 +378,147 @@ def create_default_admin(db: Session) -> Optional[User]:
|
|||||||
return admin
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
# --- JWT Validation ---
|
||||||
|
|
||||||
|
|
||||||
|
def validate_jwt_token(token: str) -> Optional[dict]:
|
||||||
|
"""Validate a JWT token and return the decoded payload.
|
||||||
|
|
||||||
|
Returns None if validation fails or JWT is not configured.
|
||||||
|
Uses python-jose for JWT operations.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if not settings.jwt_enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from jose import jwt, JWTError, ExpiredSignatureError
|
||||||
|
from jose.exceptions import JWTClaimsError
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("python-jose not installed, JWT authentication disabled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build decode options
|
||||||
|
decode_options = {}
|
||||||
|
|
||||||
|
# Set up key for validation
|
||||||
|
if settings.jwt_algorithm.startswith("RS"):
|
||||||
|
# RS256/RS384/RS512 - use JWKS
|
||||||
|
if not settings.jwt_jwks_url:
|
||||||
|
logger.error("JWT JWKS URL not configured for RSA algorithm")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Fetch JWKS from the URL
|
||||||
|
response = httpx.get(settings.jwt_jwks_url, timeout=10.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
jwks = response.json()
|
||||||
|
|
||||||
|
# Get the key ID from the token header
|
||||||
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
|
kid = unverified_header.get("kid")
|
||||||
|
|
||||||
|
# Find the matching key
|
||||||
|
rsa_key = None
|
||||||
|
for key in jwks.get("keys", []):
|
||||||
|
if key.get("kid") == kid:
|
||||||
|
rsa_key = key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not rsa_key:
|
||||||
|
logger.error(f"No matching key found in JWKS for kid: {kid}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = rsa_key
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get signing key from JWKS: {e}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# HS256/HS384/HS512 - use secret
|
||||||
|
if not settings.jwt_secret:
|
||||||
|
logger.error("JWT secret not configured for HMAC algorithm")
|
||||||
|
return None
|
||||||
|
key = settings.jwt_secret
|
||||||
|
|
||||||
|
# Build decode kwargs
|
||||||
|
decode_kwargs = {
|
||||||
|
"algorithms": [settings.jwt_algorithm],
|
||||||
|
"options": decode_options,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add issuer validation if configured
|
||||||
|
if settings.jwt_issuer:
|
||||||
|
decode_kwargs["issuer"] = settings.jwt_issuer
|
||||||
|
|
||||||
|
# Add audience validation if configured
|
||||||
|
if settings.jwt_audience:
|
||||||
|
decode_kwargs["audience"] = settings.jwt_audience
|
||||||
|
|
||||||
|
# Decode and validate the token
|
||||||
|
payload = jwt.decode(token, key, **decode_kwargs)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
logger.debug("JWT token expired")
|
||||||
|
return None
|
||||||
|
except JWTClaimsError as e:
|
||||||
|
logger.debug(f"JWT claims error: {e}")
|
||||||
|
return None
|
||||||
|
except JWTError as e:
|
||||||
|
logger.debug(f"Invalid JWT token: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"JWT validation error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_user_from_jwt(db: Session, payload: dict) -> Optional[User]:
|
||||||
|
"""Get or create a user from JWT payload.
|
||||||
|
|
||||||
|
Uses the configured username claim to extract the username.
|
||||||
|
Creates a new user if one doesn't exist (for SSO auto-provisioning).
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
username = payload.get(settings.jwt_username_claim)
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
logger.warning(f"JWT missing username claim: {settings.jwt_username_claim}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sanitize username (remove domain from email if needed)
|
||||||
|
if "@" in username and settings.jwt_username_claim == "email":
|
||||||
|
# Keep full email as username for email-based auth
|
||||||
|
pass
|
||||||
|
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
user = auth_service.get_user_by_username(username)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
if not user.is_active:
|
||||||
|
logger.debug(f"JWT user {username} is inactive")
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Auto-provision user from JWT
|
||||||
|
logger.info(f"Auto-provisioning user from JWT: {username}")
|
||||||
|
try:
|
||||||
|
user = auth_service.create_user(
|
||||||
|
username=username,
|
||||||
|
password=None, # No password for SSO users
|
||||||
|
email=payload.get("email"),
|
||||||
|
is_admin=False,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to auto-provision JWT user: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# --- FastAPI Dependencies ---
|
# --- FastAPI Dependencies ---
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status, Cookie, Header
|
from fastapi import Depends, HTTPException, status, Cookie, Header
|
||||||
@@ -388,10 +533,15 @@ def get_current_user_optional(
|
|||||||
session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
|
session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
|
||||||
authorization: Optional[str] = Header(None),
|
authorization: Optional[str] = Header(None),
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
"""Get the current user from session cookie or API key.
|
"""Get the current user from session cookie, API key, or JWT token.
|
||||||
|
|
||||||
Returns None if no valid authentication is provided.
|
Returns None if no valid authentication is provided.
|
||||||
Does not raise an exception for unauthenticated requests.
|
Does not raise an exception for unauthenticated requests.
|
||||||
|
|
||||||
|
Authentication methods are tried in order:
|
||||||
|
1. Session cookie (web UI)
|
||||||
|
2. API key (Bearer token starting with 'orch_')
|
||||||
|
3. JWT token (Bearer token that's a valid JWT)
|
||||||
"""
|
"""
|
||||||
auth_service = AuthService(db)
|
auth_service = AuthService(db)
|
||||||
|
|
||||||
@@ -403,13 +553,24 @@ def get_current_user_optional(
|
|||||||
if user and user.is_active:
|
if user and user.is_active:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
# Then try API key (CLI/programmatic access)
|
# Then try Bearer token (API key or JWT)
|
||||||
if authorization:
|
if authorization and authorization.startswith("Bearer "):
|
||||||
if authorization.startswith("Bearer "):
|
token = authorization[7:] # Remove "Bearer " prefix
|
||||||
api_key = authorization[7:] # Remove "Bearer " prefix
|
|
||||||
user = auth_service.get_user_from_api_key(api_key)
|
# Check if it's an API key (starts with orch_)
|
||||||
|
if token.startswith(API_KEY_PREFIX):
|
||||||
|
user = auth_service.get_user_from_api_key(token)
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
|
else:
|
||||||
|
# Try JWT validation
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.jwt_enabled:
|
||||||
|
payload = validate_jwt_token(token)
|
||||||
|
if payload:
|
||||||
|
user = get_or_create_user_from_jwt(db, payload)
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
|
|||||||
database_pool_recycle: int = (
|
database_pool_recycle: int = (
|
||||||
1800 # Recycle connections after this many seconds (30 min)
|
1800 # Recycle connections after this many seconds (30 min)
|
||||||
)
|
)
|
||||||
|
database_query_timeout: int = 30 # Query timeout in seconds (0 = no timeout)
|
||||||
|
|
||||||
# S3
|
# S3
|
||||||
s3_endpoint: str = ""
|
s3_endpoint: str = ""
|
||||||
@@ -52,6 +53,17 @@ class Settings(BaseSettings):
|
|||||||
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
log_format: str = "auto" # "json", "standard", or "auto" (json in production)
|
log_format: str = "auto" # "json", "standard", or "auto" (json in production)
|
||||||
|
|
||||||
|
# JWT Authentication settings (optional, for external identity providers)
|
||||||
|
jwt_enabled: bool = False # Enable JWT token validation
|
||||||
|
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS
|
||||||
|
jwt_algorithm: str = "HS256" # HS256 or RS256
|
||||||
|
jwt_issuer: str = "" # Expected issuer (iss claim), leave empty to skip validation
|
||||||
|
jwt_audience: str = "" # Expected audience (aud claim), leave empty to skip validation
|
||||||
|
jwt_jwks_url: str = "" # JWKS URL for RS256 (e.g., https://auth.example.com/.well-known/jwks.json)
|
||||||
|
jwt_username_claim: str = (
|
||||||
|
"sub" # JWT claim to use as username (sub, email, preferred_username, etc.)
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
|
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ from .models import Base
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Build connect_args with query timeout if configured
|
||||||
|
connect_args = {}
|
||||||
|
if settings.database_query_timeout > 0:
|
||||||
|
# PostgreSQL statement_timeout is in milliseconds
|
||||||
|
connect_args["options"] = f"-c statement_timeout={settings.database_query_timeout * 1000}"
|
||||||
|
|
||||||
# Create engine with connection pool configuration
|
# Create engine with connection pool configuration
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
@@ -21,6 +27,7 @@ engine = create_engine(
|
|||||||
max_overflow=settings.database_max_overflow,
|
max_overflow=settings.database_max_overflow,
|
||||||
pool_timeout=settings.database_pool_timeout,
|
pool_timeout=settings.database_pool_timeout,
|
||||||
pool_recycle=settings.database_pool_recycle,
|
pool_recycle=settings.database_pool_recycle,
|
||||||
|
connect_args=connect_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|||||||
@@ -45,11 +45,13 @@ from .models import (
|
|||||||
Consumer,
|
Consumer,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
User,
|
User,
|
||||||
|
AccessPermission,
|
||||||
)
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
|
ProjectWithAccessResponse,
|
||||||
PackageCreate,
|
PackageCreate,
|
||||||
PackageUpdate,
|
PackageUpdate,
|
||||||
PackageResponse,
|
PackageResponse,
|
||||||
@@ -947,7 +949,7 @@ def global_search(
|
|||||||
|
|
||||||
|
|
||||||
# Project routes
|
# Project routes
|
||||||
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse])
|
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectWithAccessResponse])
|
||||||
def list_projects(
|
def list_projects(
|
||||||
request: Request,
|
request: Request,
|
||||||
page: int = Query(default=1, ge=1, description="Page number"),
|
page: int = Query(default=1, ge=1, description="Page number"),
|
||||||
@@ -963,8 +965,9 @@ def list_projects(
|
|||||||
),
|
),
|
||||||
order: str = Query(default="asc", description="Sort order (asc, desc)"),
|
order: str = Query(default="asc", description="Sort order (asc, desc)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||||
):
|
):
|
||||||
user_id = get_user_id(request)
|
user_id = current_user.username if current_user else get_user_id(request)
|
||||||
|
|
||||||
# Validate sort field
|
# Validate sort field
|
||||||
valid_sort_fields = {
|
valid_sort_fields = {
|
||||||
@@ -1022,8 +1025,51 @@ def list_projects(
|
|||||||
# Calculate total pages
|
# Calculate total pages
|
||||||
total_pages = math.ceil(total / limit) if total > 0 else 1
|
total_pages = math.ceil(total / limit) if total > 0 else 1
|
||||||
|
|
||||||
|
# Build access level info for each project
|
||||||
|
project_ids = [p.id for p in projects]
|
||||||
|
access_map = {}
|
||||||
|
|
||||||
|
if current_user and project_ids:
|
||||||
|
# Get access permissions for this user across these projects
|
||||||
|
permissions = (
|
||||||
|
db.query(AccessPermission)
|
||||||
|
.filter(
|
||||||
|
AccessPermission.project_id.in_(project_ids),
|
||||||
|
AccessPermission.user_id == current_user.username,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
access_map = {p.project_id: p.level for p in permissions}
|
||||||
|
|
||||||
|
# Build response with access levels
|
||||||
|
items = []
|
||||||
|
for p in projects:
|
||||||
|
is_owner = p.created_by == user_id
|
||||||
|
access_level = None
|
||||||
|
|
||||||
|
if is_owner:
|
||||||
|
access_level = "admin"
|
||||||
|
elif p.id in access_map:
|
||||||
|
access_level = access_map[p.id]
|
||||||
|
elif p.is_public:
|
||||||
|
access_level = "read"
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
ProjectWithAccessResponse(
|
||||||
|
id=p.id,
|
||||||
|
name=p.name,
|
||||||
|
description=p.description,
|
||||||
|
is_public=p.is_public,
|
||||||
|
created_at=p.created_at,
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
created_by=p.created_by,
|
||||||
|
access_level=access_level,
|
||||||
|
is_owner=is_owner,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=projects,
|
items=items,
|
||||||
pagination=PaginationMeta(
|
pagination=PaginationMeta(
|
||||||
page=page,
|
page=page,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ class ProjectUpdate(BaseModel):
|
|||||||
is_public: Optional[bool] = None
|
is_public: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectWithAccessResponse(ProjectResponse):
|
||||||
|
"""Project response with user's access level included"""
|
||||||
|
|
||||||
|
access_level: Optional[str] = None # 'read', 'write', 'admin', or None
|
||||||
|
is_owner: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Package format and platform enums
|
# Package format and platform enums
|
||||||
PACKAGE_FORMATS = [
|
PACKAGE_FORMATS = [
|
||||||
"generic",
|
"generic",
|
||||||
|
|||||||
@@ -98,3 +98,19 @@
|
|||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: #c0392b;
|
background: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expired permission styling */
|
||||||
|
.expired {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date input styling in table */
|
||||||
|
.access-table input[type="date"] {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
|
|||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [newUsername, setNewUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
const [newLevel, setNewLevel] = useState<AccessLevel>('read');
|
const [newLevel, setNewLevel] = useState<AccessLevel>('read');
|
||||||
|
const [newExpiresAt, setNewExpiresAt] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
const [editingUser, setEditingUser] = useState<string | null>(null);
|
const [editingUser, setEditingUser] = useState<string | null>(null);
|
||||||
const [editLevel, setEditLevel] = useState<AccessLevel>('read');
|
const [editLevel, setEditLevel] = useState<AccessLevel>('read');
|
||||||
|
const [editExpiresAt, setEditExpiresAt] = useState('');
|
||||||
|
|
||||||
const loadPermissions = useCallback(async () => {
|
const loadPermissions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -55,10 +57,12 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
|
|||||||
await grantProjectAccess(projectName, {
|
await grantProjectAccess(projectName, {
|
||||||
username: newUsername.trim(),
|
username: newUsername.trim(),
|
||||||
level: newLevel,
|
level: newLevel,
|
||||||
|
expires_at: newExpiresAt || undefined,
|
||||||
});
|
});
|
||||||
setSuccess(`Access granted to ${newUsername}`);
|
setSuccess(`Access granted to ${newUsername}`);
|
||||||
setNewUsername('');
|
setNewUsername('');
|
||||||
setNewLevel('read');
|
setNewLevel('read');
|
||||||
|
setNewExpiresAt('');
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
await loadPermissions();
|
await loadPermissions();
|
||||||
setTimeout(() => setSuccess(null), 3000);
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
@@ -73,7 +77,10 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
|
|||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
await updateProjectAccess(projectName, username, { level: editLevel });
|
await updateProjectAccess(projectName, username, {
|
||||||
|
level: editLevel,
|
||||||
|
expires_at: editExpiresAt || null,
|
||||||
|
});
|
||||||
setSuccess(`Updated access for ${username}`);
|
setSuccess(`Updated access for ${username}`);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
await loadPermissions();
|
await loadPermissions();
|
||||||
@@ -105,10 +112,26 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
|
|||||||
const startEdit = (permission: AccessPermission) => {
|
const startEdit = (permission: AccessPermission) => {
|
||||||
setEditingUser(permission.user_id);
|
setEditingUser(permission.user_id);
|
||||||
setEditLevel(permission.level as AccessLevel);
|
setEditLevel(permission.level as AccessLevel);
|
||||||
|
// Convert ISO date to local date format for date input
|
||||||
|
setEditExpiresAt(permission.expires_at ? permission.expires_at.split('T')[0] : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
|
setEditExpiresAt('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExpiration = (expiresAt: string | null) => {
|
||||||
|
if (!expiresAt) return 'Never';
|
||||||
|
const date = new Date(expiresAt);
|
||||||
|
const now = new Date();
|
||||||
|
const isExpired = date < now;
|
||||||
|
return (
|
||||||
|
<span className={isExpired ? 'expired' : ''}>
|
||||||
|
{date.toLocaleDateString()}
|
||||||
|
{isExpired && ' (Expired)'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -158,6 +181,17 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
|
|||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="expires_at">Expires (optional)</label>
|
||||||
|
<input
|
||||||
|
id="expires_at"
|
||||||
|
type="date"
|
||||||
|
value={newExpiresAt}
|
||||||
|
onChange={(e) => setNewExpiresAt(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
{submitting ? 'Granting...' : 'Grant Access'}
|
{submitting ? 'Granting...' : 'Grant Access'}
|
||||||
</button>
|
</button>
|
||||||
@@ -175,6 +209,7 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
|
|||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Access Level</th>
|
<th>Access Level</th>
|
||||||
<th>Granted</th>
|
<th>Granted</th>
|
||||||
|
<th>Expires</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -200,6 +235,19 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{new Date(p.created_at).toLocaleDateString()}</td>
|
<td>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||||
|
<td>
|
||||||
|
{editingUser === p.user_id ? (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editExpiresAt}
|
||||||
|
onChange={(e) => setEditExpiresAt(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
formatExpiration(p.expires_at)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="actions">
|
<td className="actions">
|
||||||
{editingUser === p.user_id ? (
|
{editingUser === p.user_id ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -474,3 +474,16 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lock icon for private projects */
|
||||||
|
.lock-icon {
|
||||||
|
color: var(--warning);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project badges container */
|
||||||
|
.project-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,19 @@ import { SortDropdown, SortOption } from '../components/SortDropdown';
|
|||||||
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
|
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
|
||||||
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import './Home.css';
|
import './Home.css';
|
||||||
|
|
||||||
|
// Lock icon SVG component
|
||||||
|
function LockIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lock-icon">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SORT_OPTIONS: SortOption[] = [
|
const SORT_OPTIONS: SortOption[] = [
|
||||||
{ value: 'name', label: 'Name' },
|
{ value: 'name', label: 'Name' },
|
||||||
{ value: 'created_at', label: 'Created' },
|
{ value: 'created_at', label: 'Created' },
|
||||||
@@ -23,6 +34,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
|
|||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
|
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -117,9 +129,15 @@ function Home() {
|
|||||||
<div className="home">
|
<div className="home">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Projects</h1>
|
<h1>Projects</h1>
|
||||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
{user ? (
|
||||||
{showForm ? 'Cancel' : '+ New Project'}
|
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||||
</button>
|
{showForm ? 'Cancel' : '+ New Project'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="btn btn-secondary">
|
||||||
|
Login to create projects
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
@@ -199,12 +217,32 @@ function Home() {
|
|||||||
<div className="project-grid">
|
<div className="project-grid">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Link to={`/project/${project.name}`} key={project.id} className="project-card card">
|
<Link to={`/project/${project.name}`} key={project.id} className="project-card card">
|
||||||
<h3>{project.name}</h3>
|
<h3>
|
||||||
|
{!project.is_public && <LockIcon />}
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
{project.description && <p>{project.description}</p>}
|
{project.description && <p>{project.description}</p>}
|
||||||
<div className="project-meta">
|
<div className="project-meta">
|
||||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
<div className="project-badges">
|
||||||
{project.is_public ? 'Public' : 'Private'}
|
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||||
</Badge>
|
{project.is_public ? 'Public' : 'Private'}
|
||||||
|
</Badge>
|
||||||
|
{user && project.access_level && (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
project.is_owner
|
||||||
|
? 'success'
|
||||||
|
: project.access_level === 'admin'
|
||||||
|
? 'success'
|
||||||
|
: project.access_level === 'write'
|
||||||
|
? 'info'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="project-meta__dates">
|
<div className="project-meta__dates">
|
||||||
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span>
|
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span>
|
||||||
{project.updated_at !== project.created_at && (
|
{project.updated_at !== project.created_at && (
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Access Control types (moved to top for use in Project interface)
|
||||||
|
export type AccessLevel = 'read' | 'write' | 'admin';
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -6,6 +9,9 @@ export interface Project {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
|
// Access level info (populated when listing projects)
|
||||||
|
access_level?: AccessLevel | null;
|
||||||
|
is_owner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagSummary {
|
export interface TagSummary {
|
||||||
@@ -290,9 +296,7 @@ export interface UserUpdate {
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access Control types
|
// Access Permission types
|
||||||
export type AccessLevel = 'read' | 'write' | 'admin';
|
|
||||||
|
|
||||||
export interface AccessPermission {
|
export interface AccessPermission {
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user