diff --git a/CHANGELOG.md b/CHANGELOG.md index 8839669..7170cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) diff --git a/backend/app/routes.py b/backend/app/routes.py index 2c8eee4..337f5a5 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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 --- diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6260fa7..d8a0141 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -668,3 +668,17 @@ export async function listTeamProjects( }); return handleResponse>(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 { + const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, { + credentials: 'include', + }); + return handleResponse(response); +} diff --git a/frontend/src/components/UserAutocomplete.css b/frontend/src/components/UserAutocomplete.css new file mode 100644 index 0000000..42a7921 --- /dev/null +++ b/frontend/src/components/UserAutocomplete.css @@ -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(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.875rem; +} + +.user-autocomplete__input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1)); +} + +.user-autocomplete__spinner { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + border: 2px solid var(--color-border); + border-top-color: var(--color-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(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + 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(--color-bg-secondary); +} + +.user-autocomplete__avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-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(--color-text); +} + +.user-autocomplete__admin-badge { + font-size: 0.6875rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.025em; +} diff --git a/frontend/src/components/UserAutocomplete.tsx b/frontend/src/components/UserAutocomplete.tsx new file mode 100644 index 0000000..0b249d9 --- /dev/null +++ b/frontend/src/components/UserAutocomplete.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + const debounceRef = useRef>(); + + // 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) => { + 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 ( +
+
+ query.length >= 1 && results.length > 0 && setIsOpen(true)} + placeholder={placeholder} + disabled={disabled} + autoFocus={autoFocus} + autoComplete="off" + className="user-autocomplete__input" + /> + {loading && ( +
+ )} +
+ + {isOpen && results.length > 0 && ( +
    + {results.map((user, index) => ( +
  • handleSelect(user)} + onMouseEnter={() => setSelectedIndex(index)} + > +
    + {user.username.charAt(0).toUpperCase()} +
    +
    + {user.username} + {user.is_admin && ( + Admin + )} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/TeamMembersPage.tsx b/frontend/src/pages/TeamMembersPage.tsx index 88a8c39..e92cd42 100644 --- a/frontend/src/pages/TeamMembersPage.tsx +++ b/frontend/src/pages/TeamMembersPage.tsx @@ -11,6 +11,7 @@ import { import { useAuth } from '../contexts/AuthContext'; import { Badge } from '../components/Badge'; import { Breadcrumb } from '../components/Breadcrumb'; +import { UserAutocomplete } from '../components/UserAutocomplete'; import './TeamMembersPage.css'; function TeamMembersPage() { @@ -166,13 +167,10 @@ function TeamMembersPage() {
- setNewMember({ ...newMember, username: e.target.value })} - placeholder="Enter username" - required + onChange={(username) => setNewMember({ ...newMember, username })} + placeholder="Search for a user..." autoFocus />
diff --git a/frontend/src/pages/TeamsPage.css b/frontend/src/pages/TeamsPage.css index 393708e..f5f31fc 100644 --- a/frontend/src/pages/TeamsPage.css +++ b/frontend/src/pages/TeamsPage.css @@ -1,89 +1,117 @@ .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; - margin-bottom: 1.5rem; + margin-bottom: 2rem; gap: 1rem; } -.page-header h1 { +.teams-header__content h1 { margin: 0; font-size: 1.75rem; + font-weight: 600; } -.page-subtitle { +.teams-header__subtitle { margin: 0.25rem 0 0; color: var(--color-text-muted); font-size: 0.9375rem; } -.team-name-cell { +/* Stats */ +.teams-stats { display: flex; - flex-direction: column; - gap: 0.125rem; -} - -.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); - 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; + gap: 2rem; + padding: 1rem 1.5rem; background: var(--color-bg-secondary); border-radius: var(--radius-lg); + margin-bottom: 1.5rem; +} + +.teams-stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.teams-stat__value { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text); +} + +.teams-stat__label { + font-size: 0.75rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Search */ +.teams-search { + position: relative; + margin-bottom: 1.5rem; +} + +.teams-search__icon { + position: absolute; + left: 0.875rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-muted); + pointer-events: none; +} + +.teams-search__input { + width: 100%; + padding: 0.625rem 2.5rem 0.625rem 2.75rem; border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.875rem; } -.empty-state svg { - color: var(--color-text-muted); - margin-bottom: 1rem; +.teams-search__input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1)); } -.empty-state h2 { - margin: 0 0 0.5rem; - font-size: 1.25rem; -} - -.empty-state p { - margin: 0 0 1.5rem; +.teams-search__input::placeholder { color: var(--color-text-muted); } -/* Loading state */ -.loading-state { - text-align: center; - padding: 4rem 2rem; +.teams-search__clear { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + padding: 0.375rem; + cursor: pointer; color: var(--color-text-muted); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); } -/* Error message */ -.error-message { +.teams-search__clear:hover { + color: var(--color-text); + background: var(--color-bg-secondary); +} + +/* Error */ +.teams-error { display: flex; align-items: center; justify-content: space-between; @@ -96,7 +124,7 @@ font-size: 0.875rem; } -.error-dismiss { +.teams-error__dismiss { background: none; border: none; font-size: 1.25rem; @@ -106,6 +134,145 @@ line-height: 1; } +/* Loading */ +.teams-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 4rem 2rem; + color: var(--color-text-muted); +} + +.teams-loading__spinner { + width: 32px; + height: 32px; + border: 3px solid var(--color-border); + border-top-color: var(--color-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(--color-bg-secondary); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); +} + +.teams-empty-icon { + color: var(--color-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(--color-text-muted); +} + +/* Grid */ +.teams-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; +} + +/* Team Card */ +.team-card { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.25rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.team-card:hover { + border-color: var(--color-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.team-card__header { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.team-card__avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-hover, #2563eb)); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1.125rem; + flex-shrink: 0; +} + +.team-card__title { + flex: 1; + min-width: 0; +} + +.team-card__title h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.team-card__slug { + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.team-card__description { + font-size: 0.875rem; + color: var(--color-text-secondary); + margin: 0 0 1rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.team-card__footer { + display: flex; + gap: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border); +} + +.team-card__stat { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.team-card__stat svg { + opacity: 0.7; +} + /* Modal */ .modal-overlay { position: fixed; @@ -124,15 +291,43 @@ .modal-content { background: var(--color-bg); border-radius: var(--radius-lg); - padding: 1.5rem; width: 100%; max-width: 480px; box-shadow: var(--shadow-xl); + 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(--color-border); +} + +.modal-header h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: var(--color-text-muted); + display: flex; + border-radius: var(--radius-sm); +} + +.modal-close:hover { + color: var(--color-text); + background: var(--color-bg-secondary); +} + +.modal-content form { + padding: 1.5rem; } /* Form */ @@ -147,13 +342,18 @@ font-size: 0.875rem; } +.form-group .optional { + font-weight: 400; + color: var(--color-text-muted); +} + .form-group input, .form-group textarea { width: 100%; - padding: 0.5rem 0.75rem; + padding: 0.625rem 0.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-md); - font-size: 0.9375rem; + font-size: 0.875rem; background: var(--color-bg); color: var(--color-text); } @@ -162,13 +362,34 @@ .form-group textarea:focus { outline: none; border-color: var(--color-primary); - box-shadow: 0 0 0 2px var(--color-primary-bg); + box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1)); +} + +.input-with-prefix { + display: flex; + align-items: stretch; +} + +.input-prefix { + display: flex; + align-items: center; + padding: 0 0.75rem; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-right: none; + border-radius: var(--radius-md) 0 0 var(--radius-md); + color: var(--color-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; + font-size: 0.75rem; color: var(--color-text-muted); } @@ -177,6 +398,8 @@ justify-content: flex-end; gap: 0.75rem; margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); } /* Buttons */ @@ -216,3 +439,23 @@ .btn-secondary:hover:not(:disabled) { background: var(--color-bg-tertiary); } + +/* 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-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx index eba24a1..53a76a9 100644 --- a/frontend/src/pages/TeamsPage.tsx +++ b/frontend/src/pages/TeamsPage.tsx @@ -4,7 +4,6 @@ import { TeamDetail, TeamCreate, PaginatedResponse } from '../types'; import { listTeams, createTeam } from '../api'; import { useAuth } from '../contexts/AuthContext'; import { Badge } from '../components/Badge'; -import { DataTable } from '../components/DataTable'; import './TeamsPage.css'; function TeamsPage() { @@ -17,6 +16,7 @@ function TeamsPage() { const [newTeam, setNewTeam] = useState({ name: '', slug: '', description: '' }); const [creating, setCreating] = useState(false); const [slugManuallySet, setSlugManuallySet] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); const loadTeams = useCallback(async () => { try { @@ -65,16 +65,42 @@ function TeamsPage() { } } - const roleVariants: Record = { - 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())) + ) || []; + + // Stats + const totalTeams = teamsData?.items.length || 0; + const totalProjects = teamsData?.items.reduce((sum, t) => sum + t.project_count, 0) || 0; + const ownedTeams = teamsData?.items.filter(t => t.user_role === 'owner').length || 0; + + const roleConfig: Record = { + owner: { variant: 'success', label: 'Owner' }, + admin: { variant: 'info', label: 'Admin' }, + member: { variant: 'default', label: 'Member' }, }; if (!user) { return (
-
+
+
+ + + + + + +

Sign in to view your teams

Teams help you organize projects and collaborate with others.

Sign In @@ -83,76 +109,86 @@ function TeamsPage() { ); } - const columns = [ - { - key: 'name', - header: 'Team', - render: (team: TeamDetail) => ( -
- - {team.name} - - @{team.slug} -
- ), - }, - { - key: 'description', - header: 'Description', - render: (team: TeamDetail) => ( - {team.description || '-'} - ), - }, - { - key: 'role', - header: 'Your Role', - render: (team: TeamDetail) => ( - team.user_role ? ( - - {team.user_role} - - ) : null - ), - }, - { - key: 'members', - header: 'Members', - render: (team: TeamDetail) => team.member_count, - }, - { - key: 'projects', - header: 'Projects', - render: (team: TeamDetail) => team.project_count, - }, - ]; - return (
-
-
+ {/* Header */} +
+

Teams

-

Organize projects and collaborate with others

+

Organize projects and collaborate with your team

- {error && ( -
- {error} - + {/* Stats */} + {!loading && totalTeams > 0 && ( +
+
+ {totalTeams} + Teams +
+
+ {ownedTeams} + Owned +
+
+ {totalProjects} + Projects +
)} + {/* Search */} + {!loading && totalTeams > 3 && ( +
+ + + + + setSearchQuery(e.target.value)} + className="teams-search__input" + /> + {searchQuery && ( + + )} +
+ )} + + {error && ( +
+ {error} + +
+ )} + + {/* Create Team Modal */} {showForm && ( -
setShowForm(false)}> +
e.stopPropagation()}> -

Create New Team

+
+

Create New Team

+ +
@@ -161,27 +197,30 @@ function TeamsPage() { type="text" value={newTeam.name} onChange={e => handleNameChange(e.target.value)} - placeholder="My Team" + placeholder="Engineering" required autoFocus />
- - 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 - /> - Lowercase letters, numbers, and hyphens only + +
+ @ + 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 + /> +
+ Used in URLs. Lowercase letters, numbers, and hyphens.
- +