17 Commits

Author SHA1 Message Date
Mondo Diaz
6c79147cbf Create Global Admins team when admin user is created
- Admin user is now automatically added to Global Admins team as owner
- Ensures every user belongs to at least one team
- Updated unit tests to handle multiple db.add() calls
2026-01-28 17:24:26 +00:00
Mondo Diaz
1bf8274d8c Update CHANGELOG with dark theme fixes and UI improvements 2026-01-28 16:51:12 +00:00
Mondo Diaz
9b79838cc3 Fix dark theme styling for team pages and add footer enhancements
- Update all team page CSS to use correct theme variables (--bg-*, --text-*,
  --border-*, --accent-* instead of non-existent --color-* variables)
- Fix modal, form input, and dropdown backgrounds for dark theme
- Fix UserAutocomplete and TeamSelector dropdown styling
- Center team members and settings page content
- Add orchard logo icon to footer
- Add dot separator between Orchard and tagline in footer
2026-01-28 16:49:50 +00:00
Mondo Diaz
1f5d3665c8 Fix modal backgrounds to be solid white instead of transparent 2026-01-28 16:25:22 +00:00
Mondo Diaz
1b2bc33aba Use DataTable for members, add seed users, remove teams stats
- Update TeamMembersPage to use DataTable component for consistency
- Add test users (alice, bob, charlie, diana, eve, frank) with various roles
- Remove stats from teams list header
- Passwords for test users are same as their usernames
2026-01-28 16:20:23 +00:00
Mondo Diaz
2b9c039157 Use DataTable component for teams and projects tables
Consistent table styling across the app with:
- Row hover highlighting
- Clickable rows
- Standard cell padding and borders
- Proper header styling
2026-01-28 16:13:32 +00:00
Mondo Diaz
7d106998be Add subtle vertical column separators to tables 2026-01-28 16:09:20 +00:00
Mondo Diaz
6198a174c7 Use subtle faint row separators for tables instead of thick borders 2026-01-28 16:07:13 +00:00
Mondo Diaz
184cb8ec00 Fix table borders, single team nav link, remove dashboard stats
- Use explicit border color (#e2e8f0) for table cell borders
- Navbar shows 'Team' (singular) linking directly to team dashboard when user has only 1 team
- Navbar shows 'Teams' (plural) linking to teams list when user has multiple teams
- Remove project/member counts from team dashboard header
2026-01-28 16:05:02 +00:00
Mondo Diaz
000540727c Improve table styling and make headers more horizontal
- Add visible column borders to teams and projects tables
- Make header 2px border for visual separation
- Consolidate teams page header: title + inline stats on left, create button on right
- Consolidate team dashboard header: title/badge/slug + description + inline stats on left, action buttons on right
2026-01-28 15:57:11 +00:00
Mondo Diaz
aece9e0b9f Change teams list to table view for consistency with projects table 2026-01-28 15:48:45 +00:00
Mondo Diaz
018e352820 Change projects display to table view in team dashboard 2026-01-28 15:45:46 +00:00
Mondo Diaz
86f2f031db Redesign teams portal and add user autocomplete for member invitations
- Redesign TeamsPage with modern card-based layout including stats bar,
  search functionality, and empty states
- Add UserAutocomplete component with debounced search and keyboard
  navigation for selecting existing users
- Add /api/v1/users/search endpoint for username prefix search
- Update TeamMembersPage to use UserAutocomplete instead of free text input
2026-01-28 15:42:55 +00:00
Mondo Diaz
69f3737303 Move project settings to team portal, remove project-level permissions
- Add Settings button to project cards in team dashboard
- Hide Settings button on ProjectPage for projects belonging to a team
- Remove AccessManagement section from ProjectSettingsPage
  (team membership now governs all access to team projects)
- Update project card layout with separate clickable area and actions
2026-01-28 15:19:41 +00:00
Mondo Diaz
60179e68fd Hide visibility filter for anonymous users on home page
Anonymous users can only see public projects, so the visibility
filter dropdown is not useful for them. Only show it when logged in.
2026-01-28 15:07:41 +00:00
Mondo Diaz
6901880a2f Update CHANGELOG with access management team display feature 2026-01-28 00:57:30 +00:00
Mondo Diaz
89186a0d61 Show team-based access in project access management
- Add source, team_slug, team_role fields to AccessPermissionResponse schema
- Update list_project_permissions endpoint to include team members with source="team"
- Display team-based access in AccessManagement component with read-only styling
- Add "Source" column to differentiate explicit vs team-based permissions
- Team-based access shows "Via team" in actions column (not editable)
2026-01-28 00:57:16 +00:00
25 changed files with 1413 additions and 615 deletions

View File

@@ -44,7 +44,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Teams navigation link in header (authenticated users only) - Teams navigation link in header (authenticated users only)
- Updated seed data to create a "Demo Team" and assign all seed projects to it - Updated seed data to create a "Demo Team" and assign all seed projects to it
- Added TypeScript types and API client functions for teams - Added TypeScript types and API client functions for teams
- Access management now shows team-based permissions alongside explicit permissions
- Team-based access displayed as read-only with "Source" column indicating origin
- Team members with access show team slug and role
- Added integration tests for team CRUD, membership, and project operations - Added integration tests for team CRUD, membership, and project operations
- Redesigned teams portal with modern card-based layout
- Card grid view with team avatar, name, slug, role badge, and stats
- Stats bar showing total teams, owned teams, and total projects
- Search functionality for filtering teams (appears when >3 teams)
- Empty states for no teams and no search results
- Added user autocomplete component for team member invitations
- `GET /api/v1/users/search` endpoint for username prefix search
- Dropdown shows matching users as you type
- Keyboard navigation support (arrow keys, enter, escape)
- Debounced search to reduce API calls
- Added unit tests for TeamAuthorizationService - Added unit tests for TeamAuthorizationService
- Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87) - Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87)
- When set, admin user is created with the specified password (no password change required) - When set, admin user is created with the specified password (no password change required)
@@ -92,6 +105,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added pre-test stage reset to ensure known environment state before integration tests (#54) - Added pre-test stage reset to ensure known environment state before integration tests (#54)
- Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies - Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies
- Updated upload API documentation with ensure file format and examples - Updated upload API documentation with ensure file format and examples
- Converted teams list and team projects to use DataTable component for consistent styling
- Centered team members and team settings page content
- Added orchard logo icon and dot separator to footer
### Fixed
- Fixed dark theme styling for team pages - modals, forms, and dropdowns now use correct theme variables
- Fixed UserAutocomplete and TeamSelector dropdown backgrounds for dark theme
## [0.5.1] - 2026-01-23 ## [0.5.1] - 2026-01-23
### Changed ### Changed

View File

@@ -11,7 +11,7 @@ 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, Team, TeamMembership
from .config import get_settings from .config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -363,6 +363,8 @@ def create_default_admin(db: Session) -> Optional[User]:
The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable. The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable.
If not set, defaults to 'changeme123' and requires password change on first login. If not set, defaults to 'changeme123' and requires password change on first login.
Also creates the "Global Admins" team and adds the admin user to it.
""" """
# Check if any users exist # Check if any users exist
user_count = db.query(User).count() user_count = db.query(User).count()
@@ -385,6 +387,27 @@ def create_default_admin(db: Session) -> Optional[User]:
must_change_password=must_change, must_change_password=must_change,
) )
# Create Global Admins team and add admin to it
global_admins_team = Team(
name="Global Admins",
slug="global-admins",
description="System administrators with full access",
created_by="admin",
)
db.add(global_admins_team)
db.flush()
membership = TeamMembership(
team_id=global_admins_team.id,
user_id=admin.id,
role="owner",
invited_by="admin",
)
db.add(membership)
db.commit()
logger.info("Created Global Admins team and added admin as owner")
if settings.admin_password: if settings.admin_password:
logger.info("Created default admin user with configured password") logger.info("Created default admin user with configured password")
else: else:

