18 Commits

Author SHA1 Message Date
Mondo Diaz
9d57fd0700 Add teams migration to runtime migrations
The teams schema (teams table, team_memberships table, team_id column on
projects) was not included in the runtime migrations, causing existing
databases to fail on startup.

This adds the teams migration to _run_migrations() so it runs automatically
on application startup for databases that don't have the teams schema yet.
2026-01-28 20:02:49 +00:00
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
26 changed files with 1480 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)
- Updated seed data to create a "Demo Team" and assign all seed projects to it
- 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
- 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 `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)
@@ -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)
- Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies
- 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
### Changed

View File

@@ -11,7 +11,7 @@ from typing import Optional
from passlib.context import CryptContext
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
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.
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
user_count = db.query(User).count()
@@ -385,6 +387,27 @@ def create_default_admin(db: Session) -> Optional[User]:
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:
logger.info("Created default admin user with configured password")
else:

View File

@@ -285,6 +285,73 @@ def _run_migrations():
END IF;
END $$;
""",
# Teams and multi-tenancy migration (009_teams.sql)
"""
-- Create teams table
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255) NOT NULL,
settings JSONB DEFAULT '{}'::jsonb,
CONSTRAINT check_team_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' OR slug ~ '^[a-z0-9]$')
);
""",
"""
-- Create team_memberships table
CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
invited_by VARCHAR(255),
CONSTRAINT unique_team_membership UNIQUE (team_id, user_id),
CONSTRAINT check_team_role CHECK (role IN ('owner', 'admin', 'member'))
);
""",
# Add team_id column to projects table
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'projects' AND column_name = 'team_id'
) THEN
ALTER TABLE projects ADD COLUMN team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
END IF;
END $$;
""",
# Create indexes for teams
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_slug') THEN
CREATE INDEX idx_teams_slug ON teams(slug);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_by') THEN
CREATE INDEX idx_teams_created_by ON teams(created_by);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_at') THEN
CREATE INDEX idx_teams_created_at ON teams(created_at);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_team_id') THEN
CREATE INDEX idx_team_memberships_team_id ON team_memberships(team_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_user_id') THEN
CREATE INDEX idx_team_memberships_user_id ON team_memberships(user_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_role') THEN
CREATE INDEX idx_team_memberships_role ON team_memberships(role);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_projects_team_id') THEN
CREATE INDEX idx_projects_team_id ON projects(team_id);
END IF;
END $$;
""",
]
with engine.connect() as conn:

View File

@@ -1093,6 +1093,43 @@ def oidc_callback(
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 ---
@@ -1795,14 +1832,63 @@ def list_project_permissions(
):
"""
List all access permissions for a project.
Includes both explicit permissions and team-based access.
Requires admin access to the project.
"""
project = check_project_access(db, project_name, current_user, "admin")
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(

View File

@@ -911,6 +911,9 @@ class AccessPermissionResponse(BaseModel):
level: str
created_at: 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:
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 .storage import get_storage
from .auth import hash_password
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})")
# 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
project_map = {}
package_map = {}

View File

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

View File

@@ -668,3 +668,17 @@ export async function listTeamProjects(
});
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;
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>
<th>User</th>
<th>Access Level</th>
<th>Source</th>
<th>Granted</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={p.id}>
<td>{p.user_id}</td>
<td>
{editingUser === p.user_id ? (
<select
value={editLevel}
onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
disabled={submitting}
>
<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)}
{permissions.map((p) => {
const isTeamBased = p.source === 'team';
return (
<tr key={p.id} className={isTeamBased ? 'team-access-row' : ''}>
<td>{p.user_id}</td>
<td>
{editingUser === p.user_id && !isTeamBased ? (
<select
value={editLevel}
onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
disabled={submitting}
>
Save
</button>
<button
className="btn btn-sm"
onClick={cancelEdit}
<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>
{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}
>
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>
))}
min={new Date().toISOString().split('T')[0]}
/>
) : (
formatExpiration(p.expires_at)
)}
</td>
<td className="actions">
{isTeamBased ? (
<span className="text-muted" title="Manage access via team settings">
Via team
</span>
) : editingUser === p.user_id ? (
<>
<button
className="btn btn-sm btn-primary"
onClick={() => handleUpdate(p.user_id)}
disabled={submitting}
>
Save
</button>
<button
className="btn btn-sm"
onClick={cancelEdit}
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>
</table>
)}

View File

@@ -284,7 +284,11 @@
.footer-brand {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
}
.footer-icon {
color: var(--accent-primary);
}
.footer-logo {
@@ -292,6 +296,10 @@
color: var(--text-primary);
}
.footer-separator {
color: var(--text-muted);
}
.footer-tagline {
color: var(--text-secondary);
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 { useAuth } from '../contexts/AuthContext';
import { GlobalSearch } from './GlobalSearch';
import { listTeams } from '../api';
import { TeamDetail } from '../types';
import './Layout.css';
interface LayoutProps {
@@ -13,8 +15,22 @@ function Layout({ children }: LayoutProps) {
const navigate = useNavigate();
const { user, loading, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false);
const [userTeams, setUserTeams] = useState<TeamDetail[]>([]);
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
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@@ -77,15 +93,18 @@ function Layout({ children }: LayoutProps) {
</svg>
Dashboard
</Link>
{user && (
<Link to="/teams" className={location.pathname.startsWith('/teams') ? 'active' : ''}>
{user && userTeams.length > 0 && (
<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">
<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>
Teams
{userTeams.length === 1 ? 'Team' : 'Teams'}
</Link>
)}
<a href="/docs" className="nav-link-muted">
@@ -199,7 +218,17 @@ function Layout({ children }: LayoutProps) {
<footer className="footer">
<div className="container footer-content">
<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-separator">·</span>
<span className="footer-tagline">Content-Addressable Storage</span>
</div>
<div className="footer-links">

View File

@@ -7,10 +7,10 @@
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--color-text);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
@@ -18,8 +18,8 @@
}
.team-selector-trigger:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
background: var(--bg-tertiary);
border-color: var(--border-secondary);
}
.team-selector-trigger:disabled {
@@ -51,8 +51,8 @@
right: 0;
min-width: 240px;
margin-top: 0.25rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
@@ -62,7 +62,7 @@
.team-selector-empty {
padding: 1rem;
text-align: center;
color: var(--color-text-muted);
color: var(--text-muted);
}
.team-selector-empty p {
@@ -71,7 +71,7 @@
}
.team-selector-create-link {
color: var(--color-primary);
color: var(--accent-primary);
font-size: 0.875rem;
text-decoration: none;
}
@@ -96,7 +96,7 @@
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: var(--color-text);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
text-align: left;
@@ -104,11 +104,11 @@
}
.team-selector-item:hover {
background: var(--color-bg-secondary);
background: var(--bg-hover);
}
.team-selector-item.selected {
background: var(--color-primary-bg);
background: rgba(16, 185, 129, 0.1);
}
.team-selector-item-info {
@@ -127,7 +127,7 @@
.team-selector-item-meta {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--text-muted);
}
.team-selector-item-role {
@@ -140,24 +140,24 @@
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
border-top: 1px solid var(--border-primary);
background: var(--bg-tertiary);
}
.team-selector-link {
font-size: 0.8125rem;
color: var(--color-text-muted);
color: var(--text-muted);
text-decoration: none;
}
.team-selector-link:hover {
color: var(--color-text);
color: var(--text-primary);
}
.team-selector-link-primary {
color: var(--color-primary);
color: var(--accent-primary);
}
.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>
)}
<div className="list-controls">
<FilterDropdown
label="Visibility"
options={VISIBILITY_OPTIONS}
value={visibility}
onChange={handleVisibilityChange}
/>
</div>
{user && (
<div className="list-controls">
<FilterDropdown
label="Visibility"
options={VISIBILITY_OPTIONS}
value={visibility}
onChange={handleVisibilityChange}
/>
</div>
)}
{hasActiveFilters && (
{user && hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}>
{visibility && (
<FilterChip

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { getTeam, listTeamProjects, createProject } from '../api';
import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import './TeamDashboardPage.css';
function TeamDashboardPage() {
@@ -95,54 +96,42 @@ function TeamDashboardPage() {
/>
<div className="team-header">
<div className="team-header-info">
<h1>{team.name}</h1>
{team.user_role && (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
<div className="team-header-left">
<div className="team-header-title">
<h1>{team.name}</h1>
{team.user_role && (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
)}
<span className="team-slug">@{team.slug}</span>
</div>
{team.description && (
<p className="team-description">{team.description}</p>
)}
</div>
{team.description && (
<p className="team-description">{team.description}</p>
{isAdminOrOwner && (
<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 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 && (
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
@@ -214,28 +203,65 @@ function TeamDashboardPage() {
)}
</div>
) : (
<div className="projects-grid">
{projects?.items.map(project => (
<div
key={project.id}
className="project-card"
onClick={() => navigate(`/project/${project.name}`)}
>
<div className="project-card-header">
<h3>{project.name}</h3>
<DataTable
data={projects?.items || []}
keyExtractor={(project) => project.id}
onRowClick={(project) => navigate(`/project/${project.name}`)}
columns={[
{
key: 'name',
header: 'Name',
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'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
</div>
{project.description && (
<p className="project-card-description">{project.description}</p>
)}
<div className="project-card-meta">
<span>Created by {project.created_by}</span>
</div>
</div>
))}
</div>
),
},
{
key: 'created_by',
header: 'Created By',
render: (project) => <span className="text-muted">{project.created_by}</span>,
},
...(isAdminOrOwner ? [{
key: 'actions',
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 && (

View File

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

View File

@@ -11,6 +11,8 @@ import {
import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb';
import { DataTable } from '../components/DataTable';
import { UserAutocomplete } from '../components/UserAutocomplete';
import './TeamMembersPage.css';
function TeamMembersPage() {
@@ -166,13 +168,10 @@ function TeamMembersPage() {
<form onSubmit={handleAddMember}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
<UserAutocomplete
value={newMember.username}
onChange={e => setNewMember({ ...newMember, username: e.target.value })}
placeholder="Enter username"
required
onChange={(username) => setNewMember({ ...newMember, username })}
placeholder="Search for a user..."
autoFocus
/>
</div>
@@ -203,34 +202,49 @@ function TeamMembersPage() {
</div>
)}
<div className="members-list">
{members.map(member => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
<DataTable
data={members}
keyExtractor={(member) => member.id}
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 (
<div key={member.id} className={`member-card ${isCurrentUser ? 'current-user' : ''}`}>
<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 ? (
if (canModify) {
return (
<select
value={member.role}
onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)}
disabled={editingMember === member.username}
className="role-select"
onClick={e => e.stopPropagation()}
>
{roles.map(role => (
<option
@@ -242,30 +256,54 @@ function TeamMembersPage() {
</option>
))}
</select>
) : (
<Badge variant={roleVariants[member.role] || 'default'}>
{member.role}
</Badge>
)}
{canModify && (
<button
className="btn btn-icon btn-danger-ghost"
onClick={() => 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>
);
})}
</div>
);
}
return (
<Badge variant={roleVariants[member.role] || 'default'}>
{member.role}
</Badge>
);
},
},
{
key: 'joined',
header: 'Joined',
render: (member) => (
<span className="text-muted">
{new Date(member.created_at).toLocaleDateString()}
</span>
),
},
...(isAdmin ? [{
key: 'actions',
header: '',
render: (member: TeamMember) => {
const isCurrentUser = user?.username === member.username;
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>
);
}

View File

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

View File

@@ -1,102 +1,95 @@
.teams-page {
padding: 1.5rem 0;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
/* Header */
.teams-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.page-header h1 {
.teams-header h1 {
margin: 0;
font-size: 1.75rem;
font-size: 1.5rem;
font-weight: 600;
}
.page-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
font-size: 0.9375rem;
/* Search */
.teams-search {
position: relative;
margin-bottom: 1.5rem;
}
.team-name-cell {
display: flex;
flex-direction: column;
gap: 0.125rem;
.teams-search__icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.team-name-link {
font-weight: 500;
color: var(--color-text);
text-decoration: none;
}
.team-name-link:hover {
color: var(--color-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.team-description {
color: var(--color-text-secondary);
.teams-search__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 2.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
.teams-search__input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.empty-state svg {
color: var(--color-text-muted);
margin-bottom: 1rem;
.teams-search__input::placeholder {
color: var(--text-muted);
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
.teams-search__clear {
position: absolute;
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 {
margin: 0 0 1.5rem;
color: var(--color-text-muted);
.teams-search__clear:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Loading state */
.loading-state {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
/* Error message */
.error-message {
/* Error */
.teams-error {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2);
border: 1px solid var(--color-error-border, #fecaca);
background: var(--error-bg);
border: 1px solid var(--error);
border-radius: var(--radius-md);
color: var(--color-error, #dc2626);
color: var(--error);
font-size: 0.875rem;
}
.error-dismiss {
.teams-error__dismiss {
background: none;
border: none;
font-size: 1.25rem;
@@ -106,6 +99,88 @@
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-overlay {
position: fixed;
@@ -113,7 +188,7 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
@@ -122,17 +197,47 @@
}
.modal-content {
background: var(--color-bg);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px;
box-shadow: var(--shadow-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.modal-content h2 {
margin: 0 0 1.5rem;
font-size: 1.25rem;
.modal-header {
display: flex;
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 */
@@ -145,31 +250,58 @@
margin-bottom: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group .optional {
font-weight: 400;
color: var(--text-muted);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg);
border-color: var(--accent-primary);
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 {
display: block;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--color-text-muted);
font-size: 0.75rem;
color: var(--text-muted);
}
.form-actions {
@@ -177,6 +309,8 @@
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
}
/* Buttons */
@@ -199,20 +333,44 @@
}
.btn-primary {
background: var(--color-primary);
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
background: var(--accent-primary-hover);
}
.btn-secondary {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
.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 [creating, setCreating] = useState(false);
const [slugManuallySet, setSlugManuallySet] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadTeams = useCallback(async () => {
try {
@@ -65,16 +66,39 @@ function TeamsPage() {
}
}
const roleVariants: Record<string, 'success' | 'info' | 'default'> = {
owner: 'success',
admin: 'info',
member: 'default',
const closeModal = () => {
setShowForm(false);
setNewTeam({ name: '', slug: '', description: '' });
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) {
return (
<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>
<p>Teams help you organize projects and collaborate with others.</p>
<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 (
<div className="teams-page">
<div className="page-header">
<div>
<h1>Teams</h1>
<p className="page-subtitle">Organize projects and collaborate with others</p>
</div>
{/* Header */}
<div className="teams-header">
<h1>Teams</h1>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
<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="5" y1="12" x2="19" y2="12" />
</svg>
New Team
Create Team
</button>
</div>
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)} className="error-dismiss">&times;</button>
{/* Search */}
{!loading && totalTeams > 3 && (
<div className="teams-search">
<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>
)}
{error && (
<div className="teams-error">
{error}
<button onClick={() => setError(null)} className="teams-error__dismiss">&times;</button>
</div>
)}
{/* Create Team Modal */}
{showForm && (
<div className="modal-overlay" onClick={() => setShowForm(false)}>
<div className="modal-overlay" onClick={closeModal}>
<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}>
<div className="form-group">
<label htmlFor="team-name">Team Name</label>
@@ -161,27 +174,30 @@ function TeamsPage() {
type="text"
value={newTeam.name}
onChange={e => handleNameChange(e.target.value)}
placeholder="My Team"
placeholder="Engineering"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="team-slug">Slug</label>
<input
id="team-slug"
type="text"
value={newTeam.slug}
onChange={e => handleSlugChange(e.target.value)}
placeholder="my-team"
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
title="Lowercase letters, numbers, and hyphens only"
required
/>
<span className="form-hint">Lowercase letters, numbers, and hyphens only</span>
<label htmlFor="team-slug">URL Slug</label>
<div className="input-with-prefix">
<span className="input-prefix">@</span>
<input
id="team-slug"
type="text"
value={newTeam.slug}
onChange={e => handleSlugChange(e.target.value)}
placeholder="engineering"
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
title="Lowercase letters, numbers, and hyphens only"
required
/>
</div>
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
</div>
<div className="form-group">
<label htmlFor="team-description">Description (optional)</label>
<label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
<textarea
id="team-description"
value={newTeam.description}
@@ -191,7 +207,7 @@ function TeamsPage() {
/>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}>
<button type="button" className="btn btn-secondary" onClick={closeModal}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={creating}>
@@ -203,28 +219,88 @@ function TeamsPage() {
</div>
)}
{/* Content */}
{loading ? (
<div className="loading-state">Loading teams...</div>
) : teamsData?.items.length === 0 ? (
<div className="empty-state">
<svg width="48" height="48" 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>
<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 className="teams-loading">
<div className="teams-loading__spinner" />
<span>Loading teams...</span>
</div>
) : filteredTeams.length === 0 ? (
<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>
{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>
) : (
<DataTable
columns={columns}
data={teamsData?.items || []}
keyExtractor={team => team.id}
onRowClick={team => navigate(`/teams/${team.slug}`)}
data={filteredTeams}
keyExtractor={(team) => team.id}
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>

View File

@@ -320,6 +320,8 @@ export interface UserUpdate {
}
// Access Permission types
export type AccessSource = 'explicit' | 'team';
export interface AccessPermission {
id: string;
project_id: string;
@@ -327,6 +329,9 @@ export interface AccessPermission {
level: AccessLevel;
created_at: string;
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 {