Compare commits
6 Commits
35da73b3fd
...
986172b6fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 986172b6fc | |||
| 430fc84c26 | |||
| ea3f54a7b6 | |||
| 59102059ee | |||
| 1b111612ee | |||
| bb02dacff6 |
@@ -127,6 +127,7 @@ export interface Repo {
|
|||||||
docker_url?: string
|
docker_url?: string
|
||||||
is_foreign?: boolean
|
is_foreign?: boolean
|
||||||
attachable?: boolean
|
attachable?: boolean
|
||||||
|
created_at?: number
|
||||||
owner_project_name?: string
|
owner_project_name?: string
|
||||||
owner_project?: string
|
owner_project?: string
|
||||||
owner_slug?: string
|
owner_slug?: string
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import DashboardCustomizeIcon from '@mui/icons-material/DashboardCustomize'
|
|||||||
import StorageIcon from '@mui/icons-material/Storage'
|
import StorageIcon from '@mui/icons-material/Storage'
|
||||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
|
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
|
||||||
import CalculateIcon from '@mui/icons-material/Calculate'
|
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 KeyIcon from '@mui/icons-material/Key'
|
||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
|
||||||
import BadgeIcon from '@mui/icons-material/Badge'
|
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 BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined'
|
||||||
import HistoryIcon from '@mui/icons-material/History'
|
import HistoryIcon from '@mui/icons-material/History'
|
||||||
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'
|
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 VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
|
||||||
import PaletteIcon from '@mui/icons-material/Palette'
|
import PaletteIcon from '@mui/icons-material/Palette'
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||||
@@ -147,8 +147,8 @@ export default function Layout() {
|
|||||||
if (isAdmin) adminItems.push({ kind: 'divider', key })
|
if (isAdmin) adminItems.push({ kind: 'divider', key })
|
||||||
}
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
pushAdmin('users.manage', { label: 'Users', path: '/admin/users', icon: <PeopleIcon 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: <GroupWorkIcon 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(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('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" /> })
|
pushAdmin('gpg.manage', { label: 'Admin GPG Keys', path: '/admin/gpg-keys', icon: <VpnKeyIcon fontSize="small" /> })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,23 @@ type SplitPanelProps = {
|
|||||||
direction?: SplitDirection
|
direction?: SplitDirection
|
||||||
defaultRatio?: number
|
defaultRatio?: number
|
||||||
collapsible?: boolean
|
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']
|
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
|
// 'left'/'right' name the two slots regardless of direction (left = top slot in
|
||||||
// column mode).
|
// column mode).
|
||||||
type CollapsedSide = 'left' | 'right' | null
|
type CollapsedSide = 'left' | 'right' | null
|
||||||
@@ -37,8 +51,8 @@ const collapseButtonSx = {
|
|||||||
'&:hover': { bgcolor: 'background.paper' },
|
'&:hover': { bgcolor: 'background.paper' },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export default function SplitPanel({ left, right, direction = 'row', defaultRatio = 0.3, collapsible = true, sx }: SplitPanelProps) {
|
export default function SplitPanel({ left, right, direction = 'row', defaultRatio = 0.3, collapsible = true, storageKey, sx }: SplitPanelProps) {
|
||||||
const [splitRatio, setSplitRatio] = useState<number>(defaultRatio)
|
const [splitRatio, setSplitRatio] = useState<number>(() => readStoredRatio(storageKey, defaultRatio))
|
||||||
const [splitDragging, setSplitDragging] = useState<boolean>(false)
|
const [splitDragging, setSplitDragging] = useState<boolean>(false)
|
||||||
const [collapsedSide, setCollapsedSide] = useState<CollapsedSide>(null)
|
const [collapsedSide, setCollapsedSide] = useState<CollapsedSide>(null)
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -67,6 +81,10 @@ export default function SplitPanel({ left, right, direction = 'row', defaultRati
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerUp = (): void => {
|
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)
|
setSplitDragging(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,8 @@ export default function UtilityTray(props: UtilityTrayProps) {
|
|||||||
flexDirection: fill ? 'column' : undefined,
|
flexDirection: fill ? 'column' : undefined,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflow: fill ? 'hidden' : 'auto'
|
overflow: fill ? 'hidden' : 'auto',
|
||||||
|
pt: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Widget widgetId={`tray-${item.type}`} />
|
<Widget widgetId={`tray-${item.type}`} />
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
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 ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import Autocomplete from '../components/Autocomplete'
|
import Autocomplete from '../components/Autocomplete'
|
||||||
import {
|
import {
|
||||||
@@ -9,14 +11,18 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItemButton,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} 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 SectionCard from '../components/SectionCard'
|
||||||
import SplitPanel from '../components/SplitPanel'
|
import SplitPanel from '../components/SplitPanel'
|
||||||
import TintedPanel from '../components/TintedPanel'
|
import TintedPanel from '../components/TintedPanel'
|
||||||
@@ -34,6 +40,25 @@ function fmt(value: number): string {
|
|||||||
return formatEpochTime(value)
|
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 {
|
function defaultUserGroupForm(): UserGroupFormState {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -79,6 +104,32 @@ export default function AdminUserGroupsPage() {
|
|||||||
return groups.find((item) => item.id === selectedGroupID) || null
|
return groups.find((item) => item.id === selectedGroupID) || null
|
||||||
}, [groups, selectedGroupID])
|
}, [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
|
// Group the permission checkboxes by their catalog domain, preserving catalog
|
||||||
// order, so the (now sizeable) list reads as sections rather than a flat wall.
|
// order, so the (now sizeable) list reads as sections rather than a flat wall.
|
||||||
const optionsByDomain = useMemo(() => {
|
const optionsByDomain = useMemo(() => {
|
||||||
@@ -131,9 +182,7 @@ export default function AdminUserGroupsPage() {
|
|||||||
domain: entry.domain
|
domain: entry.domain
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
if (!selectedGroupID && Array.isArray(list) && list.length > 0) {
|
// Open on the overview (no selection) rather than auto-selecting a group.
|
||||||
setSelectedGroupID(list[0].id)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load groups')
|
setError(err instanceof Error ? err.message : 'Failed to load groups')
|
||||||
setGroups([])
|
setGroups([])
|
||||||
@@ -308,25 +357,6 @@ export default function AdminUserGroupsPage() {
|
|||||||
// Compact grant summary for the left list: a count (with the full list on
|
// 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
|
// hover) rather than enumerating every permission key, which doesn't scale as
|
||||||
// the catalog grows. The authoritative per-permission view is the detail panel.
|
// 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) => {
|
const hasGroupOption = (groupID: string, permission: string) => {
|
||||||
return subjectPermissions.some((item: SubjectPermission) => item.permission === permission && item.subject_id === groupID)
|
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', flexDirection: 'column', height: 'calc(100dvh - 76px)', minHeight: 0 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="h5">Admin: User Groups</Typography>
|
<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>
|
</Box>
|
||||||
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
|
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
|
||||||
<SplitPanel
|
<SplitPanel
|
||||||
defaultRatio={0.38}
|
defaultRatio={0.34}
|
||||||
|
storageKey={storageKey('admin-user-groups:split')}
|
||||||
sx={{ flex: 1, minHeight: 0 }}
|
sx={{ flex: 1, minHeight: 0 }}
|
||||||
left={(
|
left={(
|
||||||
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto' }}>
|
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', display: 'flex', flexDirection: 'column', p: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="Search"
|
label="Search"
|
||||||
placeholder="Name, id, or description"
|
placeholder="Name, id, or description"
|
||||||
value={groupQuery}
|
value={groupQuery}
|
||||||
onChange={(event) => setGroupQuery(event.target.value)}
|
onChange={(event) => setGroupQuery(event.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ mb: 1 }}
|
sx={{ mb: 1, mt: 1 }}
|
||||||
/>
|
/>
|
||||||
{loading ? <Typography variant="body2" color="text.secondary">Loading groups...</Typography> : null}
|
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
{!loading && groups.length === 0 ? <Typography variant="body2" color="text.secondary">No groups.</Typography> : null}
|
{loading ? <Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>Loading groups...</Typography> : null}
|
||||||
{!loading && groups.length > 0 && filteredGroups.length === 0 ? (
|
{!loading && groups.length === 0 ? <Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>No groups.</Typography> : null}
|
||||||
<Typography variant="body2" color="text.secondary">No matching groups.</Typography>
|
{!loading && groups.length > 0 && filteredGroups.length === 0 ? (
|
||||||
) : null}
|
<Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>No matching groups.</Typography>
|
||||||
<List>
|
) : null}
|
||||||
{filteredGroups.map((group) => (
|
<List dense disablePadding>
|
||||||
<ListItem
|
{filteredGroups.map(groupRow)}
|
||||||
key={group.id}
|
</List>
|
||||||
divider
|
</Box>
|
||||||
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>
|
</TintedPanel>
|
||||||
)}
|
)}
|
||||||
right={(
|
right={(
|
||||||
<Box sx={{ display: 'grid', gap: 1, minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto', alignContent: 'start' }}>
|
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto', p: 1.5, ml: 1 }}>
|
||||||
<SectionCard title="Selected Group">
|
|
||||||
{selectedGroup ? (
|
{selectedGroup ? (
|
||||||
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
<Typography variant="h6" sx={{ minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
{selectedGroup.name}
|
<Tooltip title="Back to overview">
|
||||||
</Typography>
|
<IconButton size="small" onClick={() => setSelectedGroupID('')} aria-label="Back to overview" sx={{ ml: -0.5 }}><ArrowBackIcon fontSize="small" /></IconButton>
|
||||||
<Typography component="div">
|
</Tooltip>
|
||||||
<Chip
|
<Typography variant="h6" noWrap sx={{ minWidth: 0 }}>{selectedGroup.name}</Typography>
|
||||||
size="small"
|
<Chip size="small" color={selectedGroup.disabled ? 'error' : 'success'} label={selectedGroup.disabled ? 'Disabled' : 'Active'} />
|
||||||
color={selectedGroup.disabled ? 'error' : 'success'}
|
{selectedGroup.totp_required ? <Chip size="small" variant="outlined" label="TOTP required" /> : null}
|
||||||
label={selectedGroup.disabled ? 'Disabled' : 'Active'}
|
<Box sx={{ ml: 'auto', display: 'flex', gap: 0.5 }}>
|
||||||
/>
|
<Tooltip title="Edit group">
|
||||||
{groupOptionPermissions.map((item: { permission: string, label: string, chip: string }) => (
|
<span>
|
||||||
hasGroupOption(selectedGroup.id, item.permission) ? (
|
<IconButton size="small" onClick={() => openEdit(selectedGroup)} disabled={!canManageGroups} aria-label="Edit group"><EditIcon fontSize="small" /></IconButton>
|
||||||
<Chip key={item.permission} size="small" variant="outlined" label={item.chip} sx={{ mr: 0.2, ml: 0.2 }} />
|
</span>
|
||||||
) : null
|
</Tooltip>
|
||||||
))}
|
<Tooltip title="Delete group">
|
||||||
{selectedGroup.totp_required ? (
|
<span>
|
||||||
<Chip size="small" variant="outlined" label="TOTP required" sx={{ mr: 0.2, ml: 0.2 }} />
|
<IconButton size="small" color="error" onClick={() => { setDeleteItem(selectedGroup); setDeleteConfirm('') }} disabled={!canManageGroups} aria-label="Delete group"><DeleteOutlinedIcon fontSize="small" /></IconButton>
|
||||||
) : null}
|
</span>
|
||||||
</Typography>
|
</Tooltip>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
) : null}
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'minmax(120px, 1fr) minmax(120px, 1fr) minmax(0, 2fr)' }, gap: 1 }}>
|
||||||
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
<InfoTile label="Members" value={String(members.length)} color="info.main" />
|
||||||
{members.map((member) => (
|
<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
|
<Box
|
||||||
key={member.user_id}
|
key={member.user_id}
|
||||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
|
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
|
||||||
@@ -589,55 +559,63 @@ export default function AdminUserGroupsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">No members.</Typography>
|
||||||
No members.
|
) : null}
|
||||||
</Typography>
|
</Box>
|
||||||
) : null}
|
</SectionCard>
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Select a group.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard title="Member Options">
|
<SectionCard collapsible title={<Typography variant="h6">Member Options</Typography>}>
|
||||||
{selectedGroup ? (
|
<Box sx={{ display: 'grid' }}>
|
||||||
<Box sx={{ display: 'grid' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Checkbox
|
||||||
<Checkbox
|
size="small"
|
||||||
checked={Boolean(selectedGroup.totp_required)}
|
checked={Boolean(selectedGroup.totp_required)}
|
||||||
onChange={(event) => toggleSelectedGroupTOTPRequired(event.target.checked)}
|
onChange={(event) => toggleSelectedGroupTOTPRequired(event.target.checked)}
|
||||||
disabled={busy || !canManageGroups}
|
disabled={busy || !canManageGroups}
|
||||||
/>
|
/>
|
||||||
<Typography>Require TOTP for members</Typography>
|
<Typography variant="body2">Require TOTP for members</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{optionsByDomain.map((group) => (
|
{optionsByDomain.map((group) => (
|
||||||
<Box key={group.domain} sx={{ mt: 0.5 }}>
|
<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>
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{group.domain}</Typography>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<Box key={item.permission} sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box key={item.permission} sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={hasGroupOption(selectedGroupID, item.permission)}
|
size="small"
|
||||||
onChange={(event) => toggleSelectedGroupOption(item.permission, event.target.checked)}
|
checked={hasGroupOption(selectedGroupID, item.permission)}
|
||||||
disabled={busy || !canManageGrants}
|
onChange={(event) => toggleSelectedGroupOption(item.permission, event.target.checked)}
|
||||||
/>
|
disabled={busy || !canManageGrants}
|
||||||
<Tooltip title={item.chip}><Typography>{item.label}</Typography></Tooltip>
|
/>
|
||||||
|
<Tooltip title={item.chip}><Typography variant="body2">{item.label}</Typography></Tooltip>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
</SectionCard>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
Select a group.
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 1 }}>
|
||||||
</Typography>
|
<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>
|
</TintedPanel>
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,423 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import BoardsSection from '../components/BoardsSection'
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
import ProjectsSection from '../components/ProjectsSection'
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
||||||
import RepositoriesSection from '../components/RepositoriesSection'
|
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 (
|
return (
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ p: 1.5, borderRadius: 1, border: 1, borderColor: 'divider', borderLeft: '4px solid', borderLeftColor: props.color, minWidth: 0 }}>
|
||||||
<ProjectsSection />
|
<Typography variant="h5" sx={{ lineHeight: 1.1 }}>{props.count}</Typography>
|
||||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
<Typography variant="caption" color="text.secondary">{props.label}</Typography>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
</Box>
|
||||||
<RepositoriesSection />
|
)
|
||||||
</Box>
|
}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<BoardsSection />
|
// Colored-rail info box (same visual family as StatTile) for a labeled value.
|
||||||
</Box>
|
// The value is single-line and clipped to fit; `title` carries the full text.
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user