View File

@@ -1093,6 +1093,43 @@ def oidc_callback(
return response return response
# --- User Search Routes (for autocomplete) ---
@router.get("/api/v1/users/search")
def search_users(
q: str = Query(..., min_length=1, description="Search query for username"),
limit: int = Query(default=10, ge=1, le=50, description="Maximum results"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Search for users by username prefix.
Returns basic user info for autocomplete (no email for privacy).
Any authenticated user can search.
"""
search_pattern = f"{q.lower()}%"
users = (
db.query(User)
.filter(
func.lower(User.username).like(search_pattern),
User.is_active == True,
)
.order_by(User.username)
.limit(limit)
.all()
)
return [
{
"id": str(u.id),
"username": u.username,
"is_admin": u.is_admin,
}
for u in users
]
# --- Admin User Management Routes --- # --- Admin User Management Routes ---
@@ -1795,14 +1832,63 @@ def list_project_permissions(
): ):
""" """
List all access permissions for a project. List all access permissions for a project.
Includes both explicit permissions and team-based access.
Requires admin access to the project. Requires admin access to the project.
""" """
project = check_project_access(db, project_name, current_user, "admin") project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db) auth_service = AuthorizationService(db)
permissions = auth_service.list_project_permissions(str(project.id)) explicit_permissions = auth_service.list_project_permissions(str(project.id))
return permissions # Convert to response format with source field
result = []
for perm in explicit_permissions:
result.append(AccessPermissionResponse(
id=perm.id,
project_id=perm.project_id,
user_id=perm.user_id,
level=perm.level,
created_at=perm.created_at,
expires_at=perm.expires_at,
source="explicit",
))
# Add team-based access if project belongs to a team
if project.team_id:
team = db.query(Team).filter(Team.id == project.team_id).first()
if team:
memberships = (
db.query(TeamMembership)
.join(User, TeamMembership.user_id == User.id)
.filter(TeamMembership.team_id == project.team_id)
.all()
)
# Track users who already have explicit permissions
explicit_users = {p.user_id for p in result}
for membership in memberships:
user = db.query(User).filter(User.id == membership.user_id).first()
if user and user.username not in explicit_users:
# Map team role to project access level
if membership.role in ("owner", "admin"):
level = "admin"
else:
level = "read"
result.append(AccessPermissionResponse(
id=membership.id, # Use membership ID
project_id=project.id,
user_id=user.username,
level=level,
created_at=membership.created_at,
expires_at=None,
source="team",
team_slug=team.slug,
team_role=membership.role,
))
return result
@router.post( @router.post(

View File

@@ -911,6 +911,9 @@ class AccessPermissionResponse(BaseModel):
level: str level: str
created_at: datetime created_at: datetime
expires_at: Optional[datetime] expires_at: Optional[datetime]
source: Optional[str] = "explicit" # "explicit" or "team"
team_slug: Optional[str] = None # Team slug if source is "team"
team_role: Optional[str] = None # Team role if source is "team"
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User
from .storage import get_storage from .storage import get_storage
from .auth import hash_password
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -176,6 +177,53 @@ def seed_database(db: Session) -> None:
logger.info(f"Created team: {demo_team.name} ({demo_team.slug})") logger.info(f"Created team: {demo_team.name} ({demo_team.slug})")
# Create test users with various roles
test_users = [
{"username": "alice", "email": "alice@example.com", "role": "admin"},
{"username": "bob", "email": "bob@example.com", "role": "admin"},
{"username": "charlie", "email": "charlie@example.com", "role": "member"},
{"username": "diana", "email": "diana@example.com", "role": "member"},
{"username": "eve", "email": "eve@example.com", "role": "member"},
{"username": "frank", "email": None, "role": "member"},
]
for user_data in test_users:
# Check if user already exists
existing_user = db.query(User).filter(User.username == user_data["username"]).first()
if existing_user:
test_user = existing_user
else:
# Create the user with password same as username
test_user = User(
username=user_data["username"],
email=user_data["email"],
password_hash=hash_password(user_data["username"]),
is_admin=False,
is_active=True,
must_change_password=False,
)
db.add(test_user)
db.flush()
logger.info(f"Created test user: {user_data['username']}")
# Add to demo team with specified role
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == demo_team.id,
TeamMembership.user_id == test_user.id,
).first()
if not existing_membership:
membership = TeamMembership(
team_id=demo_team.id,
user_id=test_user.id,
role=user_data["role"],
invited_by=team_owner_username,
)
db.add(membership)
logger.info(f"Added {user_data['username']} to {demo_team.slug} as {user_data['role']}")
db.flush()
# Create projects and packages # Create projects and packages
project_map = {} project_map = {}
package_map = {} package_map = {}

View File

@@ -10,6 +10,7 @@ class TestCreateDefaultAdmin:
def test_create_default_admin_with_env_password(self): def test_create_default_admin_with_env_password(self):
"""Test that ORCHARD_ADMIN_PASSWORD env var sets admin password.""" """Test that ORCHARD_ADMIN_PASSWORD env var sets admin password."""
from app.auth import create_default_admin, verify_password from app.auth import create_default_admin, verify_password
from app.models import User
# Create mock settings with custom password # Create mock settings with custom password
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -19,20 +20,23 @@ class TestCreateDefaultAdmin:
mock_db = MagicMock() mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users mock_db.query.return_value.count.return_value = 0 # No existing users
# Track the user that gets created # Track all objects that get created
created_user = None created_objects = []
def capture_user(user): def capture_object(obj):
nonlocal created_user created_objects.append(obj)
created_user = user
mock_db.add.side_effect = capture_user mock_db.add.side_effect = capture_object
with patch("app.auth.get_settings", return_value=mock_settings): with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db) admin = create_default_admin(mock_db)
# Verify the user was created # Verify objects were created (user, team, membership)
assert mock_db.add.called assert mock_db.add.called
assert len(created_objects) >= 1
# Find the user object
created_user = next((obj for obj in created_objects if isinstance(obj, User)), None)
assert created_user is not None assert created_user is not None
assert created_user.username == "admin" assert created_user.username == "admin"
assert created_user.is_admin is True assert created_user.is_admin is True
@@ -44,6 +48,7 @@ class TestCreateDefaultAdmin:
def test_create_default_admin_with_default_password(self): def test_create_default_admin_with_default_password(self):
"""Test that default password 'changeme123' is used when env var not set.""" """Test that default password 'changeme123' is used when env var not set."""
from app.auth import create_default_admin, verify_password from app.auth import create_default_admin, verify_password
from app.models import User
# Create mock settings with empty password (default) # Create mock settings with empty password (default)
mock_settings = MagicMock() mock_settings = MagicMock()
@@ -53,20 +58,23 @@ class TestCreateDefaultAdmin:
mock_db = MagicMock() mock_db = MagicMock()
mock_db.query.return_value.count.return_value = 0 # No existing users mock_db.query.return_value.count.return_value = 0 # No existing users
# Track the user that gets created # Track all objects that get created
created_user = None created_objects = []
def capture_user(user): def capture_object(obj):
nonlocal created_user created_objects.append(obj)
created_user = user
mock_db.add.side_effect = capture_user mock_db.add.side_effect = capture_object
with patch("app.auth.get_settings", return_value=mock_settings): with patch("app.auth.get_settings", return_value=mock_settings):
admin = create_default_admin(mock_db) admin = create_default_admin(mock_db)
# Verify the user was created # Verify objects were created
assert mock_db.add.called assert mock_db.add.called
assert len(created_objects) >= 1
# Find the user object
created_user = next((obj for obj in created_objects if isinstance(obj, User)), None)
assert created_user is not None assert created_user is not None
assert created_user.username == "admin" assert created_user.username == "admin"
assert created_user.is_admin is True assert created_user.is_admin is True

View File

@@ -668,3 +668,17 @@ export async function listTeamProjects(
}); });
return handleResponse<PaginatedResponse<Project>>(response); return handleResponse<PaginatedResponse<Project>>(response);
} }
// User search (for autocomplete)
export interface UserSearchResult {
id: string;
username: string;
is_admin: boolean;
}
export async function searchUsers(query: string, limit: number = 10): Promise<UserSearchResult[]> {
const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
credentials: 'include',
});
return handleResponse<UserSearchResult[]>(response);
}

View File

@@ -114,3 +114,32 @@
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary); color: var(--text-primary);
} }
/* Access source styling */
.access-source {
display: inline-block;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.access-source--explicit {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.access-source--team {
background: var(--color-info-bg, #e3f2fd);
color: var(--color-info, #1976d2);
}
/* Team access row styling */
.team-access-row {
background: var(--bg-secondary, #fafafa);
}
.team-access-row td.actions .text-muted {
font-size: 0.8125rem;
font-style: italic;
}

View File

@@ -208,85 +208,104 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
<tr> <tr>
<th>User</th> <th>User</th>
<th>Access Level</th> <th>Access Level</th>
<th>Source</th>
<th>Granted</th> <th>Granted</th>
<th>Expires</th> <th>Expires</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{permissions.map((p) => ( {permissions.map((p) => {
<tr key={p.id}> const isTeamBased = p.source === 'team';
<td>{p.user_id}</td> return (
<td> <tr key={p.id} className={isTeamBased ? 'team-access-row' : ''}>
{editingUser === p.user_id ? ( <td>{p.user_id}</td>
<select <td>
value={editLevel} {editingUser === p.user_id && !isTeamBased ? (
onChange={(e) => setEditLevel(e.target.value as AccessLevel)} <select
disabled={submitting} value={editLevel}
> onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
<option value="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
) : (
<span className={`access-badge access-badge--${p.level}`}>
{p.level}
</span>
)}
</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">
{editingUser === p.user_id ? (
<>
<button
className="btn btn-sm btn-primary"
onClick={() => handleUpdate(p.user_id)}
disabled={submitting} disabled={submitting}
> >
Save <option value="read">Read</option>
</button> <option value="write">Write</option>
<button <option value="admin">Admin</option>
className="btn btn-sm" </select>
onClick={cancelEdit} ) : (
<span className={`access-badge access-badge--${p.level}`}>
{p.level}
</span>
)}
</td>
<td>
{isTeamBased ? (
<span className="access-source access-source--team" title={`Team role: ${p.team_role}`}>
Team: {p.team_slug}
</span>
) : (
<span className="access-source access-source--explicit">
Explicit
</span>
)}
</td>
<td>{new Date(p.created_at).toLocaleDateString()}</td>
<td>
{editingUser === p.user_id && !isTeamBased ? (
<input
type="date"
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
disabled={submitting} disabled={submitting}
> min={new Date().toISOString().split('T')[0]}
Cancel />
</button> ) : (
</> formatExpiration(p.expires_at)
) : ( )}
<> </td>
<button <td className="actions">
className="btn btn-sm" {isTeamBased ? (
onClick={() => startEdit(p)} <span className="text-muted" title="Manage access via team settings">
disabled={submitting} Via team
> </span>
Edit ) : editingUser === p.user_id ? (
</button> <>
<button <button
className="btn btn-sm btn-danger" className="btn btn-sm btn-primary"
onClick={() => handleRevoke(p.user_id)} onClick={() => handleUpdate(p.user_id)}
disabled={submitting} disabled={submitting}
> >
Revoke Save
</button> </button>
</> <button
)} className="btn btn-sm"
</td> onClick={cancelEdit}
</tr> disabled={submitting}
))} >
Cancel
</button>
</>
) : (
<>
<button
className="btn btn-sm"
onClick={() => startEdit(p)}
disabled={submitting}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleRevoke(p.user_id)}
disabled={submitting}
>
Revoke
</button>
</>
)}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
)} )}

View File

@@ -284,7 +284,11 @@
.footer-brand { .footer-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
}
.footer-icon {
color: var(--accent-primary);
} }
.footer-logo { .footer-logo {
@@ -292,6 +296,10 @@
color: var(--text-primary); color: var(--text-primary);
} }
.footer-separator {
color: var(--text-muted);
}
.footer-tagline { .footer-tagline {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;

View File

@@ -2,6 +2,8 @@ import { ReactNode, useState, useRef, useEffect } from 'react';
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { GlobalSearch } from './GlobalSearch'; import { GlobalSearch } from './GlobalSearch';
import { listTeams } from '../api';
import { TeamDetail } from '../types';
import './Layout.css'; import './Layout.css';
interface LayoutProps { interface LayoutProps {
@@ -13,8 +15,22 @@ function Layout({ children }: LayoutProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, loading, logout } = useAuth(); const { user, loading, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const [userTeams, setUserTeams] = useState<TeamDetail[]>([]);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
// Fetch user's teams
useEffect(() => {
if (user) {
listTeams({ limit: 10 }).then(data => {
setUserTeams(data.items);
}).catch(() => {
setUserTeams([]);
});
} else {
setUserTeams([]);
}
}, [user]);
// Close menu when clicking outside // Close menu when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@@ -77,15 +93,18 @@ function Layout({ children }: LayoutProps) {
</svg> </svg>
Dashboard Dashboard
</Link> </Link>
{user && ( {user && userTeams.length > 0 && (
<Link to="/teams" className={location.pathname.startsWith('/teams') ? 'active' : ''}> <Link
to={userTeams.length === 1 ? `/teams/${userTeams[0].slug}` : '/teams'}
className={location.pathname.startsWith('/teams') ? 'active' : ''}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/> <circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg> </svg>
Teams {userTeams.length === 1 ? 'Team' : 'Teams'}
</Link> </Link>
)} )}
<a href="/docs" className="nav-link-muted"> <a href="/docs" className="nav-link-muted">
@@ -199,7 +218,17 @@ function Layout({ children }: LayoutProps) {
<footer className="footer"> <footer className="footer">
<div className="container footer-content"> <div className="container footer-content">
<div className="footer-brand"> <div className="footer-brand">
<svg className="footer-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
<rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
<rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
<rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
</svg>
<span className="footer-logo">Orchard</span> <span className="footer-logo">Orchard</span>
<span className="footer-separator">·</span>
<span className="footer-tagline">Content-Addressable Storage</span> <span className="footer-tagline">Content-Addressable Storage</span>
</div> </div>
<div className="footer-links"> <div className="footer-links">

View File

@@ -7,10 +7,10 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: var(--color-bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-text); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
@@ -18,8 +18,8 @@
} }
.team-selector-trigger:hover:not(:disabled) { .team-selector-trigger:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-tertiary);
border-color: var(--color-border-hover); border-color: var(--border-secondary);
} }
.team-selector-trigger:disabled { .team-selector-trigger:disabled {
@@ -51,8 +51,8 @@
right: 0; right: 0;
min-width: 240px; min-width: 240px;
margin-top: 0.25rem; margin-top: 0.25rem;
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
z-index: 100; z-index: 100;
@@ -62,7 +62,7 @@
.team-selector-empty { .team-selector-empty {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
color: var(--color-text-muted); color: var(--text-muted);
} }
.team-selector-empty p { .team-selector-empty p {
@@ -71,7 +71,7 @@
} }
.team-selector-create-link { .team-selector-create-link {
color: var(--color-primary); color: var(--accent-primary);
font-size: 0.875rem; font-size: 0.875rem;
text-decoration: none; text-decoration: none;
} }
@@ -96,7 +96,7 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: none; background: none;
border: none; border: none;
color: var(--color-text); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
@@ -104,11 +104,11 @@
} }
.team-selector-item:hover { .team-selector-item:hover {
background: var(--color-bg-secondary); background: var(--bg-hover);
} }
.team-selector-item.selected { .team-selector-item.selected {
background: var(--color-primary-bg); background: rgba(16, 185, 129, 0.1);
} }
.team-selector-item-info { .team-selector-item-info {
@@ -127,7 +127,7 @@
.team-selector-item-meta { .team-selector-item-meta {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.team-selector-item-role { .team-selector-item-role {
@@ -140,24 +140,24 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--border-primary);
background: var(--color-bg-secondary); background: var(--bg-tertiary);
} }
.team-selector-link { .team-selector-link {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
text-decoration: none; text-decoration: none;
} }
.team-selector-link:hover { .team-selector-link:hover {
color: var(--color-text); color: var(--text-primary);
} }
.team-selector-link-primary { .team-selector-link-primary {
color: var(--color-primary); color: var(--accent-primary);
} }
.team-selector-link-primary:hover { .team-selector-link-primary:hover {
color: var(--color-primary-hover); color: var(--accent-primary-hover);
} }

View File

@@ -0,0 +1,105 @@
.user-autocomplete {
position: relative;
width: 100%;
}
.user-autocomplete__input-wrapper {
position: relative;
}
.user-autocomplete__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.875rem;
}
.user-autocomplete__input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.user-autocomplete__spinner {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 2px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: translateY(-50%) rotate(360deg); }
}
.user-autocomplete__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 0.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
max-height: 240px;
overflow-y: auto;
list-style: none;
}
.user-autocomplete__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.1s;
}
.user-autocomplete__option:hover,
.user-autocomplete__option.selected {
background: var(--bg-hover);
}
.user-autocomplete__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.user-autocomplete__user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-autocomplete__username {
font-weight: 500;
color: var(--text-primary);
}
.user-autocomplete__admin-badge {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { searchUsers, UserSearchResult } from '../api';
import './UserAutocomplete.css';
interface UserAutocompleteProps {
value: string;
onChange: (username: string) => void;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
}
export function UserAutocomplete({
value,
onChange,
placeholder = 'Search users...',
disabled = false,
autoFocus = false,
}: UserAutocompleteProps) {
const [query, setQuery] = useState(value);
const [results, setResults] = useState<UserSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Search for users with debounce
const doSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < 1) {
setResults([]);
setIsOpen(false);
return;
}
setLoading(true);
try {
const users = await searchUsers(searchQuery);
setResults(users);
setIsOpen(users.length > 0);
setSelectedIndex(-1);
} catch {
setResults([]);
setIsOpen(false);
} finally {
setLoading(false);
}
}, []);
// Handle input change with debounce
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setQuery(newValue);
onChange(newValue); // Update parent immediately for form validation
// Debounce the search
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
doSearch(newValue);
}, 200);
};
// Handle selecting a user
const handleSelect = (user: UserSearchResult) => {
setQuery(user.username);
onChange(user.username);
setIsOpen(false);
setResults([]);
inputRef.current?.focus();
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
handleSelect(results[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Sync external value changes
useEffect(() => {
setQuery(value);
}, [value]);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return (
<div className="user-autocomplete" ref={containerRef}>
<div className="user-autocomplete__input-wrapper">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => query.length >= 1 && results.length > 0 && setIsOpen(true)}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
autoComplete="off"
className="user-autocomplete__input"
/>
{loading && (
<div className="user-autocomplete__spinner" />
)}
</div>
{isOpen && results.length > 0 && (
<ul className="user-autocomplete__dropdown">
{results.map((user, index) => (
<li
key={user.id}
className={`user-autocomplete__option ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelect(user)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="user-autocomplete__avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-autocomplete__user-info">
<span className="user-autocomplete__username">{user.username}</span>
{user.is_admin && (
<span className="user-autocomplete__admin-badge">Admin</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -179,16 +179,18 @@ function Home() {
</form> </form>
)} )}
<div className="list-controls"> {user && (
<FilterDropdown <div className="list-controls">
label="Visibility" <FilterDropdown
options={VISIBILITY_OPTIONS} label="Visibility"
value={visibility} options={VISIBILITY_OPTIONS}
onChange={handleVisibilityChange} value={visibility}
/> onChange={handleVisibilityChange}
</div> />
</div>
)}
{hasActiveFilters && ( {user && hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}> <FilterChipGroup onClearAll={clearFilters}>
{visibility && ( {visibility && (
<FilterChip <FilterChip

View File

@@ -211,7 +211,7 @@ function ProjectPage() {
</div> </div>
</div> </div>
<div className="page-header__actions"> <div className="page-header__actions">
{canAdmin && ( {canAdmin && !project.team_id && (
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => navigate(`/project/${projectName}/settings`)} onClick={() => navigate(`/project/${projectName}/settings`)}

View File

@@ -10,7 +10,6 @@ import {
ForbiddenError, ForbiddenError,
} from '../api'; } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { AccessManagement } from '../components/AccessManagement';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import './ProjectSettingsPage.css'; import './ProjectSettingsPage.css';
@@ -236,9 +235,6 @@ function ProjectSettingsPage() {
</form> </form>
</div> </div>
{/* Access Management Section */}
<AccessManagement projectName={projectName!} />
{/* Danger Zone Section */} {/* Danger Zone Section */}
<div className="project-settings-danger-zone"> <div className="project-settings-danger-zone">
<h2>Danger Zone</h2> <h2>Danger Zone</h2>

View File

@@ -3,10 +3,18 @@
} }
.team-header { .team-header {
margin-bottom: 1.5rem; display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
margin-bottom: 2rem;
} }
.team-header-info { .team-header-left {
flex: 1;
}
.team-header-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -15,57 +23,26 @@
.team-header h1 { .team-header h1 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.5rem;
} font-weight: 600;
.team-description {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
font-size: 1rem;
max-width: 600px;
}
.team-meta {
display: flex;
align-items: center;
gap: 1rem;
} }
.team-slug { .team-slug {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.team-stats { .team-description {
margin: 0 0 0.5rem;
color: var(--text-secondary);
font-size: 0.9375rem;
max-width: 600px;
}
.team-header-actions {
display: flex; display: flex;
gap: 1rem; gap: 0.5rem;
margin-bottom: 1.5rem; flex-shrink: 0;
}
.stat-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.5rem;
min-width: 120px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text);
}
.stat-label {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.team-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
} }
.team-section { .team-section {
@@ -84,54 +61,23 @@
font-size: 1.25rem; font-size: 1.25rem;
} }
.projects-grid { /* Table utility classes */
display: grid; .text-muted {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); color: var(--text-muted);
gap: 1rem;
} }
.project-card { .btn-ghost {
background: var(--color-bg-secondary); background: transparent;
border: 1px solid var(--color-border); color: var(--text-muted);
border-radius: var(--radius-md); border: none;
padding: 1rem; padding: 0.375rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; border-radius: var(--radius-sm);
} }
.project-card:hover { .btn-ghost:hover {
border-color: var(--color-border-hover); background: var(--bg-tertiary);
box-shadow: var(--shadow-sm); color: var(--text-primary);
}
.project-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.project-card-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.project-card-description {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-card-meta {
font-size: 0.8125rem;
color: var(--color-text-muted);
} }
.section-footer { .section-footer {
@@ -141,7 +87,7 @@
.view-all-link { .view-all-link {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-primary); color: var(--accent-primary);
text-decoration: none; text-decoration: none;
} }
@@ -162,16 +108,16 @@
.error-state p { .error-state p {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
background: var(--color-bg-secondary); background: var(--bg-secondary);
border: 1px dashed var(--color-border); border: 1px dashed var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-text-muted); color: var(--text-muted);
} }
.empty-state p { .empty-state p {
@@ -204,29 +150,29 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: var(--color-bg-tertiary); background: var(--bg-hover);
} }
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -235,18 +181,21 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
max-height: 90vh; max-height: 90vh;
box-shadow: var(--shadow-lg);
overflow-y: auto; overflow-y: auto;
} }
.modal-content h2 { .modal-content h2 {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-primary);
} }
/* Form */ /* Form */
@@ -259,24 +208,25 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
} }
.form-group input[type="text"], .form-group input[type="text"],
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
} }
.form-group textarea { .form-group textarea {
@@ -299,7 +249,7 @@
.form-hint { .form-hint {
display: block; display: block;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
margin-top: 0.375rem; margin-top: 0.375rem;
} }

View File

@@ -5,6 +5,7 @@ import { getTeam, listTeamProjects, createProject } from '../api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import './TeamDashboardPage.css'; import './TeamDashboardPage.css';
function TeamDashboardPage() { function TeamDashboardPage() {
@@ -95,54 +96,42 @@ function TeamDashboardPage() {
/> />
<div className="team-header"> <div className="team-header">
<div className="team-header-info"> <div className="team-header-left">
<h1>{team.name}</h1> <div className="team-header-title">
{team.user_role && ( <h1>{team.name}</h1>
<Badge variant={roleVariants[team.user_role] || 'default'}> {team.user_role && (
{team.user_role} <Badge variant={roleVariants[team.user_role] || 'default'}>
</Badge> {team.user_role}
</Badge>
)}
<span className="team-slug">@{team.slug}</span>
</div>
{team.description && (
<p className="team-description">{team.description}</p>
)} )}
</div> </div>
{team.description && ( {isAdminOrOwner && (
<p className="team-description">{team.description}</p> <div className="team-header-actions">
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Members
</Link>
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</Link>
</div>
)} )}
<div className="team-meta">
<span className="team-slug">@{team.slug}</span>
</div>
</div> </div>
<div className="team-stats">
<div className="stat-card">
<div className="stat-value">{team.project_count}</div>
<div className="stat-label">Projects</div>
</div>
<div className="stat-card">
<div className="stat-value">{team.member_count}</div>
<div className="stat-label">Members</div>
</div>
</div>
{isAdminOrOwner && (
<div className="team-actions">
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</Link>
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Members
</Link>
</div>
)}
{showProjectForm && ( {showProjectForm && (
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}> <div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}> <div className="modal-content" onClick={e => e.stopPropagation()}>
@@ -214,28 +203,65 @@ function TeamDashboardPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="projects-grid"> <DataTable
{projects?.items.map(project => ( data={projects?.items || []}
<div keyExtractor={(project) => project.id}
key={project.id} onRowClick={(project) => navigate(`/project/${project.name}`)}
className="project-card" columns={[
onClick={() => navigate(`/project/${project.name}`)} {
> key: 'name',
<div className="project-card-header"> header: 'Name',
<h3>{project.name}</h3> render: (project) => (
<Link
to={`/project/${project.name}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{project.name}
</Link>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (project) => project.description || <span className="text-muted"></span>,
},
{
key: 'visibility',
header: 'Visibility',
render: (project) => (
<Badge variant={project.is_public ? 'public' : 'private'}> <Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'} {project.is_public ? 'Public' : 'Private'}
</Badge> </Badge>
</div> ),
{project.description && ( },
<p className="project-card-description">{project.description}</p> {
)} key: 'created_by',
<div className="project-card-meta"> header: 'Created By',
<span>Created by {project.created_by}</span> render: (project) => <span className="text-muted">{project.created_by}</span>,
</div> },
</div> ...(isAdminOrOwner ? [{
))} key: 'actions',
</div> header: '',
render: (project: Project) => (
<button
className="btn btn-sm btn-ghost"
onClick={(e) => {
e.stopPropagation();
navigate(`/project/${project.name}/settings`);
}}
title="Settings"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
),
}] : []),
]}
/>
)} )}
{projects && projects.pagination.total > 10 && ( {projects && projects.pagination.total > 10 && (

View File

@@ -1,6 +1,7 @@
.team-members { .team-members {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 800px; max-width: 800px;
margin: 0 auto;
} }
.page-header { .page-header {
@@ -16,41 +17,18 @@
font-size: 1.75rem; font-size: 1.75rem;
} }
/* Members list */ /* Member cell in table */
.members-list { .member-cell {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.member-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
gap: 1rem;
}
.member-card.current-user {
background: var(--color-primary-bg);
border-color: var(--color-primary-border, var(--color-border));
}
.member-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
min-width: 0;
} }
.member-avatar { .member-avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -76,37 +54,34 @@
.you-badge { .you-badge {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: normal; font-weight: normal;
color: var(--color-text-muted); color: var(--text-muted);
} }
.member-email { .member-email {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.member-actions { .text-muted {
display: flex; color: var(--text-muted);
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
} }
.role-select { .role-select {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.875rem; font-size: 0.875rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
cursor: pointer; cursor: pointer;
} }
.role-select:focus { .role-select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
} }
/* Messages */ /* Messages */
@@ -116,10 +91,10 @@
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
border: 1px solid var(--color-error-border, #fecaca); border: 1px solid var(--error);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-error, #dc2626); color: var(--error);
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -146,7 +121,7 @@
.error-state p { .error-state p {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
/* Modal */ /* Modal */
@@ -156,7 +131,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -165,17 +140,19 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-lg);
} }
.modal-content h2 { .modal-content h2 {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-primary);
} }
/* Form */ /* Form */
@@ -188,24 +165,25 @@
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
} }
.form-group input, .form-group input,
.form-group select { .form-group select {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.9375rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .form-group select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg); box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
} }
.form-actions { .form-actions {
@@ -236,22 +214,22 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-hover);
} }
.btn-icon { .btn-icon {
@@ -260,10 +238,10 @@
.btn-danger-ghost { .btn-danger-ghost {
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--text-muted);
} }
.btn-danger-ghost:hover:not(:disabled) { .btn-danger-ghost:hover:not(:disabled) {
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
color: var(--color-error, #dc2626); color: var(--error);
} }

View File

@@ -11,6 +11,8 @@ import {
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import { UserAutocomplete } from '../components/UserAutocomplete';
import './TeamMembersPage.css'; import './TeamMembersPage.css';
function TeamMembersPage() { function TeamMembersPage() {
@@ -166,13 +168,10 @@ function TeamMembersPage() {
<form onSubmit={handleAddMember}> <form onSubmit={handleAddMember}>
<div className="form-group"> <div className="form-group">
<label htmlFor="username">Username</label> <label htmlFor="username">Username</label>
<input <UserAutocomplete
id="username"
type="text"
value={newMember.username} value={newMember.username}
onChange={e => setNewMember({ ...newMember, username: e.target.value })} onChange={(username) => setNewMember({ ...newMember, username })}
placeholder="Enter username" placeholder="Search for a user..."
required
autoFocus autoFocus
/> />
</div> </div>
@@ -203,34 +202,49 @@ function TeamMembersPage() {
</div> </div>
)} )}
<div className="members-list"> <DataTable
{members.map(member => { data={members}
const isCurrentUser = user?.username === member.username; keyExtractor={(member) => member.id}
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner'); emptyMessage="No members in this team yet."
columns={[
{
key: 'member',
header: 'Member',
render: (member) => {
const isCurrentUser = user?.username === member.username;
return (
<div className="member-cell">
<div className="member-avatar">
{member.username.charAt(0).toUpperCase()}
</div>
<div className="member-details">
<span className="member-username">
{member.username}
{isCurrentUser && <span className="you-badge">(you)</span>}
</span>
{member.email && (
<span className="member-email">{member.email}</span>
)}
</div>
</div>
);
},
},
{
key: 'role',
header: 'Role',
render: (member) => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
return ( if (canModify) {
<div key={member.id} className={`member-card ${isCurrentUser ? 'current-user' : ''}`}> return (
<div className="member-info">
<div className="member-avatar">
{member.username.charAt(0).toUpperCase()}
</div>
<div className="member-details">
<span className="member-username">
{member.username}
{isCurrentUser && <span className="you-badge">(you)</span>}
</span>
{member.email && (
<span className="member-email">{member.email}</span>
)}
</div>
</div>
<div className="member-actions">
{canModify ? (
<select <select
value={member.role} value={member.role}
onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)} onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)}
disabled={editingMember === member.username} disabled={editingMember === member.username}
className="role-select" className="role-select"
onClick={e => e.stopPropagation()}
> >
{roles.map(role => ( {roles.map(role => (
<option <option
@@ -242,30 +256,54 @@ function TeamMembersPage() {
</option> </option>
))} ))}
</select> </select>
) : ( );
<Badge variant={roleVariants[member.role] || 'default'}> }
{member.role} return (
</Badge> <Badge variant={roleVariants[member.role] || 'default'}>
)} {member.role}
{canModify && ( </Badge>
<button );
className="btn btn-icon btn-danger-ghost" },
onClick={() => handleRemoveMember(member.username)} },
disabled={removingMember === member.username} {
title="Remove member" key: 'joined',
> header: 'Joined',
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> render: (member) => (
<path d="M3 6h18"/> <span className="text-muted">
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/> {new Date(member.created_at).toLocaleDateString()}
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> </span>
</svg> ),
</button> },
)} ...(isAdmin ? [{
</div> key: 'actions',
</div> header: '',
); render: (member: TeamMember) => {
})} const isCurrentUser = user?.username === member.username;
</div> const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (!canModify) return null;
return (
<button
className="btn btn-icon btn-danger-ghost"
onClick={(e) => {
e.stopPropagation();
handleRemoveMember(member.username);
}}
disabled={removingMember === member.username}
title="Remove member"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
);
},
}] : []),
]}
/>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
.team-settings { .team-settings {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 640px; max-width: 640px;
margin: 0 auto;
} }
.team-settings h1 { .team-settings h1 {
@@ -13,8 +14,8 @@
} }
.form-section { .form-section {
background: var(--color-bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@@ -23,6 +24,7 @@
.form-section h2 { .form-section h2 {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 1.125rem; font-size: 1.125rem;
color: var(--text-primary);
} }
.form-group { .form-group {
@@ -34,29 +36,30 @@
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
} }
.form-group input, .form-group input,
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.9375rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg); box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
} }
.input-disabled { .input-disabled {
background: var(--color-bg-tertiary) !important; background: var(--bg-elevated) !important;
color: var(--color-text-muted) !important; color: var(--text-muted) !important;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -64,23 +67,23 @@
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
/* Danger zone */ /* Danger zone */
.danger-zone { .danger-zone {
border-color: var(--color-error-border, #fecaca); border-color: var(--error);
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
} }
.danger-zone h2 { .danger-zone h2 {
color: var(--color-error, #dc2626); color: var(--error);
} }
.danger-warning { .danger-warning {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--text-secondary);
} }
/* Messages */ /* Messages */
@@ -90,10 +93,10 @@
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
border: 1px solid var(--color-error-border, #fecaca); border: 1px solid var(--error);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-error, #dc2626); color: var(--error);
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -110,10 +113,10 @@
.success-message { .success-message {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-success-bg, #f0fdf4); background: var(--success-bg);
border: 1px solid var(--color-success-border, #86efac); border: 1px solid var(--success);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-success, #16a34a); color: var(--success);
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -130,7 +133,7 @@
.error-state p { .error-state p {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
/* Modal */ /* Modal */
@@ -140,7 +143,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -149,33 +152,36 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-lg);
} }
.modal-content h2 { .modal-content h2 {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--color-error, #dc2626); color: var(--error);
} }
.modal-content p { .modal-content p {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--text-secondary);
} }
.delete-confirm-input { .delete-confirm-input {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.9375rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--bg-tertiary);
color: var(--text-primary);
} }
.form-actions { .form-actions {
@@ -205,26 +211,26 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-hover);
} }
.btn-danger { .btn-danger {
background: var(--color-error, #dc2626); background: var(--error);
color: white; color: white;
} }

View File

@@ -1,102 +1,95 @@
.teams-page { .teams-page {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 1200px;
margin: 0 auto;
} }
.page-header { /* Header */
.teams-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
gap: 1rem; gap: 1rem;
} }
.page-header h1 { .teams-header h1 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.5rem;
font-weight: 600;
} }
.page-subtitle { /* Search */
margin: 0.25rem 0 0; .teams-search {
color: var(--color-text-muted); position: relative;
font-size: 0.9375rem; margin-bottom: 1.5rem;
} }
.team-name-cell { .teams-search__icon {
display: flex; position: absolute;
flex-direction: column; left: 0.875rem;
gap: 0.125rem; top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
} }
.team-name-link { .teams-search__input {
font-weight: 500; width: 100%;
color: var(--color-text); padding: 0.625rem 2.5rem 0.625rem 2.75rem;
text-decoration: none; border: 1px solid var(--border-primary);
} border-radius: var(--radius-md);
background: var(--bg-primary);
.team-name-link:hover { color: var(--text-primary);
color: var(--color-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.team-description {
color: var(--color-text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* Empty state */ .teams-search__input:focus {
.empty-state { outline: none;
text-align: center; border-color: var(--accent-primary);
padding: 4rem 2rem; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
} }
.empty-state svg { .teams-search__input::placeholder {
color: var(--color-text-muted); color: var(--text-muted);
margin-bottom: 1rem;
} }
.empty-state h2 { .teams-search__clear {
margin: 0 0 0.5rem; position: absolute;
font-size: 1.25rem; right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.375rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
} }
.empty-state p { .teams-search__clear:hover {
margin: 0 0 1.5rem; color: var(--text-primary);
color: var(--color-text-muted); background: var(--bg-secondary);
} }
/* Loading state */ /* Error */
.loading-state { .teams-error {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
/* Error message */
.error-message {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
border: 1px solid var(--color-error-border, #fecaca); border: 1px solid var(--error);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-error, #dc2626); color: var(--error);
font-size: 0.875rem; font-size: 0.875rem;
} }
.error-dismiss { .teams-error__dismiss {
background: none; background: none;
border: none; border: none;
font-size: 1.25rem; font-size: 1.25rem;
@@ -106,6 +99,88 @@
line-height: 1; line-height: 1;
} }
/* Loading */
.teams-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
color: var(--text-muted);
}
.teams-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: teams-spin 0.8s linear infinite;
}
@keyframes teams-spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.teams-empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.teams-empty-icon {
color: var(--text-muted);
margin-bottom: 1rem;
}
.teams-empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.teams-empty-state p {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
/* Table cell styles */
.team-name-cell {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.team-name-link {
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
}
.team-name-link:hover {
color: var(--accent-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--text-muted);
}
.team-description-cell {
color: var(--text-secondary);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted {
color: var(--text-muted);
}
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@@ -113,7 +188,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -122,17 +197,47 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-lg);
overflow: hidden;
} }
.modal-content h2 { .modal-header {
margin: 0 0 1.5rem; display: flex;
font-size: 1.25rem; justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-primary);
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.modal-content form {
padding: 1.5rem;
} }
/* Form */ /* Form */
@@ -145,31 +250,58 @@
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
}
.form-group .optional {
font-weight: 400;
color: var(--text-muted);
} }
.form-group input, .form-group input,
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.875rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.input-with-prefix {
display: flex;
align-items: stretch;
}
.input-prefix {
display: flex;
align-items: center;
padding: 0 0.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border-primary);
border-right: none;
border-radius: var(--radius-md) 0 0 var(--radius-md);
color: var(--text-muted);
font-size: 0.875rem;
}
.input-with-prefix input {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
} }
.form-hint { .form-hint {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.8125rem; font-size: 0.75rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.form-actions { .form-actions {
@@ -177,6 +309,8 @@
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
} }
/* Buttons */ /* Buttons */
@@ -199,20 +333,44 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-hover);
}
/* Responsive */
@media (max-width: 640px) {
.teams-header {
flex-direction: column;
align-items: stretch;
}
.teams-header .btn {
justify-content: center;
}
.teams-stats {
justify-content: space-around;
}
.teams-table-container {
overflow-x: auto;
}
.teams-table {
min-width: 600px;
}
} }

View File

@@ -17,6 +17,7 @@ function TeamsPage() {
const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' }); const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [slugManuallySet, setSlugManuallySet] = useState(false); const [slugManuallySet, setSlugManuallySet] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadTeams = useCallback(async () => { const loadTeams = useCallback(async () => {
try { try {
@@ -65,16 +66,39 @@ function TeamsPage() {
} }
} }
const roleVariants: Record<string, 'success' | 'info' | 'default'> = { const closeModal = () => {
owner: 'success', setShowForm(false);
admin: 'info', setNewTeam({ name: '', slug: '', description: '' });
member: 'default', setSlugManuallySet(false);
};
// Filter teams by search
const filteredTeams = teamsData?.items.filter(team =>
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
team.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
(team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
) || [];
const totalTeams = teamsData?.items.length || 0;
const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = {
owner: { variant: 'success', label: 'Owner' },
admin: { variant: 'info', label: 'Admin' },
member: { variant: 'default', label: 'Member' },
}; };
if (!user) { if (!user) {
return ( return (
<div className="teams-page"> <div className="teams-page">
<div className="empty-state"> <div className="teams-empty-state">
<div className="teams-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h2>Sign in to view your teams</h2> <h2>Sign in to view your teams</h2>
<p>Teams help you organize projects and collaborate with others.</p> <p>Teams help you organize projects and collaborate with others.</p>
<Link to="/login" className="btn btn-primary">Sign In</Link> <Link to="/login" className="btn btn-primary">Sign In</Link>
@@ -83,76 +107,65 @@ function TeamsPage() {
); );
} }
const columns = [
{
key: 'name',
header: 'Team',
render: (team: TeamDetail) => (
<div className="team-name-cell">
<Link to={`/teams/${team.slug}`} className="team-name-link">
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
render: (team: TeamDetail) => (
<span className="team-description">{team.description || '-'}</span>
),
},
{
key: 'role',
header: 'Your Role',
render: (team: TeamDetail) => (
team.user_role ? (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
) : null
),
},
{
key: 'members',
header: 'Members',
render: (team: TeamDetail) => team.member_count,
},
{
key: 'projects',
header: 'Projects',
render: (team: TeamDetail) => team.project_count,
},
];
return ( return (
<div className="teams-page"> <div className="teams-page">
<div className="page-header"> {/* Header */}
<div> <div className="teams-header">
<h1>Teams</h1> <h1>Teams</h1>
<p className="page-subtitle">Organize projects and collaborate with others</p>
</div>
<button className="btn btn-primary" onClick={() => setShowForm(true)}> <button className="btn btn-primary" onClick={() => setShowForm(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
New Team Create Team
</button> </button>
</div> </div>
{error && ( {/* Search */}
<div className="error-message"> {!loading && totalTeams > 3 && (
{error} <div className="teams-search">
<button onClick={() => setError(null)} className="error-dismiss">&times;</button> <svg className="teams-search__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="teams-search__input"
/>
{searchQuery && (
<button className="teams-search__clear" onClick={() => setSearchQuery('')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div> </div>
)} )}
{error && (
<div className="teams-error">
{error}
<button onClick={() => setError(null)} className="teams-error__dismiss">&times;</button>
</div>
)}
{/* Create Team Modal */}
{showForm && ( {showForm && (
<div className="modal-overlay" onClick={() => setShowForm(false)}> <div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={e => e.stopPropagation()}> <div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Create New Team</h2> <div className="modal-header">
<h2>Create New Team</h2>
<button className="modal-close" onClick={closeModal}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form onSubmit={handleCreateTeam}> <form onSubmit={handleCreateTeam}>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-name">Team Name</label> <label htmlFor="team-name">Team Name</label>
@@ -161,27 +174,30 @@ function TeamsPage() {
type="text" type="text"
value={newTeam.name} value={newTeam.name}
onChange={e => handleNameChange(e.target.value)} onChange={e => handleNameChange(e.target.value)}
placeholder="My Team" placeholder="Engineering"
required required
autoFocus autoFocus
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-slug">Slug</label> <label htmlFor="team-slug">URL Slug</label>
<input <div className="input-with-prefix">
id="team-slug" <span className="input-prefix">@</span>
type="text" <input
value={newTeam.slug} id="team-slug"
onChange={e => handleSlugChange(e.target.value)} type="text"
placeholder="my-team" value={newTeam.slug}
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$" onChange={e => handleSlugChange(e.target.value)}
title="Lowercase letters, numbers, and hyphens only" placeholder="engineering"
required pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
/> title="Lowercase letters, numbers, and hyphens only"
<span className="form-hint">Lowercase letters, numbers, and hyphens only</span> required
/>
</div>
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-description">Description (optional)</label> <label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
<textarea <textarea
id="team-description" id="team-description"
value={newTeam.description} value={newTeam.description}
@@ -191,7 +207,7 @@ function TeamsPage() {
/> />
</div> </div>
<div className="form-actions"> <div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}> <button type="button" className="btn btn-secondary" onClick={closeModal}>
Cancel Cancel
</button> </button>
<button type="submit" className="btn btn-primary" disabled={creating}> <button type="submit" className="btn btn-primary" disabled={creating}>
@@ -203,28 +219,88 @@ function TeamsPage() {
</div> </div>
)} )}
{/* Content */}
{loading ? ( {loading ? (
<div className="loading-state">Loading teams...</div> <div className="teams-loading">
) : teamsData?.items.length === 0 ? ( <div className="teams-loading__spinner" />
<div className="empty-state"> <span>Loading teams...</span>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> </div>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> ) : filteredTeams.length === 0 ? (
<circle cx="9" cy="7" r="4"/> <div className="teams-empty-state">
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <div className="teams-empty-icon">
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
</svg> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<h2>No teams yet</h2> <circle cx="9" cy="7" r="4"/>
<p>Create your first team to start organizing your projects.</p> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<button className="btn btn-primary" onClick={() => setShowForm(true)}> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
Create Team </svg>
</button> </div>
{searchQuery ? (
<>
<h2>No teams found</h2>
<p>No teams match "{searchQuery}"</p>
<button className="btn btn-secondary" onClick={() => setSearchQuery('')}>
Clear search
</button>
</>
) : (
<>
<h2>No teams yet</h2>
<p>Create your first team to start organizing your projects.</p>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
Create Team
</button>
</>
)}
</div> </div>
) : ( ) : (
<DataTable <DataTable
columns={columns} data={filteredTeams}
data={teamsData?.items || []} keyExtractor={(team) => team.id}
keyExtractor={team => team.id} onRowClick={(team) => navigate(`/teams/${team.slug}`)}
onRowClick={team => navigate(`/teams/${team.slug}`)} columns={[
{
key: 'name',
header: 'Name',
render: (team) => (
<div className="team-name-cell">
<Link
to={`/teams/${team.slug}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (team) => team.description || <span className="text-muted"></span>,
},
{
key: 'role',
header: 'Role',
render: (team) => team.user_role ? (
<Badge variant={roleConfig[team.user_role]?.variant || 'default'}>
{roleConfig[team.user_role]?.label || team.user_role}
</Badge>
) : null,
},
{
key: 'members',
header: 'Members',
render: (team) => <span className="text-muted">{team.member_count}</span>,
},
{
key: 'projects',
header: 'Projects',
render: (team) => <span className="text-muted">{team.project_count}</span>,
},
]}
/> />
)} )}
</div> </div>

View File

@@ -320,6 +320,8 @@ export interface UserUpdate {
} }
// Access Permission types // Access Permission types
export type AccessSource = 'explicit' | 'team';
export interface AccessPermission { export interface AccessPermission {
id: string; id: string;
project_id: string; project_id: string;
@@ -327,6 +329,9 @@ export interface AccessPermission {
level: AccessLevel; level: AccessLevel;
created_at: string; created_at: string;
expires_at: string | null; expires_at: string | null;
source?: AccessSource; // "explicit" or "team"
team_slug?: string; // Team slug if source is "team"
team_role?: string; // Team role if source is "team"
} }
export interface AccessPermissionCreate { export interface AccessPermissionCreate {