+
Orchard
+
·
Content-Addressable Storage
diff --git a/frontend/src/components/TeamSelector.css b/frontend/src/components/TeamSelector.css
new file mode 100644
index 0000000..05a8dc2
--- /dev/null
+++ b/frontend/src/components/TeamSelector.css
@@ -0,0 +1,163 @@
+.team-selector {
+ position: relative;
+}
+
+.team-selector-trigger {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.375rem 0.75rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ min-width: 160px;
+}
+
+.team-selector-trigger:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+ border-color: var(--border-secondary);
+}
+
+.team-selector-trigger:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.team-selector-name {
+ flex: 1;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.team-selector-chevron {
+ transition: transform 0.15s ease;
+ flex-shrink: 0;
+}
+
+.team-selector-chevron.open {
+ transform: rotate(180deg);
+}
+
+.team-selector-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ min-width: 240px;
+ margin-top: 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;
+ overflow: hidden;
+}
+
+.team-selector-empty {
+ padding: 1rem;
+ text-align: center;
+ color: var(--text-muted);
+}
+
+.team-selector-empty p {
+ margin: 0 0 0.75rem;
+ font-size: 0.875rem;
+}
+
+.team-selector-create-link {
+ color: var(--accent-primary);
+ font-size: 0.875rem;
+ text-decoration: none;
+}
+
+.team-selector-create-link:hover {
+ text-decoration: underline;
+}
+
+.team-selector-list {
+ list-style: none;
+ margin: 0;
+ padding: 0.25rem 0;
+ max-height: 280px;
+ overflow-y: auto;
+}
+
+.team-selector-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ background: none;
+ border: none;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.1s ease;
+}
+
+.team-selector-item:hover {
+ background: var(--bg-hover);
+}
+
+.team-selector-item.selected {
+ background: rgba(16, 185, 129, 0.1);
+}
+
+.team-selector-item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.team-selector-item-name {
+ display: block;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.team-selector-item-meta {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.team-selector-item-role {
+ font-size: 0.75rem;
+ text-transform: capitalize;
+ flex-shrink: 0;
+}
+
+.team-selector-footer {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ border-top: 1px solid var(--border-primary);
+ background: var(--bg-tertiary);
+}
+
+.team-selector-link {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ text-decoration: none;
+}
+
+.team-selector-link:hover {
+ color: var(--text-primary);
+}
+
+.team-selector-link-primary {
+ color: var(--accent-primary);
+}
+
+.team-selector-link-primary:hover {
+ color: var(--accent-primary-hover);
+}
diff --git a/frontend/src/components/TeamSelector.tsx b/frontend/src/components/TeamSelector.tsx
new file mode 100644
index 0000000..1ff641a
--- /dev/null
+++ b/frontend/src/components/TeamSelector.tsx
@@ -0,0 +1,141 @@
+import { useState, useRef, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { useTeam } from '../contexts/TeamContext';
+import { useAuth } from '../contexts/AuthContext';
+import { TeamDetail } from '../types';
+import './TeamSelector.css';
+
+export function TeamSelector() {
+ const { user } = useAuth();
+ const { teams, currentTeam, loading, setCurrentTeam } = useTeam();
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef
(null);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Don't show if not authenticated
+ if (!user) {
+ return null;
+ }
+
+ const handleTeamSelect = (team: TeamDetail) => {
+ setCurrentTeam(team);
+ setIsOpen(false);
+ };
+
+ const roleColors: Record = {
+ owner: 'var(--color-success)',
+ admin: 'var(--color-primary)',
+ member: 'var(--color-text-muted)',
+ };
+
+ return (
+
+
+
+ {isOpen && (
+
+ {teams.length === 0 ? (
+
+
You're not a member of any teams yet.
+
setIsOpen(false)}
+ >
+ Create your first team
+
+
+ ) : (
+ <>
+
+ {teams.map(team => (
+ -
+
+
+ ))}
+
+
+ setIsOpen(false)}
+ >
+ View all teams
+
+ setIsOpen(false)}
+ >
+ + New Team
+
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/UserAutocomplete.css b/frontend/src/components/UserAutocomplete.css
new file mode 100644
index 0000000..e334794
--- /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(--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;
+}
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 && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/contexts/TeamContext.tsx b/frontend/src/contexts/TeamContext.tsx
new file mode 100644
index 0000000..ac6297b
--- /dev/null
+++ b/frontend/src/contexts/TeamContext.tsx
@@ -0,0 +1,110 @@
+import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
+import { TeamDetail } from '../types';
+import { listTeams } from '../api';
+import { useAuth } from './AuthContext';
+
+const SELECTED_TEAM_KEY = 'orchard_selected_team';
+
+interface TeamContextType {
+ teams: TeamDetail[];
+ currentTeam: TeamDetail | null;
+ loading: boolean;
+ error: string | null;
+ setCurrentTeam: (team: TeamDetail | null) => void;
+ refreshTeams: () => Promise;
+ clearError: () => void;
+}
+
+const TeamContext = createContext(undefined);
+
+interface TeamProviderProps {
+ children: ReactNode;
+}
+
+export function TeamProvider({ children }: TeamProviderProps) {
+ const { user } = useAuth();
+ const [teams, setTeams] = useState([]);
+ const [currentTeam, setCurrentTeamState] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadTeams = useCallback(async () => {
+ if (!user) {
+ setTeams([]);
+ setCurrentTeamState(null);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listTeams({ limit: 100 });
+ setTeams(response.items);
+
+ // Try to restore previously selected team
+ const savedSlug = localStorage.getItem(SELECTED_TEAM_KEY);
+ if (savedSlug) {
+ const savedTeam = response.items.find(t => t.slug === savedSlug);
+ if (savedTeam) {
+ setCurrentTeamState(savedTeam);
+ return;
+ }
+ }
+
+ // Auto-select first team if none selected
+ if (response.items.length > 0 && !currentTeam) {
+ setCurrentTeamState(response.items[0]);
+ localStorage.setItem(SELECTED_TEAM_KEY, response.items[0].slug);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to load teams';
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ }, [user, currentTeam]);
+
+ // Load teams when user changes
+ useEffect(() => {
+ loadTeams();
+ }, [user]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const setCurrentTeam = useCallback((team: TeamDetail | null) => {
+ setCurrentTeamState(team);
+ if (team) {
+ localStorage.setItem(SELECTED_TEAM_KEY, team.slug);
+ } else {
+ localStorage.removeItem(SELECTED_TEAM_KEY);
+ }
+ }, []);
+
+ const refreshTeams = useCallback(async () => {
+ await loadTeams();
+ }, [loadTeams]);
+
+ const clearError = useCallback(() => {
+ setError(null);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTeam() {
+ const context = useContext(TeamContext);
+ if (context === undefined) {
+ throw new Error('useTeam must be used within a TeamProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index fb6aeab..7b3792b 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -179,16 +179,18 @@ function Home() {
)}
-
-
-
+ {user && (
+
+
+
+ )}
- {hasActiveFilters && (
+ {user && hasActiveFilters && (
{visibility && (
- {canAdmin && (
+ {canAdmin && !project.team_id && (
- {/* Access Management Section */}
-
-
{/* Danger Zone Section */}
Danger Zone
diff --git a/frontend/src/pages/TeamDashboardPage.css b/frontend/src/pages/TeamDashboardPage.css
new file mode 100644
index 0000000..84115e1
--- /dev/null
+++ b/frontend/src/pages/TeamDashboardPage.css
@@ -0,0 +1,270 @@
+.team-dashboard {
+ padding: 1.5rem 0;
+}
+
+.team-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.team-header-left {
+ flex: 1;
+}
+
+.team-header-title {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.5rem;
+}
+
+.team-header h1 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.team-slug {
+ font-size: 0.875rem;
+ color: var(--text-muted);
+}
+
+.team-description {
+ margin: 0 0 0.5rem;
+ color: var(--text-secondary);
+ font-size: 0.9375rem;
+ max-width: 600px;
+}
+
+.team-header-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.team-section {
+ margin-top: 2rem;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.section-header h2 {
+ margin: 0;
+ font-size: 1.25rem;
+}
+
+/* Table utility classes */
+.text-muted {
+ color: var(--text-muted);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-muted);
+ border: none;
+ padding: 0.375rem;
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+}
+
+.btn-ghost:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.section-footer {
+ margin-top: 1rem;
+ text-align: center;
+}
+
+.view-all-link {
+ font-size: 0.875rem;
+ color: var(--accent-primary);
+ text-decoration: none;
+}
+
+.view-all-link:hover {
+ text-decoration: underline;
+}
+
+/* States */
+.loading-state,
+.error-state {
+ text-align: center;
+ padding: 4rem 2rem;
+}
+
+.error-state h2 {
+ margin: 0 0 0.5rem;
+}
+
+.error-state p {
+ margin: 0 0 1.5rem;
+ color: var(--text-muted);
+}
+
+.empty-state {
+ text-align: center;
+ padding: 2rem;
+ background: var(--bg-secondary);
+ border: 1px dashed var(--border-primary);
+ border-radius: var(--radius-md);
+ color: var(--text-muted);
+}
+
+.empty-state p {
+ margin: 0;
+}
+
+.empty-hint {
+ margin-top: 0.5rem !important;
+ font-size: 0.875rem;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ transition: all 0.15s ease;
+}
+
+.btn-sm {
+ padding: 0.375rem 0.75rem;
+ font-size: 0.8125rem;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-primary-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-hover);
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.modal-content {
+ 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 */
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ 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(--border-primary);
+ border-radius: var(--radius-md);
+ 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(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
+}
+
+.form-group textarea {
+ resize: vertical;
+ min-height: 80px;
+}
+
+.checkbox-group label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+}
+
+.checkbox-group input[type="checkbox"] {
+ width: 1rem;
+ height: 1rem;
+}
+
+.form-hint {
+ display: block;
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ margin-top: 0.375rem;
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ margin-top: 1.5rem;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.empty-state .btn {
+ margin-top: 1rem;
+}
diff --git a/frontend/src/pages/TeamDashboardPage.tsx b/frontend/src/pages/TeamDashboardPage.tsx
new file mode 100644
index 0000000..00acbce
--- /dev/null
+++ b/frontend/src/pages/TeamDashboardPage.tsx
@@ -0,0 +1,279 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Link, useParams, useNavigate } from 'react-router-dom';
+import { TeamDetail, Project, PaginatedResponse } from '../types';
+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() {
+ const { slug } = useParams<{ slug: string }>();
+ const navigate = useNavigate();
+ const { user } = useAuth();
+ const [team, setTeam] = useState
(null);
+ const [projects, setProjects] = useState | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showProjectForm, setShowProjectForm] = useState(false);
+ const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true });
+ const [creating, setCreating] = useState(false);
+
+ const loadTeamData = useCallback(async () => {
+ if (!slug) return;
+ try {
+ setLoading(true);
+ const [teamData, projectsData] = await Promise.all([
+ getTeam(slug),
+ listTeamProjects(slug, { limit: 10 }),
+ ]);
+ setTeam(teamData);
+ setProjects(projectsData);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load team');
+ } finally {
+ setLoading(false);
+ }
+ }, [slug]);
+
+ useEffect(() => {
+ loadTeamData();
+ }, [loadTeamData]);
+
+ async function handleCreateProject(e: React.FormEvent) {
+ e.preventDefault();
+ if (!team) return;
+ try {
+ setCreating(true);
+ const project = await createProject({ ...newProject, team_id: team.id });
+ setNewProject({ name: '', description: '', is_public: true });
+ setShowProjectForm(false);
+ navigate(`/project/${project.name}`);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create project');
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error || !team) {
+ return (
+
+
+
Error loading team
+
{error || 'Team not found'}
+
Back to Teams
+
+
+ );
+ }
+
+ const isAdminOrOwner = team.user_role === 'owner' || team.user_role === 'admin' || user?.is_admin;
+
+ const roleVariants: Record = {
+ owner: 'success',
+ admin: 'info',
+ member: 'default',
+ };
+
+ return (
+
+
+
+
+
+
+
{team.name}
+ {team.user_role && (
+
+ {team.user_role}
+
+ )}
+ @{team.slug}
+
+ {team.description && (
+
{team.description}
+ )}
+
+ {isAdminOrOwner && (
+
+
+
+ Members
+
+
+
+ Settings
+
+
+ )}
+
+
+ {showProjectForm && (
+
setShowProjectForm(false)}>
+
e.stopPropagation()}>
+
Create New Project
+
+
+
+ )}
+
+
+
+
Projects
+ {isAdminOrOwner && (
+
+ )}
+
+
+ {projects?.items.length === 0 ? (
+
+
No projects in this team yet.
+ {isAdminOrOwner && (
+
+ )}
+
+ ) : (
+
project.id}
+ onRowClick={(project) => navigate(`/project/${project.name}`)}
+ columns={[
+ {
+ key: 'name',
+ header: 'Name',
+ render: (project) => (
+ e.stopPropagation()}
+ >
+ {project.name}
+
+ ),
+ },
+ {
+ key: 'description',
+ header: 'Description',
+ className: 'cell-description',
+ render: (project) => project.description || —,
+ },
+ {
+ key: 'visibility',
+ header: 'Visibility',
+ render: (project) => (
+
+ {project.is_public ? 'Public' : 'Private'}
+
+ ),
+ },
+ {
+ key: 'created_by',
+ header: 'Created By',
+ render: (project) => {project.created_by},
+ },
+ ...(isAdminOrOwner ? [{
+ key: 'actions',
+ header: '',
+ render: (project: Project) => (
+
+ ),
+ }] : []),
+ ]}
+ />
+ )}
+
+ {projects && projects.pagination.total > 10 && (
+
+
+ View all {projects.pagination.total} projects
+
+
+ )}
+
+
+ );
+}
+
+export default TeamDashboardPage;
diff --git a/frontend/src/pages/TeamMembersPage.css b/frontend/src/pages/TeamMembersPage.css
new file mode 100644
index 0000000..db5d659
--- /dev/null
+++ b/frontend/src/pages/TeamMembersPage.css
@@ -0,0 +1,247 @@
+.team-members {
+ padding: 1.5rem 0;
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ gap: 1rem;
+}
+
+.page-header h1 {
+ margin: 0;
+ font-size: 1.75rem;
+}
+
+/* Member cell in table */
+.member-cell {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.member-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--accent-primary);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.member-details {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.member-username {
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.you-badge {
+ font-size: 0.75rem;
+ font-weight: normal;
+ color: var(--text-muted);
+}
+
+.member-email {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.text-muted {
+ color: var(--text-muted);
+}
+
+.role-select {
+ padding: 0.375rem 0.75rem;
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ cursor: pointer;
+}
+
+.role-select:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+}
+
+/* Messages */
+.error-message {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ background: var(--error-bg);
+ border: 1px solid var(--error);
+ border-radius: var(--radius-md);
+ color: var(--error);
+ font-size: 0.875rem;
+}
+
+.error-dismiss {
+ background: none;
+ border: none;
+ font-size: 1.25rem;
+ cursor: pointer;
+ color: inherit;
+ padding: 0;
+ line-height: 1;
+}
+
+/* States */
+.loading-state,
+.error-state {
+ text-align: center;
+ padding: 4rem 2rem;
+}
+
+.error-state h2 {
+ margin: 0 0 0.5rem;
+}
+
+.error-state p {
+ margin: 0 0 1.5rem;
+ color: var(--text-muted);
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.modal-content {
+ 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-lg);
+}
+
+.modal-content h2 {
+ margin: 0 0 1.5rem;
+ font-size: 1.25rem;
+ color: var(--text-primary);
+}
+
+/* Form */
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ 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(--border-primary);
+ border-radius: var(--radius-md);
+ font-size: 0.9375rem;
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ margin-top: 1.5rem;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ transition: all 0.15s ease;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-primary-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--bg-hover);
+}
+
+.btn-icon {
+ padding: 0.375rem;
+}
+
+.btn-danger-ghost {
+ background: transparent;
+ color: var(--text-muted);
+}
+
+.btn-danger-ghost:hover:not(:disabled) {
+ background: var(--error-bg);
+ color: var(--error);
+}
diff --git a/frontend/src/pages/TeamMembersPage.tsx b/frontend/src/pages/TeamMembersPage.tsx
new file mode 100644
index 0000000..a03e880
--- /dev/null
+++ b/frontend/src/pages/TeamMembersPage.tsx
@@ -0,0 +1,311 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import { TeamDetail, TeamMember, TeamMemberCreate, TeamRole } from '../types';
+import {
+ getTeam,
+ listTeamMembers,
+ addTeamMember,
+ updateTeamMember,
+ removeTeamMember,
+} from '../api';
+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() {
+ const { slug } = useParams<{ slug: string }>();
+ const { user } = useAuth();
+ const [team, setTeam] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [adding, setAdding] = useState(false);
+ const [newMember, setNewMember] = useState({ username: '', role: 'member' });
+ const [editingMember, setEditingMember] = useState(null);
+ const [removingMember, setRemovingMember] = useState(null);
+
+ const loadData = useCallback(async () => {
+ if (!slug) return;
+ try {
+ setLoading(true);
+ const [teamData, membersData] = await Promise.all([
+ getTeam(slug),
+ listTeamMembers(slug),
+ ]);
+ setTeam(teamData);
+ setMembers(membersData);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load team');
+ } finally {
+ setLoading(false);
+ }
+ }, [slug]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ async function handleAddMember(e: React.FormEvent) {
+ e.preventDefault();
+ if (!slug) return;
+ try {
+ setAdding(true);
+ setError(null);
+ await addTeamMember(slug, newMember);
+ setNewMember({ username: '', role: 'member' });
+ setShowAddForm(false);
+ loadData();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to add member');
+ } finally {
+ setAdding(false);
+ }
+ }
+
+ async function handleRoleChange(username: string, newRole: TeamRole) {
+ if (!slug) return;
+ try {
+ setEditingMember(username);
+ setError(null);
+ await updateTeamMember(slug, username, { role: newRole });
+ loadData();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to update member');
+ } finally {
+ setEditingMember(null);
+ }
+ }
+
+ async function handleRemoveMember(username: string) {
+ if (!slug) return;
+ if (!confirm(`Remove ${username} from the team?`)) return;
+ try {
+ setRemovingMember(username);
+ setError(null);
+ await removeTeamMember(slug, username);
+ loadData();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to remove member');
+ } finally {
+ setRemovingMember(null);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
Loading team members...
+
+ );
+ }
+
+ if (error && !team) {
+ return (
+
+
+
Error loading team
+
{error}
+
Back to Teams
+
+
+ );
+ }
+
+ if (!team) return null;
+
+ const isOwner = team.user_role === 'owner' || user?.is_admin;
+ const isAdmin = team.user_role === 'admin' || isOwner;
+
+ const roleVariants: Record = {
+ owner: 'success',
+ admin: 'info',
+ member: 'default',
+ };
+
+ const roles: TeamRole[] = ['owner', 'admin', 'member'];
+
+ return (
+
+
+
+
+
Team Members
+ {isAdmin && (
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {showAddForm && (
+
setShowAddForm(false)}>
+
e.stopPropagation()}>
+
Invite Member
+
+
+
+ )}
+
+
member.id}
+ emptyMessage="No members in this team yet."
+ columns={[
+ {
+ key: 'member',
+ header: 'Member',
+ render: (member) => {
+ const isCurrentUser = user?.username === member.username;
+ return (
+
+
+ {member.username.charAt(0).toUpperCase()}
+
+
+
+ {member.username}
+ {isCurrentUser && (you)}
+
+ {member.email && (
+ {member.email}
+ )}
+
+
+ );
+ },
+ },
+ {
+ key: 'role',
+ header: 'Role',
+ render: (member) => {
+ const isCurrentUser = user?.username === member.username;
+ const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
+
+ if (canModify) {
+ return (
+
+ );
+ }
+ return (
+
+ {member.role}
+
+ );
+ },
+ },
+ {
+ key: 'joined',
+ header: 'Joined',
+ render: (member) => (
+
+ {new Date(member.created_at).toLocaleDateString()}
+
+ ),
+ },
+ ...(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 (
+
+ );
+ },
+ }] : []),
+ ]}
+ />
+
+ );
+}
+
+export default TeamMembersPage;
diff --git a/frontend/src/pages/TeamSettingsPage.css b/frontend/src/pages/TeamSettingsPage.css
new file mode 100644
index 0000000..9c1909b
--- /dev/null
+++ b/frontend/src/pages/TeamSettingsPage.css
@@ -0,0 +1,239 @@
+.team-settings {
+ padding: 1.5rem 0;
+ max-width: 640px;
+ margin: 0 auto;
+}
+
+.team-settings h1 {
+ margin: 0 0 1.5rem;
+ font-size: 1.75rem;
+}
+
+.settings-form {
+ margin-bottom: 2rem;
+}
+
+.form-section {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.form-section h2 {
+ margin: 0 0 1rem;
+ font-size: 1.125rem;
+ color: var(--text-primary);
+}
+
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ 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(--border-primary);
+ border-radius: var(--radius-md);
+ font-size: 0.9375rem;
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.form-group input:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
+}
+
+.input-disabled {
+ background: var(--bg-elevated) !important;
+ color: var(--text-muted) !important;
+ cursor: not-allowed;
+}
+
+.form-hint {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+}
+
+/* Danger zone */
+.danger-zone {
+ border-color: var(--error);
+ background: var(--error-bg);
+}
+
+.danger-zone h2 {
+ color: var(--error);
+}
+
+.danger-warning {
+ margin: 0 0 1rem;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+/* Messages */
+.error-message {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ background: var(--error-bg);
+ border: 1px solid var(--error);
+ border-radius: var(--radius-md);
+ color: var(--error);
+ font-size: 0.875rem;
+}
+
+.error-dismiss {
+ background: none;
+ border: none;
+ font-size: 1.25rem;
+ cursor: pointer;
+ color: inherit;
+ padding: 0;
+ line-height: 1;
+}
+
+.success-message {
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ background: var(--success-bg);
+ border: 1px solid var(--success);
+ border-radius: var(--radius-md);
+ color: var(--success);
+ font-size: 0.875rem;
+}
+
+/* States */
+.loading-state,
+.error-state {
+ text-align: center;
+ padding: 4rem 2rem;
+}
+
+.error-state h2 {
+ margin: 0 0 0.5rem;
+}
+
+.error-state p {
+ margin: 0 0 1.5rem;
+ color: var(--text-muted);
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.modal-content {
+ 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-lg);
+}
+
+.modal-content h2 {
+ margin: 0 0 1rem;
+ font-size: 1.25rem;
+ color: var(--error);
+}
+
+.modal-content p {
+ margin: 0 0 1rem;
+ font-size: 0.9375rem;
+ color: var(--text-secondary);
+}
+
+.delete-confirm-input {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ 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 {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ transition: all 0.15s ease;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-primary-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--bg-hover);
+}
+
+.btn-danger {
+ background: var(--error);
+ color: white;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: #b91c1c;
+}
diff --git a/frontend/src/pages/TeamSettingsPage.tsx b/frontend/src/pages/TeamSettingsPage.tsx
new file mode 100644
index 0000000..ad70525
--- /dev/null
+++ b/frontend/src/pages/TeamSettingsPage.tsx
@@ -0,0 +1,251 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import { TeamDetail, TeamUpdate } from '../types';
+import { getTeam, updateTeam, deleteTeam } from '../api';
+import { useAuth } from '../contexts/AuthContext';
+import { Breadcrumb } from '../components/Breadcrumb';
+import './TeamSettingsPage.css';
+
+function TeamSettingsPage() {
+ const { slug } = useParams<{ slug: string }>();
+ const navigate = useNavigate();
+ const { user } = useAuth();
+ const [team, setTeam] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [error, setError] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [deleteConfirmText, setDeleteConfirmText] = useState('');
+
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ });
+
+ const loadTeam = useCallback(async () => {
+ if (!slug) return;
+ try {
+ setLoading(true);
+ const teamData = await getTeam(slug);
+ setTeam(teamData);
+ setFormData({
+ name: teamData.name,
+ description: teamData.description || '',
+ });
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load team');
+ } finally {
+ setLoading(false);
+ }
+ }, [slug]);
+
+ useEffect(() => {
+ loadTeam();
+ }, [loadTeam]);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!slug || !team) return;
+
+ try {
+ setSaving(true);
+ setError(null);
+ const updatedTeam = await updateTeam(slug, formData);
+ setTeam(updatedTeam);
+ setSuccessMessage('Settings saved successfully');
+ setTimeout(() => setSuccessMessage(null), 3000);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save settings');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete() {
+ if (!slug || !team) return;
+ if (deleteConfirmText !== team.slug) return;
+
+ try {
+ setDeleting(true);
+ await deleteTeam(slug);
+ navigate('/teams');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to delete team');
+ setShowDeleteConfirm(false);
+ } finally {
+ setDeleting(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
Loading team settings...
+
+ );
+ }
+
+ if (error && !team) {
+ return (
+
+
+
Error loading team
+
{error}
+
Back to Teams
+
+
+ );
+ }
+
+ if (!team) return null;
+
+ const isOwner = team.user_role === 'owner' || user?.is_admin;
+ const isAdmin = team.user_role === 'admin' || isOwner;
+
+ if (!isAdmin) {
+ return (
+
+
+
Access Denied
+
You need admin privileges to access team settings.
+
Back to Team
+
+
+ );
+ }
+
+ return (
+
+
+
+
Team Settings
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+
+
+ {isOwner && (
+
+
Danger Zone
+
+ Deleting a team is permanent and cannot be undone.
+ You must move or delete all projects in this team first.
+
+
+
+ )}
+
+ {showDeleteConfirm && (
+
setShowDeleteConfirm(false)}>
+
e.stopPropagation()}>
+
Delete Team
+
+ This will permanently delete the team {team.name}.
+ This action cannot be undone.
+
+
+ To confirm, type {team.slug} below:
+
+
setDeleteConfirmText(e.target.value)}
+ placeholder={team.slug}
+ className="delete-confirm-input"
+ />
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+export default TeamSettingsPage;
diff --git a/frontend/src/pages/TeamsPage.css b/frontend/src/pages/TeamsPage.css
new file mode 100644
index 0000000..ed0659e
--- /dev/null
+++ b/frontend/src/pages/TeamsPage.css
@@ -0,0 +1,376 @@
+.teams-page {
+ padding: 1.5rem 0;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+/* Header */
+.teams-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ gap: 1rem;
+}
+
+.teams-header h1 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+/* Search */
+.teams-search {
+ position: relative;
+ margin-bottom: 1.5rem;
+}
+
+.teams-search__icon {
+ position: absolute;
+ left: 0.875rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+.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;
+}
+
+.teams-search__input:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
+}
+
+.teams-search__input::placeholder {
+ color: var(--text-muted);
+}
+
+.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);
+}
+
+.teams-search__clear:hover {
+ color: var(--text-primary);
+ background: var(--bg-secondary);
+}
+
+/* Error */
+.teams-error {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ background: var(--error-bg);
+ border: 1px solid var(--error);
+ border-radius: var(--radius-md);
+ color: var(--error);
+ font-size: 0.875rem;
+}
+
+.teams-error__dismiss {
+ background: none;
+ border: none;
+ font-size: 1.25rem;
+ cursor: pointer;
+ color: inherit;
+ padding: 0;
+ 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;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.modal-content {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ width: 100%;
+ max-width: 480px;
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+}
+
+.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 */
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ 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.625rem 0.75rem;
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-md);
+ 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(--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.75rem;
+ color: var(--text-muted);
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ margin-top: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border-primary);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-primary-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ 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;
+ }
+}
diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx
new file mode 100644
index 0000000..9dc8651
--- /dev/null
+++ b/frontend/src/pages/TeamsPage.tsx
@@ -0,0 +1,310 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+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() {
+ const navigate = useNavigate();
+ const { user } = useAuth();
+ const [teamsData, setTeamsData] = useState | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showForm, setShowForm] = useState(false);
+ 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 {
+ setLoading(true);
+ const data = await listTeams({ limit: 100 });
+ setTeamsData(data);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load teams');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadTeams();
+ }, [loadTeams]);
+
+ // Auto-generate slug from name
+ const handleNameChange = (name: string) => {
+ setNewTeam(prev => ({
+ ...prev,
+ name,
+ slug: slugManuallySet ? prev.slug : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
+ }));
+ };
+
+ const handleSlugChange = (slug: string) => {
+ setSlugManuallySet(true);
+ setNewTeam(prev => ({ ...prev, slug }));
+ };
+
+ async function handleCreateTeam(e: React.FormEvent) {
+ e.preventDefault();
+ try {
+ setCreating(true);
+ const team = await createTeam(newTeam);
+ setNewTeam({ name: '', slug: '', description: '' });
+ setSlugManuallySet(false);
+ setShowForm(false);
+ navigate(`/teams/${team.slug}`);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create team');
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ 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 = {
+ 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
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
Teams
+
+
+
+ {/* Search */}
+ {!loading && totalTeams > 3 && (
+
+
+ setSearchQuery(e.target.value)}
+ className="teams-search__input"
+ />
+ {searchQuery && (
+
+ )}
+
+ )}
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {/* Create Team Modal */}
+ {showForm && (
+
+
e.stopPropagation()}>
+
+
Create New Team
+
+
+
+
+
+ )}
+
+ {/* Content */}
+ {loading ? (
+
+ ) : filteredTeams.length === 0 ? (
+
+
+ {searchQuery ? (
+ <>
+
No teams found
+
No teams match "{searchQuery}"
+
+ >
+ ) : (
+ <>
+
No teams yet
+
Create your first team to start organizing your projects.
+
+ >
+ )}
+
+ ) : (
+
team.id}
+ onRowClick={(team) => navigate(`/teams/${team.slug}`)}
+ columns={[
+ {
+ key: 'name',
+ header: 'Name',
+ render: (team) => (
+
+ e.stopPropagation()}
+ >
+ {team.name}
+
+ @{team.slug}
+
+ ),
+ },
+ {
+ key: 'description',
+ header: 'Description',
+ className: 'cell-description',
+ render: (team) => team.description || —,
+ },
+ {
+ key: 'role',
+ header: 'Role',
+ render: (team) => team.user_role ? (
+
+ {roleConfig[team.user_role]?.label || team.user_role}
+
+ ) : null,
+ },
+ {
+ key: 'members',
+ header: 'Members',
+ render: (team) => {team.member_count},
+ },
+ {
+ key: 'projects',
+ header: 'Projects',
+ render: (team) => {team.project_count},
+ },
+ ]}
+ />
+ )}
+
+ );
+}
+
+export default TeamsPage;
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index b6ed663..eb9306c 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -12,6 +12,10 @@ export interface Project {
// Access level info (populated when listing projects)
access_level?: AccessLevel | null;
is_owner?: boolean;
+ // Team info
+ team_id?: string | null;
+ team_slug?: string | null;
+ team_name?: string | null;
}
export interface TagSummary {
@@ -316,6 +320,8 @@ export interface UserUpdate {
}
// Access Permission types
+export type AccessSource = 'explicit' | 'team';
+
export interface AccessPermission {
id: string;
project_id: string;
@@ -323,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 {
@@ -447,3 +456,50 @@ export interface DependencyResolutionError {
}>;
}>;
}
+
+// Team types
+export type TeamRole = 'owner' | 'admin' | 'member';
+
+export interface Team {
+ id: string;
+ name: string;
+ slug: string;
+ description: string | null;
+ created_at: string;
+ updated_at: string;
+ member_count: number;
+ project_count: number;
+}
+
+export interface TeamDetail extends Team {
+ user_role: TeamRole | null;
+}
+
+export interface TeamMember {
+ id: string;
+ user_id: string;
+ username: string;
+ email: string | null;
+ role: TeamRole;
+ created_at: string;
+}
+
+export interface TeamCreate {
+ name: string;
+ slug: string;
+ description?: string;
+}
+
+export interface TeamUpdate {
+ name?: string;
+ description?: string;
+}
+
+export interface TeamMemberCreate {
+ username: string;
+ role: TeamRole;
+}
+
+export interface TeamMemberUpdate {
+ role: TeamRole;
+}
diff --git a/migrations/009_teams.sql b/migrations/009_teams.sql
new file mode 100644
index 0000000..f8fac17
--- /dev/null
+++ b/migrations/009_teams.sql
@@ -0,0 +1,62 @@
+-- Migration 009: Teams and Multi-Tenancy
+-- Adds support for team-based multi-tenancy
+-- Part of Multi-Tenancy with Teams feature
+
+-- 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,
+
+ -- Slug must be lowercase alphanumeric with hyphens
+ 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),
+
+ -- Each user can only be a member of a team once
+ CONSTRAINT unique_team_membership UNIQUE (team_id, user_id),
+
+ -- Role must be one of: owner, admin, member
+ CONSTRAINT check_team_role CHECK (role IN ('owner', 'admin', 'member'))
+);
+
+-- Add team_id column to projects table (nullable for migration compatibility)
+ALTER TABLE projects ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
+
+-- Indexes for teams table
+CREATE INDEX IF NOT EXISTS idx_teams_slug ON teams(slug);
+CREATE INDEX IF NOT EXISTS idx_teams_created_by ON teams(created_by);
+CREATE INDEX IF NOT EXISTS idx_teams_created_at ON teams(created_at);
+
+-- Indexes for team_memberships table
+CREATE INDEX IF NOT EXISTS idx_team_memberships_team_id ON team_memberships(team_id);
+CREATE INDEX IF NOT EXISTS idx_team_memberships_user_id ON team_memberships(user_id);
+CREATE INDEX IF NOT EXISTS idx_team_memberships_role ON team_memberships(role);
+CREATE INDEX IF NOT EXISTS idx_team_memberships_team_role ON team_memberships(team_id, role);
+
+-- Index for projects team_id
+CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects(team_id);
+
+-- Comments
+COMMENT ON TABLE teams IS 'Teams serve as organizational containers for projects';
+COMMENT ON COLUMN teams.slug IS 'URL-friendly unique identifier (lowercase alphanumeric with hyphens)';
+COMMENT ON COLUMN teams.settings IS 'JSON object for team-specific settings';
+
+COMMENT ON TABLE team_memberships IS 'Maps users to teams with their roles';
+COMMENT ON COLUMN team_memberships.role IS 'User role in the team: owner, admin, or member';
+COMMENT ON COLUMN team_memberships.invited_by IS 'Username of the user who invited this member';
+
+COMMENT ON COLUMN projects.team_id IS 'Optional team that owns this project';
diff --git a/migrations/009b_migrate_projects.sql b/migrations/009b_migrate_projects.sql
new file mode 100644
index 0000000..425b8ea
--- /dev/null
+++ b/migrations/009b_migrate_projects.sql
@@ -0,0 +1,99 @@
+-- Migration 009b: Migrate Existing Projects to Personal Teams
+-- Creates personal teams for existing users and assigns their projects to those teams.
+-- This migration is idempotent and can be run multiple times safely.
+
+-- Create personal teams for users who own projects but don't have a personal team yet
+INSERT INTO teams (name, slug, description, created_by, settings)
+SELECT DISTINCT
+ u.username || '''s Team' AS name,
+ LOWER(u.username) || '-personal' AS slug,
+ 'Personal team for ' || u.username AS description,
+ u.username AS created_by,
+ '{"personal": true}'::jsonb AS settings
+FROM users u
+JOIN projects p ON p.created_by = u.username
+WHERE NOT EXISTS (
+ SELECT 1 FROM teams t
+ WHERE t.slug = LOWER(u.username) || '-personal'
+)
+AND p.team_id IS NULL
+ON CONFLICT (slug) DO NOTHING;
+
+-- Add users as owners of their personal teams
+INSERT INTO team_memberships (team_id, user_id, role, invited_by)
+SELECT
+ t.id AS team_id,
+ u.id AS user_id,
+ 'owner' AS role,
+ u.username AS invited_by
+FROM teams t
+JOIN users u ON t.created_by = u.username
+WHERE t.slug LIKE '%-personal'
+AND NOT EXISTS (
+ SELECT 1 FROM team_memberships tm
+ WHERE tm.team_id = t.id
+ AND tm.user_id = u.id
+)
+ON CONFLICT DO NOTHING;
+
+-- Assign projects without a team to their creator's personal team
+UPDATE projects p
+SET team_id = t.id
+FROM teams t
+WHERE t.slug = LOWER(p.created_by) || '-personal'
+AND p.team_id IS NULL;
+
+-- Handle orphaned projects (created_by doesn't match any user)
+-- Create a special orphaned projects team if there are any
+DO $$
+DECLARE
+ orphan_count INTEGER;
+ orphan_team_id UUID;
+BEGIN
+ -- Count orphaned projects
+ SELECT COUNT(*) INTO orphan_count
+ FROM projects p
+ WHERE p.team_id IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM users u WHERE u.username = p.created_by
+ );
+
+ IF orphan_count > 0 THEN
+ -- Create or get the orphaned projects team
+ INSERT INTO teams (name, slug, description, created_by, settings)
+ VALUES (
+ 'Orphaned Projects',
+ 'orphaned-projects',
+ 'Projects whose original creators no longer exist',
+ 'system',
+ '{"system": true}'::jsonb
+ )
+ ON CONFLICT (slug) DO UPDATE SET name = teams.name
+ RETURNING id INTO orphan_team_id;
+
+ -- Assign orphaned projects to this team
+ UPDATE projects
+ SET team_id = orphan_team_id
+ WHERE team_id IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM users u WHERE u.username = projects.created_by
+ );
+
+ RAISE NOTICE 'Migrated % orphaned project(s) to orphaned-projects team', orphan_count;
+ END IF;
+END $$;
+
+-- Log migration results
+DO $$
+DECLARE
+ teams_created INTEGER;
+ memberships_created INTEGER;
+ projects_migrated INTEGER;
+BEGIN
+ SELECT COUNT(*) INTO teams_created FROM teams WHERE slug LIKE '%-personal';
+ SELECT COUNT(*) INTO memberships_created FROM team_memberships;
+ SELECT COUNT(*) INTO projects_migrated FROM projects WHERE team_id IS NOT NULL;
+
+ RAISE NOTICE 'Migration complete: % personal teams, % memberships, % projects with teams',
+ teams_created, memberships_created, projects_migrated;
+END $$;