Compare commits

..

6 Commits

9 changed files with 800 additions and 260 deletions
+1
View File
@@ -127,6 +127,7 @@ export interface Repo {
docker_url?: string
is_foreign?: boolean
attachable?: boolean
created_at?: number
owner_project_name?: string
owner_project?: string
owner_slug?: string
+4 -4
View File
@@ -38,7 +38,7 @@ import DashboardCustomizeIcon from '@mui/icons-material/DashboardCustomize'
import StorageIcon from '@mui/icons-material/Storage'
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
import CalculateIcon from '@mui/icons-material/Calculate'
import PeopleIcon from '@mui/icons-material/People'
import PersonIcon from '@mui/icons-material/Person'
import KeyIcon from '@mui/icons-material/Key'
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
import BadgeIcon from '@mui/icons-material/Badge'
@@ -48,7 +48,7 @@ import VpnKeyIcon from '@mui/icons-material/VpnKey'
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined'
import HistoryIcon from '@mui/icons-material/History'
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'
import GroupWorkIcon from '@mui/icons-material/GroupWork'
import PeopleIcon from '@mui/icons-material/People'
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
import PaletteIcon from '@mui/icons-material/Palette'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
@@ -147,8 +147,8 @@ export default function Layout() {
if (isAdmin) adminItems.push({ kind: 'divider', key })
}
if (user) {
pushAdmin('users.manage', { label: 'Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> })
pushAdmin('groups.manage', { label: 'User Groups', path: '/admin/user-groups', icon: <GroupWorkIcon fontSize="small" /> })
pushAdmin('users.manage', { label: 'Users', path: '/admin/users', icon: <PersonIcon fontSize="small" /> })
pushAdmin('groups.manage', { label: 'User Groups', path: '/admin/user-groups', icon: <PeopleIcon fontSize="small" /> })
pushAdmin(undefined, { label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
pushAdmin('sessions.manage', { label: 'Active Web Sessions', path: '/admin/sessions', icon: <DevicesIcon fontSize="small" /> })
pushAdmin('gpg.manage', { label: 'Admin GPG Keys', path: '/admin/gpg-keys', icon: <VpnKeyIcon fontSize="small" /> })
+20
View File
@@ -0,0 +1,20 @@
import { Chip, Tooltip } from '@mui/material'
import { formatDuration, formatEpochTime } from '../time'
// A compact "age since creation" chip (e.g. 2y, 3mo, 5d) with the exact creation
// date in its tooltip. Shared right-side signal for project/repo/board rows.
// Renders nothing when no creation timestamp is available.
export default function AgeChip({ createdAt }: { createdAt?: number }) {
if (!createdAt) return null
return (
<Tooltip title={`Created ${formatEpochTime(createdAt)}`}>
<Chip
label={formatDuration(Math.max(1, Math.floor(Date.now() / 1000) - createdAt))}
size="small"
variant="outlined"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
sx={{ height: 20, cursor: 'inherit', '& .MuiChip-label': { fontSize: 11, px: 0.75 } }}
/>
</Tooltip>
)
}
@@ -0,0 +1,58 @@
import ViewKanbanOutlinedIcon from '@mui/icons-material/ViewKanbanOutlined'
import { Box, List, ListItemButton, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, Board } from '../api'
import AgeChip from './AgeChip'
import SectionCard from './SectionCard'
// A lightweight, Monitoring-style row list of a single project's boards, in a
// collapsible SectionCard (matching the overview cards).
export default function ProjectBoardList({ projectId }: { projectId: string }) {
const navigate = useNavigate()
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
api.listBoards(projectId)
.then((list) => { if (!cancelled) setBoards(Array.isArray(list) ? list : []) })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load boards') })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [projectId])
return (
<SectionCard collapsible title={<Typography variant="h6">Boards ({boards.length})</Typography>}>
{error ? (
<Typography variant="body2" color="error">{error}</Typography>
) : loading ? (
<Typography variant="body2" color="text.secondary">Loading boards</Typography>
) : boards.length === 0 ? (
<Typography variant="body2" color="text.secondary">No boards in this project.</Typography>
) : (
<List dense disablePadding>
{boards.map((b) => (
<ListItemButton
key={b.id}
onClick={() => navigate(`/projects/${projectId}/boards/${b.id}`)}
sx={{ borderRadius: 1, display: 'grid', gridTemplateColumns: '20px minmax(0, 1fr) auto', columnGap: 1.25, alignItems: 'center', py: 0.75 }}
>
<ViewKanbanOutlinedIcon fontSize="small" sx={{ color: 'text.disabled' }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" noWrap>{b.title}</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
{b.description || (b.created_by_name ? `by ${b.created_by_name}` : '—')}
</Typography>
</Box>
<AgeChip createdAt={b.created_at} />
</ListItemButton>
))}
</List>
)}
</SectionCard>
)
}
@@ -0,0 +1,61 @@
import { Box, List, ListItemButton, Tooltip, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, Repo } from '../api'
import AgeChip from './AgeChip'
import SectionCard from './SectionCard'
// Repo type → swatch color (the color carries the type, like the monitor state dot).
const TYPE_COLORS: Record<string, string> = { git: '#2196f3', rpm: '#ff9800', docker: '#4caf50' }
// A lightweight, Monitoring-style row list of a single project's repositories, in
// a collapsible SectionCard (matching the overview cards). Deliberately not the
// global RepositoriesSection table — rows, not a data grid.
export default function ProjectRepoList({ projectId }: { projectId: string }) {
const navigate = useNavigate()
const [repos, setRepos] = useState<Repo[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
api.listRepos(projectId)
.then((list) => { if (!cancelled) setRepos(Array.isArray(list) ? list : []) })
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load repositories') })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [projectId])
return (
<SectionCard collapsible title={<Typography variant="h6">Repositories ({repos.length})</Typography>}>
{error ? (
<Typography variant="body2" color="error">{error}</Typography>
) : loading ? (
<Typography variant="body2" color="text.secondary">Loading repositories</Typography>
) : repos.length === 0 ? (
<Typography variant="body2" color="text.secondary">No repositories in this project.</Typography>
) : (
<List dense disablePadding>
{repos.map((r) => (
<ListItemButton
key={r.id}
onClick={() => navigate(`/projects/${projectId}/repos/${r.id}`)}
sx={{ borderRadius: 1, display: 'grid', gridTemplateColumns: '10px minmax(0, 1fr) auto', columnGap: 1.25, alignItems: 'center', py: 0.75 }}
>
<Tooltip title={r.type || 'git'}>
<Box sx={{ width: 10, height: 10, borderRadius: '2px', bgcolor: TYPE_COLORS[r.type || 'git'] ?? '#9e9e9e' }} />
</Tooltip>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" noWrap>{r.name}</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', fontFamily: 'monospace' }}>{r.id}</Typography>
</Box>
<AgeChip createdAt={r.created_at} />
</ListItemButton>
))}
</List>
)}
</SectionCard>
)
}
+20 -2
View File
@@ -17,9 +17,23 @@ type SplitPanelProps = {
direction?: SplitDirection
defaultRatio?: number
collapsible?: boolean
// When set, the drag ratio is persisted to localStorage under this key and
// restored on mount (pass a branding-scoped key, e.g. storageKey('...')).
storageKey?: string
sx?: BoxProps['sx']
}
// readStoredRatio restores a persisted split ratio, clamped to the drag bounds,
// falling back to defaultRatio when absent or invalid.
function readStoredRatio(storageKey: string | undefined, fallback: number): number {
if (!storageKey || typeof window === 'undefined') return fallback
const raw: string | null = window.localStorage.getItem(storageKey)
if (raw === null) return fallback
const parsed: number = Number(raw)
if (!Number.isFinite(parsed)) return fallback
return Math.max(0.1, Math.min(0.9, parsed))
}
// 'left'/'right' name the two slots regardless of direction (left = top slot in
// column mode).
type CollapsedSide = 'left' | 'right' | null
@@ -37,8 +51,8 @@ const collapseButtonSx = {
'&:hover': { bgcolor: 'background.paper' },
} as const
export default function SplitPanel({ left, right, direction = 'row', defaultRatio = 0.3, collapsible = true, sx }: SplitPanelProps) {
const [splitRatio, setSplitRatio] = useState<number>(defaultRatio)
export default function SplitPanel({ left, right, direction = 'row', defaultRatio = 0.3, collapsible = true, storageKey, sx }: SplitPanelProps) {
const [splitRatio, setSplitRatio] = useState<number>(() => readStoredRatio(storageKey, defaultRatio))
const [splitDragging, setSplitDragging] = useState<boolean>(false)
const [collapsedSide, setCollapsedSide] = useState<CollapsedSide>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
@@ -67,6 +81,10 @@ export default function SplitPanel({ left, right, direction = 'row', defaultRati
}
const handlePointerUp = (): void => {
// Persist only after an actual drag (this fires on pointer up/leave too).
if (splitDragging && storageKey && typeof window !== 'undefined') {
window.localStorage.setItem(storageKey, String(splitRatio))
}
setSplitDragging(false)
}
+2 -1
View File
@@ -222,7 +222,8 @@ export default function UtilityTray(props: UtilityTrayProps) {
flexDirection: fill ? 'column' : undefined,
flex: 1,
minHeight: 0,
overflow: fill ? 'hidden' : 'auto'
overflow: fill ? 'hidden' : 'auto',
pt: 1
}}
>
<Widget widgetId={`tray-${item.type}`} />
+216 -238
View File
@@ -1,7 +1,9 @@
import AddIcon from '@mui/icons-material/Add'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
import EditIcon from '@mui/icons-material/Edit'
import CompactListItemText from '../components/CompactListItemText'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import RefreshIcon from '@mui/icons-material/Refresh'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import Autocomplete from '../components/Autocomplete'
import {
@@ -9,14 +11,18 @@ import {
Button,
Checkbox,
Chip,
Divider,
IconButton,
List,
ListItem,
ListItemButton,
TextField,
Tooltip,
Typography
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { storageKey } from '../branding'
import AgeChip from '../components/AgeChip'
import HeaderActionButton from '../components/HeaderActionButton'
import SectionCard from '../components/SectionCard'
import SplitPanel from '../components/SplitPanel'
import TintedPanel from '../components/TintedPanel'
@@ -34,6 +40,25 @@ function fmt(value: number): string {
return formatEpochTime(value)
}
// Colored-rail summary/info tiles, shared visual family with the Projects page.
function StatTile(props: { label: string; count: number; color: string }) {
return (
<Box sx={{ p: 1.5, borderRadius: 1, border: 1, borderColor: 'divider', borderLeft: '4px solid', borderLeftColor: props.color, minWidth: 0 }}>
<Typography variant="h5" sx={{ lineHeight: 1.1 }}>{props.count}</Typography>
<Typography variant="caption" color="text.secondary">{props.label}</Typography>
</Box>
)
}
function InfoTile(props: { label: string; value: ReactNode; color: string; title?: string }) {
return (
<Box sx={{ p: 1.5, borderRadius: 1, border: 1, borderColor: 'divider', borderLeft: '4px solid', borderLeftColor: props.color, minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{props.label}</Typography>
<Typography variant="body2" noWrap title={props.title} sx={{ minWidth: 0 }}>{props.value}</Typography>
</Box>
)
}
function defaultUserGroupForm(): UserGroupFormState {
return {
name: '',
@@ -79,6 +104,32 @@ export default function AdminUserGroupsPage() {
return groups.find((item) => item.id === selectedGroupID) || null
}, [groups, selectedGroupID])
const activeCount = useMemo(() => groups.filter((g) => !g.disabled).length, [groups])
const disabledCount = useMemo(() => groups.filter((g) => g.disabled).length, [groups])
const recentGroups = useMemo(
() => [...groups].sort((a, b) => (b.updated_at || b.created_at || 0) - (a.updated_at || a.created_at || 0)).slice(0, 6),
[groups],
)
const groupRow = (group: UserGroup) => (
<ListItemButton
key={group.id}
selected={group.id === selectedGroupID}
onClick={() => setSelectedGroupID((cur) => (cur === group.id ? '' : group.id))}
sx={{ borderRadius: 1, display: 'grid', gridTemplateColumns: '20px minmax(0, 1fr) auto', columnGap: 1, alignItems: 'center', py: 0.75 }}
>
<PeopleOutlinedIcon fontSize="small" sx={{ color: group.id === selectedGroupID ? 'primary.main' : 'text.disabled' }} />
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
<Typography variant="body2" noWrap sx={{ fontWeight: group.id === selectedGroupID ? 600 : 400 }}>{group.name}</Typography>
{group.disabled ? <Chip size="small" color="error" label="Disabled" sx={{ height: 18, '& .MuiChip-label': { fontSize: 10, px: 0.5 } }} /> : null}
</Box>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{group.id}</Typography>
</Box>
<AgeChip createdAt={group.created_at} />
</ListItemButton>
)
// Group the permission checkboxes by their catalog domain, preserving catalog
// order, so the (now sizeable) list reads as sections rather than a flat wall.
const optionsByDomain = useMemo(() => {
@@ -131,9 +182,7 @@ export default function AdminUserGroupsPage() {
domain: entry.domain
}))
)
if (!selectedGroupID && Array.isArray(list) && list.length > 0) {
setSelectedGroupID(list[0].id)
}
// Open on the overview (no selection) rather than auto-selecting a group.
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load groups')
setGroups([])
@@ -308,25 +357,6 @@ export default function AdminUserGroupsPage() {
// Compact grant summary for the left list: a count (with the full list on
// hover) rather than enumerating every permission key, which doesn't scale as
// the catalog grows. The authoritative per-permission view is the detail panel.
const groupPermsSummary = (groupID: string) => {
const perms: string[] = groupOptionPermissions
.filter((item) => hasGroupOption(groupID, item.permission))
.map((item) => item.chip)
if (perms.length === 0) {
return null
}
return (
<>
{' · '}
<Tooltip title={perms.join(', ')}>
<Box component="span" sx={{ textDecoration: 'underline dotted' }}>
{perms.length} permission{perms.length === 1 ? '' : 's'}
</Box>
</Tooltip>
</>
)
}
const hasGroupOption = (groupID: string, permission: string) => {
return subjectPermissions.some((item: SubjectPermission) => item.permission === permission && item.subject_id === groupID)
}
@@ -385,187 +415,127 @@ export default function AdminUserGroupsPage() {
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 76px)', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: User Groups</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate} disabled={!canManageGroups}>New Group</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
<HeaderActionButton variant="outlined" startIcon={<RefreshIcon />} onClick={() => { void loadGroups() }}>Refresh</HeaderActionButton>
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate} disabled={!canManageGroups}>New Group</HeaderActionButton>
</Box>
</Box>
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
<SplitPanel
defaultRatio={0.38}
defaultRatio={0.34}
storageKey={storageKey('admin-user-groups:split')}
sx={{ flex: 1, minHeight: 0 }}
left={(
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto' }}>
<TextField
size="small"
label="Search"
placeholder="Name, id, or description"
value={groupQuery}
onChange={(event) => setGroupQuery(event.target.value)}
fullWidth
sx={{ mb: 1 }}
/>
{loading ? <Typography variant="body2" color="text.secondary">Loading groups...</Typography> : null}
{!loading && groups.length === 0 ? <Typography variant="body2" color="text.secondary">No groups.</Typography> : null}
{!loading && groups.length > 0 && filteredGroups.length === 0 ? (
<Typography variant="body2" color="text.secondary">No matching groups.</Typography>
) : null}
<List>
{filteredGroups.map((group) => (
<ListItem
key={group.id}
divider
selected={group.id === selectedGroupID}
onClick={() => setSelectedGroupID(group.id)}
sx={{ alignItems: 'flex-start', gap: 1, cursor: 'pointer' }}
>
<CompactListItemText
primary={
<Typography component="div">
{group.name}
{group.disabled? (<> <Chip size="small" label="Disabled" color="error" sx={{ mt: 0.25, flexShrink: 0 }} /></>): null}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'normal', wordBreak: 'break-all' }}>
{group.id} · updated: {fmt(group.updated_at)}{group.totp_required ? ' · TOTP required' : ''}{groupPermsSummary(group.id)}
</Typography>
}
/>
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.25, flexShrink: 0 }}>
<Tooltip title="Edit group">
<IconButton
size="small"
aria-label={`Edit ${group.name}`}
onClick={(event) => {
event.stopPropagation()
openEdit(group)
}}
disabled={!canManageGroups}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete group">
<span>
<IconButton
size="small"
color="error"
aria-label={`Delete ${group.name}`}
onClick={(event) => {
event.stopPropagation()
setDeleteItem(group)
setDeleteConfirm('')
}}
disabled={!canManageGroups}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
</ListItem>
))}
</List>
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', display: 'flex', flexDirection: 'column', p: 1 }}>
<TextField
size="small"
label="Search"
placeholder="Name, id, or description"
value={groupQuery}
onChange={(event) => setGroupQuery(event.target.value)}
fullWidth
sx={{ mb: 1, mt: 1 }}
/>
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{loading ? <Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>Loading groups...</Typography> : null}
{!loading && groups.length === 0 ? <Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>No groups.</Typography> : null}
{!loading && groups.length > 0 && filteredGroups.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>No matching groups.</Typography>
) : null}
<List dense disablePadding>
{filteredGroups.map(groupRow)}
</List>
</Box>
</TintedPanel>
)}
right={(
<Box sx={{ display: 'grid', gap: 1, minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto', alignContent: 'start' }}>
<SectionCard title="Selected Group">
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto', p: 1.5, ml: 1 }}>
{selectedGroup ? (
<Box sx={{ display: 'grid', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h6" sx={{ minWidth: 0 }}>
{selectedGroup.name}
</Typography>
<Typography component="div">
<Chip
size="small"
color={selectedGroup.disabled ? 'error' : 'success'}
label={selectedGroup.disabled ? 'Disabled' : 'Active'}
/>
{groupOptionPermissions.map((item: { permission: string, label: string, chip: string }) => (
hasGroupOption(selectedGroup.id, item.permission) ? (
<Chip key={item.permission} size="small" variant="outlined" label={item.chip} sx={{ mr: 0.2, ml: 0.2 }} />
) : null
))}
{selectedGroup.totp_required ? (
<Chip size="small" variant="outlined" label="TOTP required" sx={{ mr: 0.2, ml: 0.2 }} />
) : null}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
{selectedGroup.id} · updated: {fmt(selectedGroup.updated_at)}
</Typography>
{selectedGroup.description ? (
<Typography variant="caption" color="text.secondary">
{selectedGroup.description}
</Typography>
) : null}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Select a group to manage its members and permissions.
</Typography>
)}
</SectionCard>
<SectionCard
title="Members"
actions={
selectedGroup ? (
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={() => setAddMemberOpen((open: boolean) => !open)}
disabled={busy || !canManageGroups}
>
Add Member
</Button>
) : undefined
}
>
{selectedGroup ? (
<>
{addMemberOpen ? (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
mb: 1,
p: 1,
borderRadius: 1,
bgcolor: 'action.hover',
}}
>
<Autocomplete<User, false, false, false>
size="small"
options={availableUsers}
value={availableUsers.find((user: User) => user.id === newMemberUserID) || null}
onChange={(_event, value: User | null) => setNewMemberUserID(value?.id || '')}
getOptionLabel={(user: User) => user.display_name ? `${user.display_name} (${user.username})` : user.username}
isOptionEqualToValue={(option: User, value: User) => option.id === value.id}
noOptionsText="No available users"
renderInput={(params) => <TextField {...params} label="User" placeholder="Select user" />}
sx={{ minWidth: 240, flex: 1 }}
/>
<Button variant="contained" size="small" onClick={addMember} disabled={busy || !newMemberUserID || !canManageGroups}>
{busy ? 'Adding...' : 'Add'}
</Button>
<Button
size="small"
onClick={() => {
setAddMemberOpen(false)
setNewMemberUserID('')
}}
disabled={busy}
>
Cancel
</Button>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Tooltip title="Back to overview">
<IconButton size="small" onClick={() => setSelectedGroupID('')} aria-label="Back to overview" sx={{ ml: -0.5 }}><ArrowBackIcon fontSize="small" /></IconButton>
</Tooltip>
<Typography variant="h6" noWrap sx={{ minWidth: 0 }}>{selectedGroup.name}</Typography>
<Chip size="small" color={selectedGroup.disabled ? 'error' : 'success'} label={selectedGroup.disabled ? 'Disabled' : 'Active'} />
{selectedGroup.totp_required ? <Chip size="small" variant="outlined" label="TOTP required" /> : null}
<Box sx={{ ml: 'auto', display: 'flex', gap: 0.5 }}>
<Tooltip title="Edit group">
<span>
<IconButton size="small" onClick={() => openEdit(selectedGroup)} disabled={!canManageGroups} aria-label="Edit group"><EditIcon fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Delete group">
<span>
<IconButton size="small" color="error" onClick={() => { setDeleteItem(selectedGroup); setDeleteConfirm('') }} disabled={!canManageGroups} aria-label="Delete group"><DeleteOutlinedIcon fontSize="small" /></IconButton>
</span>
</Tooltip>
</Box>
</Box>
) : null}
<Box sx={{ display: 'grid', gap: 0.75 }}>
{members.map((member) => (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'minmax(120px, 1fr) minmax(120px, 1fr) minmax(0, 2fr)' }, gap: 1 }}>
<InfoTile label="Members" value={String(members.length)} color="info.main" />
<InfoTile label="Updated" value={fmt(selectedGroup.updated_at)} color="success.main" />
<InfoTile label="Description" value={selectedGroup.description || '-'} color="primary.main" title={selectedGroup.description || undefined} />
</Box>
</Box>
<SectionCard
collapsible
title={<Typography variant="h6">Members ({members.length})</Typography>}
actions={
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={() => setAddMemberOpen((open: boolean) => !open)}
disabled={busy || !canManageGroups}
>
Add Member
</Button>
}
>
{addMemberOpen ? (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
mb: 1,
p: 1,
borderRadius: 1,
bgcolor: 'action.hover',
}}
>
<Autocomplete<User, false, false, false>
size="small"
options={availableUsers}
value={availableUsers.find((user: User) => user.id === newMemberUserID) || null}
onChange={(_event, value: User | null) => setNewMemberUserID(value?.id || '')}
getOptionLabel={(user: User) => user.display_name ? `${user.display_name} (${user.username})` : user.username}
isOptionEqualToValue={(option: User, value: User) => option.id === value.id}
noOptionsText="No available users"
renderInput={(params) => <TextField {...params} label="User" placeholder="Select user" />}
sx={{ minWidth: 240, flex: 1 }}
/>
<Button variant="contained" size="small" onClick={addMember} disabled={busy || !newMemberUserID || !canManageGroups}>
{busy ? 'Adding...' : 'Add'}
</Button>
<Button
size="small"
onClick={() => {
setAddMemberOpen(false)
setNewMemberUserID('')
}}
disabled={busy}
>
Cancel
</Button>
</Box>
) : null}
<Box sx={{ display: 'grid', gap: 0.75 }}>
{members.map((member) => (
<Box
key={member.user_id}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
@@ -589,55 +559,63 @@ export default function AdminUserGroupsPage() {
</span>
</Tooltip>
</Box>
))}
{members.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No members.
</Typography>
) : null}
</Box>
</>
) : (
<Typography variant="body2" color="text.secondary">
Select a group.
</Typography>
)}
</SectionCard>
))}
{members.length === 0 ? (
<Typography variant="body2" color="text.secondary">No members.</Typography>
) : null}
</Box>
</SectionCard>
<SectionCard title="Member Options">
{selectedGroup ? (
<Box sx={{ display: 'grid' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Checkbox
checked={Boolean(selectedGroup.totp_required)}
onChange={(event) => toggleSelectedGroupTOTPRequired(event.target.checked)}
disabled={busy || !canManageGroups}
/>
<Typography>Require TOTP for members</Typography>
</Box>
{optionsByDomain.map((group) => (
<Box key={group.domain} sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{group.domain}</Typography>
{group.items.map((item) => (
<Box key={item.permission} sx={{ display: 'flex', alignItems: 'center' }}>
<Checkbox
checked={hasGroupOption(selectedGroupID, item.permission)}
onChange={(event) => toggleSelectedGroupOption(item.permission, event.target.checked)}
disabled={busy || !canManageGrants}
/>
<Tooltip title={item.chip}><Typography>{item.label}</Typography></Tooltip>
<SectionCard collapsible title={<Typography variant="h6">Member Options</Typography>}>
<Box sx={{ display: 'grid' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Checkbox
size="small"
checked={Boolean(selectedGroup.totp_required)}
onChange={(event) => toggleSelectedGroupTOTPRequired(event.target.checked)}
disabled={busy || !canManageGroups}
/>
<Typography variant="body2">Require TOTP for members</Typography>
</Box>
{optionsByDomain.map((group) => (
<Box key={group.domain} sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{group.domain}</Typography>
{group.items.map((item) => (
<Box key={item.permission} sx={{ display: 'flex', alignItems: 'center' }}>
<Checkbox
size="small"
checked={hasGroupOption(selectedGroupID, item.permission)}
onChange={(event) => toggleSelectedGroupOption(item.permission, event.target.checked)}
disabled={busy || !canManageGrants}
/>
<Tooltip title={item.chip}><Typography variant="body2">{item.label}</Typography></Tooltip>
</Box>
))}
</Box>
))}
</Box>
))}
</SectionCard>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Select a group.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 1 }}>
<StatTile label="Groups" count={groups.length} color="primary.main" />
<StatTile label="Active" count={activeCount} color="success.main" />
<StatTile label="Disabled" count={disabledCount} color="error.main" />
</Box>
<SectionCard collapsible title={<Typography variant="h6">Recent groups ({recentGroups.length})</Typography>}>
{recentGroups.length === 0 ? (
<Typography variant="body2" color="text.secondary">No groups yet.</Typography>
) : (
<List dense disablePadding>{recentGroups.map(groupRow)}</List>
)}
</SectionCard>
<Typography variant="body2" color="text.secondary">
Select a group to manage its members and permissions.
</Typography>
</Box>
)}
</SectionCard>
</Box>
</TintedPanel>
)}
/>
+418 -15
View File
@@ -1,20 +1,423 @@
import { Box, Typography } from '@mui/material'
import BoardsSection from '../components/BoardsSection'
import ProjectsSection from '../components/ProjectsSection'
import RepositoriesSection from '../components/RepositoriesSection'
import AddIcon from '@mui/icons-material/Add'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
import EditIcon from '@mui/icons-material/Edit'
import WorkspacesOutlinedIcon from '@mui/icons-material/WorkspacesOutlined'
import LaunchIcon from '@mui/icons-material/Launch'
import RefreshIcon from '@mui/icons-material/Refresh'
import { Box, Button, Chip, Divider, IconButton, List, ListItemButton, TextField, Tooltip, Typography } from '@mui/material'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, AppEvent, EventSeverity, Project, ProjectCreatePayload, ProjectUpdatePayload, User } from '../api'
import { storageKey } from '../branding'
import AgeChip from '../components/AgeChip'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import HeaderActionButton from '../components/HeaderActionButton'
import PageAlert from '../components/PageAlert'
import ProjectBoardList from '../components/ProjectBoardList'
import ProjectFormDialog, { ProjectDescriptionTab, ProjectHomePageValue } from '../components/ProjectFormDialog'
import ProjectRepoList from '../components/ProjectRepoList'
import SectionCard from '../components/SectionCard'
import SplitPanel from '../components/SplitPanel'
import TintedPanel from '../components/TintedPanel'
import { formatEpochTime, formatRelative } from '../time'
export default function ProjectsPage() {
const selectedStorageKey: string = storageKey('projects:selected')
// Monitoring-style summary tile: a big count over a caption with a colored rail.
function StatTile(props: { label: string; count: number; color: string }) {
return (
<Box sx={{ minWidth: 0 }}>
<ProjectsSection />
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<RepositoriesSection />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<BoardsSection />
</Box>
</Box>
<Box sx={{ p: 1.5, borderRadius: 1, border: 1, borderColor: 'divider', borderLeft: '4px solid', borderLeftColor: props.color, minWidth: 0 }}>
<Typography variant="h5" sx={{ lineHeight: 1.1 }}>{props.count}</Typography>
<Typography variant="caption" color="text.secondary">{props.label}</Typography>
</Box>
)
}
// Colored-rail info box (same visual family as StatTile) for a labeled value.
// The value is single-line and clipped to fit; `title` carries the full text.
function InfoTile(props: { label: string; value: ReactNode; color: string; title?: string }) {
return (
<Box sx={{ p: 1.5, borderRadius: 1, border: 1, borderColor: 'divider', borderLeft: '4px solid', borderLeftColor: props.color, minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{props.label}</Typography>
<Typography variant="body2" noWrap title={props.title} sx={{ minWidth: 0 }}>{props.value}</Typography>
</Box>
)
}
const severityColor = (s: EventSeverity): string => s === 'critical' ? 'error.main' : s === 'warning' ? 'warning.main' : 'info.main'
export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([])
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState<string>('')
const [selectedId, setSelectedId] = useState<string | null>(() => {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(selectedStorageKey) || null
})
// Bumping this remounts the detail sections so Refresh re-fetches their lists.
const [reloadToken, setReloadToken] = useState<number>(0)
// Totals for the overview tiles (fetched once; refreshed with the page).
const [globalCounts, setGlobalCounts] = useState<{ repos: number; boards: number } | null>(null)
// Recent workspace events for the overview's "Recent events" card.
const [events, setEvents] = useState<AppEvent[]>([])
// Create / edit / delete project state.
const [createOpen, setCreateOpen] = useState(false)
const [createSlug, setCreateSlug] = useState('')
const [createName, setCreateName] = useState('')
const [createDescription, setCreateDescription] = useState('')
const [createHomePage, setCreateHomePage] = useState<ProjectHomePageValue>('dashboard')
const [createDescTab, setCreateDescTab] = useState<ProjectDescriptionTab>('write')
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [editProject, setEditProject] = useState<Project | null>(null)
const [editSlug, setEditSlug] = useState('')
const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editHomePage, setEditHomePage] = useState<ProjectHomePageValue>('dashboard')
const [editDescTab, setEditDescTab] = useState<ProjectDescriptionTab>('write')
const [editError, setEditError] = useState<string | null>(null)
const [savingEdit, setSavingEdit] = useState(false)
const [deleteProject, setDeleteProject] = useState<Project | null>(null)
const [deleteCounts, setDeleteCounts] = useState<{ repos: number; issues: number; wiki: number; uploads: number } | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const canCreateProject = Boolean(user?.is_admin || user?.permissions?.includes('project.create'))
const loadProjects = () => {
setLoading(true)
setError(null)
api.listProjects(500, 0, '')
.then((result) => setProjects(Array.isArray(result.items) ? result.items : []))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load projects'))
.finally(() => setLoading(false))
}
useEffect(() => { loadProjects() }, [])
useEffect(() => { api.me().then(setUser).catch(() => setUser(null)) }, [])
useEffect(() => {
let cancelled = false
Promise.all([api.listAllRepos('', ''), api.listAllBoards()])
.then(([repos, boards]) => { if (!cancelled) setGlobalCounts({ repos: Array.isArray(repos) ? repos.length : 0, boards: Array.isArray(boards) ? boards.length : 0 }) })
.catch(() => { if (!cancelled) setGlobalCounts(null) })
return () => { cancelled = true }
}, [reloadToken])
useEffect(() => {
let cancelled = false
api.listEvents(false)
.then((feed) => { if (!cancelled) setEvents(feed.events || []) })
.catch(() => { if (!cancelled) setEvents([]) })
return () => { cancelled = true }
}, [reloadToken])
useEffect(() => {
if (typeof window === 'undefined') return
if (selectedId) window.localStorage.setItem(selectedStorageKey, selectedId)
else window.localStorage.removeItem(selectedStorageKey)
}, [selectedId])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return projects
return projects.filter((p) => p.name.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q))
}, [projects, search])
const selected = useMemo(() => projects.find((p) => p.id === selectedId) || null, [projects, selectedId])
// Most-recently-updated projects, to fill the overview with a quick-pick list.
const recentProjects = useMemo(
() => [...projects].sort((a, b) => (b.updated_at || b.created_at || 0) - (a.updated_at || a.created_at || 0)).slice(0, 6),
[projects],
)
// Only repo-sourced events (e.g. RPM mirror failed/recovered) belong to a
// project; monitor events and other kinds are filtered out.
const projectEvents = useMemo(() => events.filter((e) => e.source_type === 'repo'), [events])
const refresh = () => {
loadProjects()
setReloadToken((n) => n + 1)
}
// ---- create ----
const handleCreate = async () => {
const payload: ProjectCreatePayload = { slug: createSlug.trim(), name: createName.trim(), description: createDescription, home_page: createHomePage }
if (/\s/.test(payload.slug)) { setCreateError('Slug cannot contain whitespace.'); return }
setCreateError(null)
setCreating(true)
try {
const created = await api.createProject(payload)
setProjects((prev) => [...prev, created])
setSelectedId(created.id)
closeCreate()
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Failed to create project')
} finally {
setCreating(false)
}
}
const closeCreate = () => {
setCreateOpen(false); setCreateError(null); setCreateSlug(''); setCreateName('')
setCreateDescription(''); setCreateHomePage('dashboard'); setCreateDescTab('write')
}
// ---- edit ----
const openEdit = (project: Project) => {
setEditProject(project); setEditSlug(project.slug); setEditName(project.name)
setEditDescription(project.description || ''); setEditHomePage(project.home_page || 'dashboard')
setEditDescTab('write'); setEditError(null)
}
const handleEditSave = async () => {
if (!editProject) return
if (/\s/.test(editSlug)) { setEditError('Slug cannot contain whitespace.'); return }
const payload: ProjectUpdatePayload = { slug: editSlug.trim(), name: editName.trim(), description: editDescription.trim(), home_page: editHomePage }
setEditError(null)
setSavingEdit(true)
try {
const updated = await api.updateProject(editProject.id, payload)
setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
setEditProject(null)
} catch (err) {
setEditError(err instanceof Error ? err.message : 'Failed to update project')
} finally {
setSavingEdit(false)
}
}
// ---- delete ----
const openDelete = async (project: Project) => {
setDeleteProject(project); setDeleteCounts(null); setDeleteConfirm(''); setDeleteError(null); setDeleteLoading(true)
try {
const [repos, issues, wiki, uploads] = await Promise.all([
api.listRepos(project.id), api.listIssues(project.id), api.listWikiPages(project.id), api.listUploads(project.id),
])
setDeleteCounts({
repos: Array.isArray(repos) ? repos.length : 0,
issues: Array.isArray(issues) ? issues.length : 0,
wiki: Array.isArray(wiki) ? wiki.length : 0,
uploads: Array.isArray(uploads) ? uploads.length : 0,
})
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to load project contents')
} finally {
setDeleteLoading(false)
}
}
const handleDelete = async () => {
if (!deleteProject) return
setDeleteError(null)
setDeleting(true)
try {
await api.deleteProject(deleteProject.id)
const deletedId = deleteProject.id
setProjects((prev) => prev.filter((p) => p.id !== deletedId))
setSelectedId((cur) => (cur === deletedId ? null : cur))
closeDelete()
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete project')
} finally {
setDeleting(false)
}
}
const closeDelete = () => { setDeleteProject(null); setDeleteCounts(null); setDeleteConfirm(''); setDeleteError(null) }
const projectRow = (p: Project) => (
<ListItemButton
key={p.id}
selected={p.id === selectedId}
onClick={() => setSelectedId((cur) => (cur === p.id ? null : p.id))}
sx={{ borderRadius: 1, display: 'grid', gridTemplateColumns: '20px minmax(0, 1fr) auto 28px', columnGap: 1, alignItems: 'center', py: 0.75 }}
>
<WorkspacesOutlinedIcon fontSize="small" sx={{ color: p.id === selectedId ? 'primary.main' : 'text.disabled' }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" noWrap sx={{ fontWeight: p.id === selectedId ? 600 : 400 }}>{p.name}</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{p.slug}</Typography>
</Box>
<AgeChip createdAt={p.created_at} />
<Tooltip title="Open project">
<IconButton
size="small"
component={Link}
to={`/projects/${p.id}`}
aria-label={`Open ${p.name}`}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
sx={{ color: 'text.disabled', '&:hover': { color: 'primary.main' } }}
>
<LaunchIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemButton>
)
const left = (
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', display: 'flex', flexDirection: 'column', p: 1 }}>
<TextField
size="small" fullWidth label="Search" placeholder="name or slug"
value={search} onChange={(e) => setSearch(e.target.value)} sx={{ mb: 1, mt: 1}}
/>
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{loading ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>Loading projects...</Typography>
) : filtered.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>No projects found.</Typography>
) : filtered.map(projectRow)}
</Box>
</TintedPanel>
)
const right = (
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto', p: 1.5, ml: 1 }}>
{selected ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Tooltip title="Back to overview">
<IconButton size="small" onClick={() => setSelectedId(null)} aria-label="Back to overview" sx={{ ml: -0.5 }}><ArrowBackIcon fontSize="small" /></IconButton>
</Tooltip>
<Typography variant="h6" sx={{ minWidth: 0 }} noWrap>{selected.name}</Typography>
<Chip label={selected.slug} size="small" variant="outlined" />
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip title="Open project">
<IconButton size="small" component={Link} to={`/projects/${selected.id}`} aria-label="Open project"><LaunchIcon fontSize="small" /></IconButton>
</Tooltip>
<Tooltip title="Edit project">
<IconButton size="small" onClick={() => openEdit(selected)} aria-label="Edit project"><EditIcon fontSize="small" /></IconButton>
</Tooltip>
{user?.is_admin ? (
<Tooltip title="Delete project">
<IconButton size="small" color="error" onClick={() => openDelete(selected)} aria-label="Delete project"><DeleteOutlinedIcon fontSize="small" /></IconButton>
</Tooltip>
) : null}
</Box>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'minmax(120px, 1fr) minmax(120px, 1fr) minmax(0, 2fr)' }, gap: 1 }}>
<InfoTile label="Home page" value={selected.home_page || 'dashboard'} color="info.main" />
<InfoTile label="Created by" value={selected.created_by_name || '-'} color="success.main" />
<InfoTile label="Description" value={selected.description || '-'} color="primary.main" title={selected.description || undefined} />
</Box>
</Box>
<ProjectRepoList key={`repos-${selected.id}-${reloadToken}`} projectId={selected.id} />
<ProjectBoardList key={`boards-${selected.id}-${reloadToken}`} projectId={selected.id} />
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 1 }}>
<StatTile label="Projects" count={projects.length} color="primary.main" />
<StatTile label="Repositories" count={globalCounts?.repos ?? 0} color="info.main" />
<StatTile label="Boards" count={globalCounts?.boards ?? 0} color="success.main" />
</Box>
<SectionCard collapsible title={<Typography variant="h6">Recent projects ({recentProjects.length})</Typography>}>
{recentProjects.length === 0 ? (
<Typography variant="body2" color="text.secondary">No projects yet.</Typography>
) : (
<List dense disablePadding>{recentProjects.map(projectRow)}</List>
)}
</SectionCard>
<SectionCard collapsible title={<Typography variant="h6">Recent project events ({projectEvents.length})</Typography>}>
{projectEvents.length === 0 ? (
<Typography variant="body2" color="text.secondary">No recent project events.</Typography>
) : (
<List dense disablePadding>
{projectEvents.slice(0, 20).map((e) => (
<ListItemButton
key={e.id}
onClick={() => setSelectedId(e.owner_project_id)}
sx={{ borderRadius: 1, display: 'grid', gridTemplateColumns: '12px minmax(0, 1fr) auto', columnGap: 1, alignItems: 'start', py: 0.75 }}
>
<Box sx={{ mt: 0.5, width: 8, height: 8, borderRadius: '50%', flexShrink: 0, bgcolor: severityColor(e.severity) }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" noWrap>{e.title}</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', wordBreak: 'break-word' }}>
{e.owner_project_name || e.owner_project_id}{e.body ? ` · ${e.body}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'grid', justifyItems: 'end', gap: 0.25 }}>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>{formatEpochTime(e.created_at)}</Typography>
<Chip size="small" sx={{ height: 18 }} label={formatRelative(e.created_at)} />
</Box>
</ListItemButton>
))}
</List>
)}
</SectionCard>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button size="small" variant="outlined" component={Link} to="/repos">View all repositories</Button>
<Button size="small" variant="outlined" component={Link} to="/boards">View all boards</Button>
</Box>
</Box>
)}
</TintedPanel>
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 76px)', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Projects</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<HeaderActionButton variant="outlined" startIcon={<RefreshIcon />} onClick={refresh}>Refresh</HeaderActionButton>
{canCreateProject ? <HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>New Project</HeaderActionButton> : null}
</Box>
</Box>
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
<SplitPanel defaultRatio={0.28} storageKey={storageKey('projects:split')} sx={{ flex: 1, minHeight: 0 }} left={left} right={right} />
<ProjectFormDialog
open={createOpen} title="New Project" error={createError}
slug={createSlug} onSlugChange={setCreateSlug}
name={createName} onNameChange={setCreateName}
description={createDescription} onDescriptionChange={setCreateDescription}
homePage={createHomePage} onHomePageChange={setCreateHomePage}
descriptionTab={createDescTab} onDescriptionTabChange={setCreateDescTab}
busy={creating} saveText="Create" busyText="Creating..." onSave={handleCreate} onClose={closeCreate}
/>
<ProjectFormDialog
open={Boolean(editProject)} title="Edit Project" error={editError}
slug={editSlug} onSlugChange={setEditSlug}
name={editName} onNameChange={setEditName}
description={editDescription} onDescriptionChange={setEditDescription}
homePage={editHomePage} onHomePageChange={setEditHomePage}
descriptionTab={editDescTab} onDescriptionTabChange={setEditDescTab}
busy={savingEdit} saveText="Save" busyText="Saving..." onSave={handleEditSave} onClose={() => setEditProject(null)}
/>
<ConfirmDeleteDialog
open={Boolean(deleteProject)}
title="Delete Project"
prompt={deleteLoading ? 'Checking project contents...' : 'Type the project slug to confirm deletion.'}
expectedText={!deleteLoading && deleteCounts ? (deleteProject?.slug || '') : undefined}
confirmLabel="Project Slug"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
error={deleteError}
busy={deleting}
confirmDisabled={deleteLoading || !deleteProject || !deleteCounts}
onConfirm={handleDelete}
onClose={closeDelete}
>
{deleteLoading ? null : deleteCounts ? (
deleteCounts.repos + deleteCounts.issues + deleteCounts.wiki + deleteCounts.uploads === 0 ? (
<Typography variant="body2" color="text.secondary">This project has no repositories, issues, wiki pages, or files.</Typography>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="body2" color="text.secondary">This project contains:</Typography>
<Typography variant="body2">Repositories: {deleteCounts.repos}</Typography>
<Typography variant="body2">Issues: {deleteCounts.issues}</Typography>
<Typography variant="body2">Wiki pages: {deleteCounts.wiki}</Typography>
<Typography variant="body2">Files: {deleteCounts.uploads}</Typography>
</Box>
)
) : null}
</ConfirmDeleteDialog>
</Box>
)
}