Compare commits

...

15 Commits

31 changed files with 1985 additions and 951 deletions
+1
View File
@@ -482,6 +482,7 @@ func main() {
router.Handle("POST", "/api/admin/pki/acme/orders", api.CreateACMEOrder)
router.Handle("POST", "/api/admin/pki/acme/orders/:id/finalize", api.FinalizeACMEOrder)
router.Handle("DELETE", "/api/admin/pki/acme/orders/:id", api.DeleteACMEOrder)
router.Handle("GET", "/api/admin/rpm/mirrors", api.ListAdminRPMMirrors)
router.Handle("GET", "/api/admin/ssh/user-cas", api.ListSSHUserCAs)
router.Handle("POST", "/api/admin/ssh/user-cas", api.CreateSSHUserCA)
router.Handle("GET", "/api/admin/ssh/user-cas/:id", api.GetSSHUserCA)
+49
View File
@@ -296,6 +296,55 @@ func (s *Store) ListRPMMirrorRuns(repoID string, path string, limit int) ([]mode
return out, nil
}
func (s *Store) ListAdminRPMMirrorStatus() ([]models.RPMMirrorStatus, error) {
var rows *sql.Rows
var out []models.RPMMirrorStatus
var item models.RPMMirrorStatus
var err error
rows, err = s.DB.Query(`SELECT p.public_id, p.slug, r.public_id, r.name, d.path, d.sync_enabled, d.dirty, d.next_sync_at, d.sync_running, d.sync_status, d.sync_error, d.sync_step, d.sync_total, d.sync_done, d.sync_failed, d.sync_deleted, d.last_sync_started_at, d.last_sync_finished_at, d.last_sync_success_at, d.updated_at
FROM rpm_repo_dirs d
JOIN repos r ON r.id = d.repo_id
JOIN projects p ON p.id = r.project_id
WHERE d.mode = 'mirror'
ORDER BY d.sync_running DESC, d.updated_at DESC, p.slug, r.name, d.path`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(
&item.ProjectID,
&item.ProjectSlug,
&item.RepoID,
&item.RepoName,
&item.Path,
&item.SyncEnabled,
&item.Dirty,
&item.NextSyncAt,
&item.SyncRunning,
&item.SyncStatus,
&item.SyncError,
&item.SyncStep,
&item.SyncTotal,
&item.SyncDone,
&item.SyncFailed,
&item.SyncDeleted,
&item.LastSyncStartedAt,
&item.LastSyncFinishedAt,
&item.LastSyncSuccessAt,
&item.UpdatedAt)
if err != nil {
return nil, err
}
out = append(out, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return out, nil
}
func (s *Store) DeleteRPMMirrorRuns(repoID string, path string) (int64, error) {
var res sql.Result
var count int64
+82
View File
@@ -1180,6 +1180,88 @@ func (s *Store) ListProjectsFilteredForUser(userID string, limit int, offset int
return projects, rows.Err()
}
func (s *Store) CountProjectsFiltered(query string) (int, error) {
var row *sql.Row
var err error
var total int
if query == "" {
row = s.DB.QueryRow(`SELECT COUNT(*) FROM projects`)
} else {
row = s.DB.QueryRow(
`SELECT COUNT(*)
FROM projects p
WHERE p.name LIKE ? OR p.slug LIKE ?`,
"%"+query+"%",
"%"+query+"%",
)
}
err = row.Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func (s *Store) CountProjectsFilteredForUser(userID string, query string) (int, error) {
var row *sql.Row
var err error
var total int
if query == "" {
row = s.DB.QueryRow(
`SELECT COUNT(*)
FROM projects p
WHERE EXISTS (
SELECT 1
FROM project_role_bindings b
WHERE b.project_id = p.id
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
))
)
)`,
userID,
userID,
)
} else {
row = s.DB.QueryRow(
`SELECT COUNT(*)
FROM projects p
WHERE EXISTS (
SELECT 1
FROM project_role_bindings b
WHERE b.project_id = p.id
AND (
(b.subject_type = 'user' AND b.subject_public_id = ?)
OR
(b.subject_type = 'group' AND b.subject_public_id IN (
SELECT g.public_id
FROM user_group_members gm
JOIN users ux ON ux.id = gm.user_id
JOIN user_groups g ON g.id = gm.group_id
WHERE ux.public_id = ? AND g.disabled = 0
))
)
) AND (p.name LIKE ? OR p.slug LIKE ?)`,
userID,
userID,
"%"+query+"%",
"%"+query+"%",
)
}
err = row.Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func (s *Store) DeleteProject(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM projects WHERE public_id = ?`, id)
+34 -1
View File
@@ -151,6 +151,11 @@ type createProjectRequest struct {
HomePage string `json:"home_page"`
}
type projectListResponse struct {
Items []models.Project `json:"items"`
Total int `json:"total"`
}
type createRepoRequest struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -1320,9 +1325,11 @@ func (api *API) DeletePrincipalProjectRole(w http.ResponseWriter, r *http.Reques
func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var projects []models.Project
var resp projectListResponse
var err error
var limit int
var offset int
var total int
var query string
var v string
var i int
@@ -1353,21 +1360,33 @@ func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[strin
if user.IsAdmin {
if query != "" || limit > 0 || offset > 0 {
projects, err = api.Store.ListProjectsFiltered(limit, offset, query)
if err == nil {
total, err = api.Store.CountProjectsFiltered(query)
}
} else {
projects, err = api.Store.ListProjects()
total = len(projects)
}
} else {
if query != "" || limit > 0 || offset > 0 {
projects, err = api.Store.ListProjectsFilteredForUser(user.ID, limit, offset, query)
if err == nil {
total, err = api.Store.CountProjectsFilteredForUser(user.ID, query)
}
} else {
projects, err = api.Store.ListProjectsForUser(user.ID)
total = len(projects)
}
}
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, projects)
resp = projectListResponse{
Items: projects,
Total: total,
}
WriteJSON(w, http.StatusOK, resp)
}
func (api *API) GetProject(w http.ResponseWriter, r *http.Request, params map[string]string) {
@@ -4510,6 +4529,20 @@ func (api *API) EnableAdminAPIKey(w http.ResponseWriter, r *http.Request, params
w.WriteHeader(http.StatusNoContent)
}
func (api *API) ListAdminRPMMirrors(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var items []models.RPMMirrorStatus
var err error
if !api.requireAdmin(w, r) {
return
}
items, err = api.Store.ListAdminRPMMirrorStatus()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) RepoDockerDeleteTag(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
+23
View File
@@ -138,6 +138,29 @@ type RPMMirrorRun struct {
Error string `json:"error"`
}
type RPMMirrorStatus struct {
ProjectID string `json:"project_id"`
ProjectSlug string `json:"project_slug"`
RepoID string `json:"repo_id"`
RepoName string `json:"repo_name"`
Path string `json:"path"`
SyncEnabled bool `json:"sync_enabled"`
Dirty bool `json:"dirty"`
NextSyncAt int64 `json:"next_sync_at"`
SyncRunning bool `json:"sync_running"`
SyncStatus string `json:"sync_status"`
SyncError string `json:"sync_error"`
SyncStep string `json:"sync_step"`
SyncTotal int64 `json:"sync_total"`
SyncDone int64 `json:"sync_done"`
SyncFailed int64 `json:"sync_failed"`
SyncDeleted int64 `json:"sync_deleted"`
LastSyncStartedAt int64 `json:"last_sync_started_at"`
LastSyncFinishedAt int64 `json:"last_sync_finished_at"`
LastSyncSuccessAt int64 `json:"last_sync_success_at"`
UpdatedAt int64 `json:"updated_at"`
}
type Issue struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
+30 -1
View File
@@ -22,6 +22,11 @@ export interface Project {
updated_at?: number
}
export interface ProjectListPage {
items: Project[]
total: number
}
export interface Repo {
id: string
project_id: string
@@ -148,6 +153,29 @@ export interface RpmMirrorRun {
error: string
}
export interface RpmMirrorStatus {
project_id: string
project_slug: string
repo_id: string
repo_name: string
path: string
sync_enabled: boolean
dirty: boolean
next_sync_at: number
sync_running: boolean
sync_status: string
sync_error: string
sync_step: string
sync_total: number
sync_done: number
sync_failed: number
sync_deleted: number
last_sync_started_at: number
last_sync_finished_at: number
last_sync_success_at: number
updated_at: number
}
export interface RepoTypeItem {
value: string
label: string
@@ -804,6 +832,7 @@ export const api = {
const qs = params.toString()
return request<ACMEOrder[]>(`/api/admin/pki/acme/orders${qs ? `?${qs}` : ''}`)
},
listAdminRpmMirrors: () => request<RpmMirrorStatus[]>('/api/admin/rpm/mirrors'),
createACMEOrder: (payload: { profile_id: string; common_name: string; san_dns: string[] }) =>
request<ACMEOrder>('/api/admin/pki/acme/orders', { method: 'POST', body: JSON.stringify(payload) }),
finalizeACMEOrder: (id: string, renew_mode?: 'override' | 'new_cert') =>
@@ -913,7 +942,7 @@ export const api = {
if (offset) params.set('offset', String(offset))
if (query) params.set('q', query)
const qs = params.toString()
return request<Project[]>(`/api/projects${qs ? `?${qs}` : ''}`)
return request<ProjectListPage>(`/api/projects${qs ? `?${qs}` : ''}`)
},
listAllRepos: (query?: string, repoType?: string) => {
const params = new URLSearchParams()
+22 -39
View File
@@ -1,19 +1,21 @@
import { CssBaseline, GlobalStyles } from '@mui/material'
import { createTheme, ThemeProvider } from '@mui/material/styles'
import { ThemeProvider } from '@mui/material/styles'
import { useEffect, useMemo, useState } from 'react'
import { useRoutes } from 'react-router-dom'
import { routes } from './routes'
import { ThemeModeContext } from './ThemeModeContext'
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"'
import { createAppTheme, isThemeScheme, ThemeScheme } from './theme'
export default function App() {
const element = useRoutes(routes)
const [mode, setMode] = useState<'light' | 'dark'>(() => {
const stored = localStorage.getItem('codit-theme')
const stored = localStorage.getItem('codit-theme-mode') || localStorage.getItem('codit-theme')
return stored === 'dark' ? 'dark' : 'light'
})
const [scheme, setScheme] = useState<ThemeScheme>(() => {
const stored = localStorage.getItem('codit-theme-scheme')
return isThemeScheme(stored) ? stored : 'blue'
})
useEffect(() => {
let mounted = true
@@ -41,44 +43,25 @@ export default function App() {
}
}, [mode])
const toggleMode = () => {
setMode((prev) => {
const next = prev === 'light' ? 'dark' : 'light'
localStorage.setItem('codit-theme', next)
return next
})
const updateMode = (next: 'light' | 'dark') => {
localStorage.setItem('codit-theme-mode', next)
localStorage.setItem('codit-theme', next)
setMode(next)
}
const theme = useMemo(() => {
return createTheme({
palette: {
mode
},
typography: {
fontFamily
},
components: {
MuiDialog: {
defaultProps: {
disableRestoreFocus: true
}
},
MuiPaper: {
styleOverrides: {
root: {
boxShadow: 'none',
backgroundImage: 'none',
border: '0',
padding: '1px !important'
}
}
}
}
})
}, [mode])
const toggleMode = () => {
updateMode(mode === 'light' ? 'dark' : 'light')
}
const updateScheme = (next: ThemeScheme) => {
localStorage.setItem('codit-theme-scheme', next)
setScheme(next)
}
const theme = useMemo(() => createAppTheme(mode, scheme), [mode, scheme])
return (
<ThemeModeContext.Provider value={{ mode, toggleMode }}>
<ThemeModeContext.Provider value={{ mode, scheme, setMode: updateMode, toggleMode, setScheme: updateScheme }}>
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
+60 -5
View File
@@ -34,10 +34,10 @@ import HistoryIcon from '@mui/icons-material/History'
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'
import GroupWorkIcon from '@mui/icons-material/GroupWork'
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import PaletteIcon from '@mui/icons-material/Palette'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import { ThemeModeContext } from './ThemeModeContext'
import { themeSchemeOptions } from './theme'
const drawerWidth = 220
const drawerCollapsedWidth = 64
@@ -46,6 +46,7 @@ export default function Layout() {
const [user, setUser] = useState<User | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(true)
const [appearanceMenuAnchor, setAppearanceMenuAnchor] = useState<HTMLElement | null>(null)
const [accountMenuAnchor, setAccountMenuAnchor] = useState<HTMLElement | null>(null)
const themeMode = useContext(ThemeModeContext)
const navigate = useNavigate()
@@ -99,10 +100,18 @@ export default function Layout() {
setAccountMenuAnchor(event.currentTarget)
}
const openAppearanceMenu = (event: React.MouseEvent<HTMLElement>) => {
setAppearanceMenuAnchor(event.currentTarget)
}
const closeAccountMenu = () => {
setAccountMenuAnchor(null)
}
const closeAppearanceMenu = () => {
setAppearanceMenuAnchor(null)
}
const openAccountPage = () => {
closeAccountMenu()
navigate('/account')
@@ -126,9 +135,55 @@ export default function Layout() {
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Codit
</Typography>
<IconButton color="inherit" onClick={themeMode.toggleMode} aria-label="Toggle theme">
{themeMode.mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
<Button color="inherit" onClick={openAppearanceMenu} startIcon={<PaletteIcon />} endIcon={<KeyboardArrowDownIcon />}>
Appearance
</Button>
<Menu
anchorEl={appearanceMenuAnchor}
open={Boolean(appearanceMenuAnchor)}
onClose={closeAppearanceMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
border: (theme) => `1px solid ${theme.palette.divider}`
}
}}
>
<MenuItem disabled>Mode</MenuItem>
<MenuItem
selected={themeMode.mode === 'light'}
onClick={() => {
themeMode.setMode('light')
closeAppearanceMenu()
}}
>
Light
</MenuItem>
<MenuItem
selected={themeMode.mode === 'dark'}
onClick={() => {
themeMode.setMode('dark')
closeAppearanceMenu()
}}
>
Dark
</MenuItem>
<Divider />
<MenuItem disabled>Scheme</MenuItem>
{themeSchemeOptions.map((item) => (
<MenuItem
key={item.value}
selected={themeMode.scheme === item.value}
onClick={() => {
themeMode.setScheme(item.value)
closeAppearanceMenu()
}}
>
{item.label}
</MenuItem>
))}
</Menu>
{user ? (
<>
<Button color="inherit" onClick={openAccountMenu} endIcon={<KeyboardArrowDownIcon />}>
+8 -1
View File
@@ -1,11 +1,18 @@
import { createContext } from 'react'
import { ThemeScheme } from './theme'
export type ThemeModeContextValue = {
mode: 'light' | 'dark'
scheme: ThemeScheme
setMode: (mode: 'light' | 'dark') => void
toggleMode: () => void
setScheme: (scheme: ThemeScheme) => void
}
export const ThemeModeContext = createContext<ThemeModeContextValue>({
mode: 'light',
toggleMode: () => undefined
scheme: 'blue',
setMode: () => undefined,
toggleMode: () => undefined,
setScheme: () => undefined
})
+170
View File
@@ -0,0 +1,170 @@
import { PaletteMode } from '@mui/material'
import { alpha, createTheme } from '@mui/material/styles'
export type ThemeScheme = 'blue' | 'graphite' | 'forest' | 'copper' | 'ocean' | 'olive'
type SchemeTokens = {
primary: string
secondary: string
backgroundDefault: string
backgroundPaper: string
}
type SchemeDefinition = {
label: string
light: SchemeTokens
dark: SchemeTokens
}
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"'
const schemeDefinitions: Record<ThemeScheme, SchemeDefinition> = {
blue: {
label: 'Blue',
light: {
primary: '#2b5fc7',
secondary: '#537fd8',
backgroundDefault: '#f5f7fb',
backgroundPaper: '#ffffff'
},
dark: {
primary: '#82a7ff',
secondary: '#a9c1ff',
backgroundDefault: '#0f1522',
backgroundPaper: '#141d2e'
}
},
graphite: {
label: 'Graphite',
light: {
primary: '#556274',
secondary: '#7b8899',
backgroundDefault: '#f4f5f7',
backgroundPaper: '#ffffff'
},
dark: {
primary: '#a6b0bd',
secondary: '#c1c8d1',
backgroundDefault: '#121417',
backgroundPaper: '#191d22'
}
},
forest: {
label: 'Forest',
light: {
primary: '#2d6b4f',
secondary: '#5c987b',
backgroundDefault: '#f3f7f4',
backgroundPaper: '#ffffff'
},
dark: {
primary: '#7cc7a2',
secondary: '#a4debf',
backgroundDefault: '#0f1a15',
backgroundPaper: '#15231d'
}
},
copper: {
label: 'Copper',
light: {
primary: '#a65d2b',
secondary: '#c9875f',
backgroundDefault: '#faf5f1',
backgroundPaper: '#ffffff'
},
dark: {
primary: '#e0a26f',
secondary: '#efbb93',
backgroundDefault: '#1a130f',
backgroundPaper: '#241a15'
}
},
ocean: {
label: 'Ocean',
light: {
primary: '#0f6a82',
secondary: '#4e94a8',
backgroundDefault: '#f2f7f9',
backgroundPaper: '#ffffff'
},
dark: {
primary: '#71c7de',
secondary: '#9bd7e7',
backgroundDefault: '#0d171b',
backgroundPaper: '#132127'
}
},
olive: {
label: 'Olive',
light: {
primary: '#6c7a2d',
secondary: '#95a053',
backgroundDefault: '#f7f8f1',
backgroundPaper: '#ffffff'
},
dark: {
primary: '#c0d07d',
secondary: '#d6e3a7',
backgroundDefault: '#17180f',
backgroundPaper: '#212316'
}
}
}
export const themeSchemeOptions = Object.entries(schemeDefinitions).map(([value, definition]) => ({
value: value as ThemeScheme,
label: definition.label
}))
export function isThemeScheme(value: string | null): value is ThemeScheme {
if (!value) {
return false
}
return Object.prototype.hasOwnProperty.call(schemeDefinitions, value)
}
export function createAppTheme(mode: PaletteMode, scheme: ThemeScheme) {
const tokens = mode === 'dark' ? schemeDefinitions[scheme].dark : schemeDefinitions[scheme].light
return createTheme({
palette: {
mode,
primary: {
main: tokens.primary
},
secondary: {
main: tokens.secondary
},
background: {
default: tokens.backgroundDefault,
paper: tokens.backgroundPaper
},
action: {
hover: alpha(tokens.primary, mode === 'dark' ? 0.12 : 0.06),
selected: alpha(tokens.primary, mode === 'dark' ? 0.22 : 0.12),
focus: alpha(tokens.primary, mode === 'dark' ? 0.18 : 0.1)
}
},
typography: {
fontFamily
},
components: {
MuiDialog: {
defaultProps: {
disableRestoreFocus: true
}
},
MuiPaper: {
styleOverrides: {
root: {
boxShadow: 'none',
backgroundImage: 'none',
border: '0',
padding: '1px !important'
}
}
}
}
})
}
+2 -1
View File
@@ -27,7 +27,8 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
justifyContent: 'space-between'
justifyContent: 'space-between',
backgroundColor: 'transparent'
},
sx
]}
+51
View File
@@ -0,0 +1,51 @@
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography'
import { ReactNode } from 'react'
type SectionCardProps = {
title: ReactNode
subtitle?: string
actions?: ReactNode
children: ReactNode
}
export default function SectionCard(props: SectionCardProps) {
return (
<Paper
sx={{
display: 'grid',
width: '100%',
maxWidth: '100%',
minWidth: 0,
borderRadius: 1,
backgroundColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.015)'
}}
>
<Box
sx={{
display: 'grid',
gap: 0.25,
px: 2,
pt: 2,
pb: 1,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
{typeof props.title === 'string' ? <Typography variant="h6">{props.title}</Typography> : props.title}
{props.actions ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{props.actions}</Box> : null}
</Box>
{props.subtitle ? (
<Typography variant="body2" color="text.secondary">
{props.subtitle}
</Typography>
) : null}
</Box>
<Box sx={{ display: 'grid', gap: 1, px: 2, pt: 1.25, pb: 2 }}>
{props.children}
</Box>
</Paper>
)
}
+45 -48
View File
@@ -12,12 +12,13 @@ import {
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { AdminAPIKey, api, User } from '../api'
function formatUnix(value: number) {
@@ -43,11 +44,11 @@ export default function AdminApiKeysPage() {
const [bulkConfirm, setBulkConfirm] = useState('')
const [bulkDeleting, setBulkDeleting] = useState(false)
const load = async () => {
const load = async (nextUserID?: string, nextQuery?: string) => {
setLoading(true)
setError(null)
try {
const list = await api.listAdminAPIKeys(userID || undefined, query.trim() || undefined)
const list = await api.listAdminAPIKeys(nextUserID || undefined, nextQuery ? nextQuery.trim() || undefined : undefined)
setKeys(Array.isArray(list) ? list : [])
setSelected([])
} catch (err) {
@@ -67,12 +68,12 @@ export default function AdminApiKeysPage() {
}, [])
useEffect(() => {
load()
}, [userID])
const handleSearch = () => {
load()
}
var timerID: number
timerID = window.setTimeout(() => {
load(userID, query).catch(() => {})
}, 250)
return () => window.clearTimeout(timerID)
}, [userID, query])
const handleDelete = async () => {
if (!deleteTarget) {
@@ -149,50 +150,46 @@ export default function AdminApiKeysPage() {
<Typography variant="h5" sx={{ mb: 2 }}>
Admin: API Keys
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
size="small"
label="Search"
placeholder="key name, prefix, username, email"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleSearch()
}
}}
sx={{ minWidth: 280 }}
/>
<TextField
size="small"
select
label="User"
value={userID}
onChange={(event) => setUserID(event.target.value)}
sx={{ minWidth: 220 }}
>
<MenuItem value="">All users</MenuItem>
{users.map((user) => (
<MenuItem key={user.id} value={user.id}>
{user.username}
</MenuItem>
))}
</TextField>
<Button variant="outlined" onClick={handleSearch}>
Search
</Button>
<Button
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<Typography variant="h6">API Keys</Typography>
<TextField
size="small"
label="Search"
placeholder="key name, prefix, username, email"
value={query}
onChange={(event) => setQuery(event.target.value)}
sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
/>
<TextField
size="small"
select
label="User"
value={userID}
onChange={(event) => setUserID(event.target.value)}
sx={{ minWidth: 180 }}
>
<MenuItem value="">All users</MenuItem>
{users.map((user) => (
<MenuItem key={user.id} value={user.id}>
{user.username}
</MenuItem>
))}
</TextField>
</Box>
}
actions={
<HeaderActionButton
variant="outlined"
color="error"
disabled={!selected.length}
onClick={() => setBulkOpen(true)}
>
Revoke Selected ({selected.length})
</Button>
</Box>
</Paper>
<Paper sx={{ p: 2 }}>
</HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? (
<Typography variant="body2" color="text.secondary">
@@ -254,7 +251,7 @@ export default function AdminApiKeysPage() {
) : null}
</List>
)}
</Paper>
</SectionCard>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Revoke API Key</DialogTitle>
<DialogContent>
@@ -14,12 +14,13 @@ import {
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, PKICA, PKIClientProfile, User, UserGroup } from '../api'
function fmt(value: number): string {
@@ -217,12 +218,16 @@ export default function AdminPKIClientProfilesPage() {
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: Client Cert Profiles</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New Profile</Button>
</Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 1 }}>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Client Cert Profiles</Typography>
<SectionCard
title="Client Cert Profiles"
actions={
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
New Profile
</HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<List dense>
{items.map((item) => (
<ListItem
@@ -255,7 +260,7 @@ export default function AdminPKIClientProfilesPage() {
</ListItem>
) : null}
</List>
</Paper>
</SectionCard>
<Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} fullWidth maxWidth="md">
<DialogTitle>{editID ? 'Edit Client Certificate Profile' : 'New Client Certificate Profile'}</DialogTitle>
+230 -159
View File
@@ -1,4 +1,3 @@
import AddIcon from '@mui/icons-material/Add'
import DownloadIcon from '@mui/icons-material/Download'
import Alert from '@mui/material/Alert'
import {
@@ -14,12 +13,13 @@ import {
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { ACMEOrder, ACMEProfile, api, PKICA, PKICADetail, PKICert, PKICertDetail } from '../api'
function fmt(ts: number): string {
@@ -63,6 +63,8 @@ export default function AdminPKIPage() {
const [acmeProfiles, setACMEProfiles] = useState<ACMEProfile[]>([])
const [acmeOrders, setACMEOrders] = useState<ACMEOrder[]>([])
const [selectedCA, setSelectedCA] = useState('')
const [caQuery, setCAQuery] = useState('')
const [certQuery, setCertQuery] = useState('')
const [error, setError] = useState<string | null>(null)
const [dialogError, setDialogError] = useState<string | null>(null)
@@ -583,171 +585,234 @@ export default function AdminPKIPage() {
}
}
const filteredCAs = cas.filter((ca) => {
const q = caQuery.trim().toLowerCase()
if (!q) {
return true
}
return ca.name.toLowerCase().includes(q) || ca.id.toLowerCase().includes(q)
})
const filteredCerts = certs.filter((cert) => {
const q = certQuery.trim().toLowerCase()
if (!q) {
return true
}
return (
cert.common_name.toLowerCase().includes(q) ||
cert.id.toLowerCase().includes(q) ||
cert.serial_hex.toLowerCase().includes(q) ||
(cert.ca_id || '').toLowerCase().includes(q)
)
})
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: PKI</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => { setDialogError(null); setRootOpen(true) }}>
New Root CA
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setDialogError(null)
setInterCertPEM('')
setInterKeyPEM('')
setInterOpen(true)
}}
>
New Intermediate CA
</Button>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => { setDialogError(null); setIssueOpen(true) }}>
Issue Certificate
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setDialogError(null)
setImportCA('')
setImportCertPEM('')
setImportKeyPEM('')
setImportOpen(true)
}}
>
Import Certificate
</Button>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openNewACME}>
New ACME Profile
</Button>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openNewACMEOrder} disabled={acmeProfiles.length === 0}>
New ACME Order
</Button>
</Box>
</Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Certificate Authorities</Typography>
<List>
{cas.map((ca) => (
<ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText
primary={`${ca.name} (${ca.id})`}
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
sx={{ minWidth: 0, m: 0 }}
/>
<ListRowActions>
<ListRowActionButton
onClick={async () => {
const data = await api.getPKICRL(ca.id)
const blob = new Blob([data.crl_pem], { type: 'application/x-pem-file' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
}}
>
CRL
</ListRowActionButton>
<ListRowActionButton onClick={() => openCAView(ca.id)}>
View
</ListRowActionButton>
<ListRowActionButton onClick={() => openCAEdit(ca.id)}>
Edit
</ListRowActionButton>
<ListRowActionButton
color="error"
onClick={() => {
setDialogError(null)
setDeleteCAID(ca.id)
setDeleteCAName(ca.name)
setDeleteCAConfirm('')
setDeleteCAForce(false)
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))}
</List>
</Paper>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Issued Certificates</Typography>
<TextField
select
size="small"
label="CA"
value={selectedCA}
onChange={(event) => setSelectedCA(event.target.value)}
sx={{ minWidth: 280 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="standalone">(standalone)</MenuItem>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
<Box sx={{ display: 'grid', gap: 2 }}>
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
<Typography variant="h6">Certificate Authorities</Typography>
<TextField
size="small"
label="Search"
placeholder="Name or id"
value={caQuery}
onChange={(event) => setCAQuery(event.target.value)}
sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
/>
</Box>
}
actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<HeaderActionButton variant="outlined" onClick={() => { setDialogError(null); setRootOpen(true) }}>
New Root CA
</HeaderActionButton>
<HeaderActionButton
variant="outlined"
onClick={() => {
setDialogError(null)
setInterCertPEM('')
setInterKeyPEM('')
setInterOpen(true)
}}
>
New Intermediate CA
</HeaderActionButton>
</Box>
}
>
<List>
{filteredCAs.map((ca) => (
<ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText
primary={`${ca.name} (${ca.id})`}
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
sx={{ minWidth: 0, m: 0 }}
/>
<ListRowActions>
<ListRowActionButton
onClick={async () => {
const data = await api.getPKICRL(ca.id)
const blob = new Blob([data.crl_pem], { type: 'application/x-pem-file' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
}}
>
CRL
</ListRowActionButton>
<ListRowActionButton onClick={() => openCAView(ca.id)}>
View
</ListRowActionButton>
<ListRowActionButton onClick={() => openCAEdit(ca.id)}>
Edit
</ListRowActionButton>
<ListRowActionButton
color="error"
onClick={() => {
setDialogError(null)
setDeleteCAID(ca.id)
setDeleteCAName(ca.name)
setDeleteCAConfirm('')
setDeleteCAForce(false)
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))}
</TextField>
</Box>
<List>
{certs.map((cert) => (
<ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText
primary={`${cert.common_name} (${cert.id})`}
secondary={`serial: ${cert.serial_hex} · ca: ${cert.ca_id || 'standalone'} · status: ${cert.status} · valid: ${fmt(cert.not_before)} ~ ${fmt(cert.not_after)}${cert.revoked_at ? ` · revoked: ${fmt(cert.revoked_at)}` : ''}`}
sx={{ minWidth: 0, m: 0 }}
/>
<ListRowActions>
<ListRowActionButton onClick={() => openCertView(cert.id)}>
View
</ListRowActionButton>
<ListRowActionButton
color="warning"
onClick={() => setRevokeID(cert.id)}
disabled={cert.status === 'revoked'}
>
Revoke
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setDeleteID(cert.id)}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))}
</List>
</Paper>
{filteredCAs.length === 0 ? (
<ListItem>
<ListItemText primary="No certificate authorities found." />
</ListItem>
) : null}
</List>
</SectionCard>
<Paper sx={{ p: 2, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>ACME Profiles</Typography>
<List>
{acmeProfiles.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText
primary={`${item.name} (${item.id})`}
secondary={`${item.directory_url} · solver: ${item.solver_type === 'acme_dns' ? `acme-dns (${item.acme_dns_full_domain || '-'})` : 'manual'} · email: ${item.email || '-'} · account: ${item.account_url || '-'} · enabled: ${item.enabled ? 'yes' : 'no'}${item.last_error ? ` · last error: ${item.last_error}` : ''}`}
sx={{ minWidth: 0, m: 0 }}
/>
<ListRowActions>
<ListRowActionButton onClick={() => openEditACME(item)}>
Edit
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setACMEDeleteID(item.id)}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))}
</List>
</Paper>
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
<Typography variant="h6">Issued Certificates</Typography>
<TextField
size="small"
label="Search"
placeholder="CN, id, serial, CA"
value={certQuery}
onChange={(event) => setCertQuery(event.target.value)}
sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
/>
<TextField
select
size="small"
label="CA"
value={selectedCA}
onChange={(event) => setSelectedCA(event.target.value)}
sx={{ minWidth: 280 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="standalone">(standalone)</MenuItem>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
</Box>
}
actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<HeaderActionButton variant="outlined" onClick={() => { setDialogError(null); setIssueOpen(true) }}>
Issue
</HeaderActionButton>
<HeaderActionButton
variant="outlined"
onClick={() => {
setDialogError(null)
setImportCA('')
setImportCertPEM('')
setImportKeyPEM('')
setImportOpen(true)
}}
>
Import
</HeaderActionButton>
</Box>
}
>
<List>
{filteredCerts.map((cert) => (
<ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText
primary={`${cert.common_name} (${cert.id})`}
secondary={`serial: ${cert.serial_hex} · ca: ${cert.ca_id || 'standalone'} · status: ${cert.status} · valid: ${fmt(cert.not_before)} ~ ${fmt(cert.not_after)}${cert.revoked_at ? ` · revoked: ${fmt(cert.revoked_at)}` : ''}`}
sx={{ minWidth: 0, m: 0 }}
/>
<ListRowActions>
<ListRowActionButton onClick={() => openCertView(cert.id)}>
View
</ListRowActionButton>
<ListRowActionButton
color="warning"
onClick={() => setRevokeID(cert.id)}
disabled={cert.status === 'revoked'}
>
Revoke
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setDeleteID(cert.id)}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))}
{filteredCerts.length === 0 ? (
<ListItem>
<ListItemText primary="No issued certificates found." />
</ListItem>
) : null}
</List>
</SectionCard>
<SectionCard
title="ACME Profiles"
actions={
<HeaderActionButton variant="outlined" onClick={openNewACME}>
New ACME Profile
</HeaderActionButton>
}
>
<List>
{acmeProfiles.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText
primary={`${item.name} (${item.id})`}
secondary={`${item.directory_url} · solver: ${item.solver_type === 'acme_dns' ? `acme-dns (${item.acme_dns_full_domain || '-'})` : 'manual'} · email: ${item.email || '-'} · account: ${item.account_url || '-'} · enabled: ${item.enabled ? 'yes' : 'no'}${item.last_error ? ` · last error: ${item.last_error}` : ''}`}
sx={{ minWidth: 0, m: 0 }}
/>
<ListRowActions>
<ListRowActionButton onClick={() => openEditACME(item)}>
Edit
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setACMEDeleteID(item.id)}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))}
</List>
</SectionCard>
</Box>
<Dialog open={acmeOpen} onClose={() => setACMEOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
@@ -837,8 +902,14 @@ export default function AdminPKIPage() {
</DialogActions>
</Dialog>
<Paper sx={{ p: 2, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>ACME Orders (DNS-01)</Typography>
<SectionCard
title="ACME Orders (DNS-01)"
actions={
<HeaderActionButton variant="outlined" onClick={openNewACMEOrder} disabled={acmeProfiles.length === 0}>
New ACME Order
</HeaderActionButton>
}
>
<List>
{acmeOrders.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
@@ -863,7 +934,7 @@ export default function AdminPKIPage() {
</ListItem>
))}
</List>
</Paper>
</SectionCard>
<Dialog open={acmeOrderOpen} onClose={() => setACMEOrderOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
@@ -14,12 +14,13 @@ import {
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHPrincipalGrant, User, UserGroup } from '../api'
function fmt(value: number): string {
@@ -228,59 +229,65 @@ export default function AdminSSHPrincipalGrantsPage() {
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: SSH Principal Grants</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New Grant</Button>
</Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<TextField
select
size="small"
label="Filter Target Type"
value={filterTargetType}
onChange={(event) => {
setFilterTargetType(event.target.value as 'user' | 'group' | '')
setFilterTargetID('')
}}
sx={{ minWidth: 180 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="user">User</MenuItem>
<MenuItem value="group">Group</MenuItem>
</TextField>
{filterTargetType === 'user' ? (
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Principal Grants</Typography>
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
<Typography variant="h6">SSH Principal Grants</Typography>
<TextField
select
size="small"
label="User"
value={filterTargetID}
onChange={(event) => setFilterTargetID(event.target.value)}
sx={{ minWidth: 260 }}
label="Filter"
value={filterTargetType}
onChange={(event) => {
setFilterTargetType(event.target.value as 'user' | 'group' | '')
setFilterTargetID('')
}}
sx={{ minWidth: 160 }}
>
<MenuItem value="">(all users)</MenuItem>
{users.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.display_name ? `${item.display_name} (${item.username})` : item.username}</MenuItem>
))}
<MenuItem value="">(all)</MenuItem>
<MenuItem value="user">User</MenuItem>
<MenuItem value="group">Group</MenuItem>
</TextField>
) : null}
{filterTargetType === 'group' ? (
<TextField
select
size="small"
label="Group"
value={filterTargetID}
onChange={(event) => setFilterTargetID(event.target.value)}
sx={{ minWidth: 220 }}
>
<MenuItem value="">(all groups)</MenuItem>
{groups.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</TextField>
) : null}
</Box>
{filterTargetType === 'user' ? (
<TextField
select
size="small"
label="User"
value={filterTargetID}
onChange={(event) => setFilterTargetID(event.target.value)}
sx={{ minWidth: 240 }}
>
<MenuItem value="">(all users)</MenuItem>
{users.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.display_name ? `${item.display_name} (${item.username})` : item.username}</MenuItem>
))}
</TextField>
) : null}
{filterTargetType === 'group' ? (
<TextField
select
size="small"
label="Group"
value={filterTargetID}
onChange={(event) => setFilterTargetID(event.target.value)}
sx={{ minWidth: 220 }}
>
<MenuItem value="">(all groups)</MenuItem>
{groups.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</TextField>
) : null}
</Box>
}
actions={
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
New Grant
</HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? <Typography variant="body2" color="text.secondary">Loading principal grants...</Typography> : null}
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No principal grants configured.</Typography> : null}
{!loading ? (
@@ -306,7 +313,7 @@ export default function AdminSSHPrincipalGrantsPage() {
))}
</List>
) : null}
</Paper>
</SectionCard>
<Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} maxWidth="md" fullWidth>
<DialogTitle>{editID ? 'Edit Principal Grant' : 'New Principal Grant'}</DialogTitle>
+38 -46
View File
@@ -7,12 +7,12 @@ import {
DialogContent,
DialogTitle,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHUserCA, SSHUserCAIssuance } from '../api'
function fmt(value: number): string {
@@ -88,57 +88,49 @@ export default function AdminSSHSignHistoryPage() {
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: SSH Signing History</Typography>
</Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Signing History</Typography>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, mb: 2, display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
select
size="small"
label="CA"
value={caID}
onChange={(event) => {
const nextCAID = event.target.value
setCAID(nextCAID)
load(nextCAID).catch(() => {})
}}
sx={{ minWidth: 280 }}
>
<MenuItem value="">(all)</MenuItem>
{cas.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.name} ({item.id})
</MenuItem>
))}
</TextField>
<TextField
size="small"
label="Limit"
value={limitText}
onChange={(event) => setLimitText(event.target.value)}
sx={{ width: 120 }}
/>
<Button variant="outlined" onClick={() => load().catch(() => {})} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</Button>
</Paper>
<Paper sx={{ p: 2, display: 'grid', gap: 1 }}>
<SectionCard title="SSH Signing History">
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
select
size="small"
label="CA"
value={caID}
onChange={(event) => {
const nextCAID = event.target.value
setCAID(nextCAID)
load(nextCAID).catch(() => {})
}}
sx={{ minWidth: 280 }}
>
<MenuItem value="">(all)</MenuItem>
{cas.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.name} ({item.id})
</MenuItem>
))}
</TextField>
<TextField
size="small"
label="Limit"
value={limitText}
onChange={(event) => setLimitText(event.target.value)}
sx={{ width: 120 }}
/>
<Button variant="outlined" onClick={() => load().catch(() => {})} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</Button>
</Box>
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null}
{items.map((item) => (
<Paper
<Box
key={item.id}
variant="outlined"
sx={{
p: 1,
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxShadow: 'none'
minWidth: 0
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minWidth: 0 }}>
@@ -161,9 +153,9 @@ export default function AdminSSHSignHistoryPage() {
<ListRowActionButton onClick={() => inspectCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</ListRowActionButton>
</ListRowActions>
</Box>
</Paper>
</Box>
))}
</Paper>
</SectionCard>
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Inspect</DialogTitle>
+13 -8
View File
@@ -14,12 +14,13 @@ import {
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHSignUserKeyResponse, SSHUserCA } from '../api'
function fmt(value: number): string {
@@ -278,12 +279,16 @@ export default function AdminSSHUserCAPage() {
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: SSH User CA</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New SSH User CA</Button>
</Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2 }}>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH User CA</Typography>
<SectionCard
title="SSH User CAs"
actions={
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
New SSH User CA
</HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? (
<Typography variant="body2" color="text.secondary">Loading SSH User CAs...</Typography>
) : (
@@ -325,7 +330,7 @@ export default function AdminSSHUserCAPage() {
{!items.length ? <Typography variant="body2" color="text.secondary">No SSH User CAs found.</Typography> : null}
</List>
)}
</Paper>
</SectionCard>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
@@ -14,6 +14,7 @@ import {
} from '@mui/material'
import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, CertPrincipalBinding, PKICert, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
function fmt(ts: number): string {
@@ -47,17 +48,18 @@ export default function AdminServicePrincipalsPage() {
const [roleSearch, setRoleSearch] = useState('')
const load = async () => {
let allProjectsPage: { items: Project[]; total: number }
setLoading(true)
setError(null)
try {
const p = await api.listServicePrincipals()
const b = await api.listCertPrincipalBindings()
const c = await api.listPKICerts()
const allProjects = await api.listProjects(1000, 0, '')
allProjectsPage = await api.listProjects(1000, 0, '')
setPrincipals(Array.isArray(p) ? p : [])
setBindings(Array.isArray(b) ? b : [])
setPKICerts(Array.isArray(c) ? c : [])
setProjects(Array.isArray(allProjects) ? allProjects : [])
setProjects(Array.isArray(allProjectsPage.items) ? allProjectsPage.items : [])
const roleMap: Record<string, PrincipalProjectRole[]> = {}
let i: number
let roles: PrincipalProjectRole[]
@@ -254,11 +256,14 @@ export default function AdminServicePrincipalsPage() {
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Service Principals</Typography>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Box sx={{ display: 'grid', gap: 1 }}>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Principals</Typography>
<Button variant="outlined" onClick={() => { setDialogError(null); setCreateOpen(true) }}>New Principal</Button>
</Box>
<SectionCard
title="Principals"
actions={
<Button variant="outlined" onClick={() => { setDialogError(null); setCreateOpen(true) }}>
New Principal
</Button>
}
>
{loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
<Box sx={{ display: 'grid', gap: 0 }}>
{principals.map((item) => (
@@ -267,6 +272,8 @@ export default function AdminServicePrincipalsPage() {
variant="outlined"
sx={{
p: 1,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
@@ -300,13 +307,9 @@ export default function AdminServicePrincipalsPage() {
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Global permissions are managed on each subject page. No service-principal global permissions are available yet.
</Typography>
</Paper>
</SectionCard>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>Project Role Assignments</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Define what each principal can do per project.
</Typography>
<SectionCard title="Project Role Assignments" subtitle="Define what each principal can do per project.">
{dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{dialogError}</Alert> : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr auto', gap: 1, mb: 1 }}>
<TextField select label="Principal" value={rolePrincipalID} onChange={(event) => setRolePrincipalID(event.target.value)} size="small">
@@ -377,6 +380,8 @@ export default function AdminServicePrincipalsPage() {
variant="outlined"
sx={{
p: 1,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
@@ -399,13 +404,16 @@ export default function AdminServicePrincipalsPage() {
</Paper>
))}
</Box>
</Paper>
</SectionCard>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Cert Fingerprint Bindings</Typography>
<Button variant="outlined" onClick={() => { setDialogError(null); setBindSource('pki'); setBindPKICertID(''); setBindFingerprint(''); setBindPrincipalID(''); setBindingOpen(true) }}>Add Binding</Button>
</Box>
<SectionCard
title="Cert Fingerprint Bindings"
actions={
<Button variant="outlined" onClick={() => { setDialogError(null); setBindSource('pki'); setBindPKICertID(''); setBindFingerprint(''); setBindPrincipalID(''); setBindingOpen(true) }}>
Add Binding
</Button>
}
>
<Box sx={{ display: 'grid', gap: 0 }}>
{bindings.map((item) => (
<Paper
@@ -413,6 +421,8 @@ export default function AdminServicePrincipalsPage() {
variant="outlined"
sx={{
p: 1,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
@@ -449,7 +459,7 @@ export default function AdminServicePrincipalsPage() {
</Paper>
))}
</Box>
</Paper>
</SectionCard>
</Box>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
+24 -18
View File
@@ -14,6 +14,7 @@ import {
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, PKICA, PKICert, ServicePrincipal, TLSListener, TLSSettings } from '../api'
type ListenerForm = Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>
@@ -417,19 +418,20 @@ export default function AdminTLSSettingsPage() {
<Typography variant="h5" sx={{ mb: 2 }}>
Admin: Site TLS
</Typography>
<Paper sx={{ p: 2, width: '100%', minWidth: 0 }}>
<SectionCard
title="Listeners"
actions={
<Button variant="outlined" onClick={openCreateDialog}>
Add Listener
</Button>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{(loading || listenersLoading) ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading...
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Listeners</Typography>
<Button variant="outlined" onClick={openCreateDialog}>
Add Listener
</Button>
</Box>
<Box sx={{ display: 'grid', gap: 0 }}>
<Paper
variant="outlined"
@@ -437,6 +439,8 @@ export default function AdminTLSSettingsPage() {
p: 1,
display: 'grid',
gap: 0.5,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
@@ -470,13 +474,15 @@ export default function AdminTLSSettingsPage() {
<Paper
key={item.id}
variant="outlined"
sx={{
p: 1,
display: 'grid',
gap: 0.5,
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
sx={{
p: 1,
display: 'grid',
gap: 0.5,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxShadow: 'none'
@@ -519,10 +525,10 @@ export default function AdminTLSSettingsPage() {
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Policy: {item.auth_policy || 'default'} · Scope: {item.apply_policy_api ? 'API ' : ''}{item.apply_policy_git ? 'Git ' : ''}{item.apply_policy_rpm ? 'RPM ' : ''}{item.apply_policy_v2 ? 'V2' : ''}
</Typography>
</Paper>
))}
</Box>
</Paper>
</Paper>
))}
</Box>
</SectionCard>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
+19 -16
View File
@@ -22,6 +22,7 @@ import {
Typography
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import SectionCard from '../components/SectionCard'
import { api, SubjectPermission, User, UserGroup, UserGroupMember } from '../api'
const projectCreatePermission = 'project.create'
@@ -311,7 +312,7 @@ export default function AdminUserGroupsPage() {
</Paper>
<Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}>
<Paper sx={{ p: 2 }}>
<SectionCard title="Selected Group">
{selectedGroup ? (
<Box sx={{ display: 'grid', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
@@ -347,15 +348,13 @@ export default function AdminUserGroupsPage() {
Select a group to manage its members and permissions.
</Typography>
)}
</Paper>
</SectionCard>
<Paper sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1, fontWeight: 600 }}>
Members
</Typography>
{selectedGroup ? (
<>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1, flexWrap: 'wrap' }}>
<SectionCard
title="Members"
actions={
selectedGroup ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<TextField
select
size="small"
@@ -371,8 +370,15 @@ export default function AdminUserGroupsPage() {
</MenuItem>
))}
</TextField>
<Button variant="outlined" onClick={addMember} disabled={busy || !newMemberUserID}>Add Member</Button>
<Button variant="outlined" onClick={addMember} disabled={busy || !newMemberUserID}>
Add Member
</Button>
</Box>
) : undefined
}
>
{selectedGroup ? (
<>
<Box sx={{ display: 'grid', gap: 0.75 }}>
{members.map((member) => (
<Box
@@ -397,12 +403,9 @@ export default function AdminUserGroupsPage() {
Select a group.
</Typography>
)}
</Paper>
</SectionCard>
<Paper sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1, fontWeight: 600 }}>
Global Permissions
</Typography>
<SectionCard title="Global Permissions">
{selectedGroup ? (
<FormControlLabel
control={
@@ -419,7 +422,7 @@ export default function AdminUserGroupsPage() {
Select a group.
</Typography>
)}
</Paper>
</SectionCard>
</Box>
</Box>
+12 -9
View File
@@ -12,12 +12,13 @@ import {
List,
ListItem,
ListItemText,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SubjectPermission, User } from '../api'
const projectCreatePermission = 'project.create'
@@ -230,13 +231,15 @@ export default function AdminUsersPage() {
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: Users</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New User
</Button>
</Box>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography>
<SectionCard
title="Users"
actions={
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New User
</HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<List>
{users.map((u) => (
@@ -272,7 +275,7 @@ export default function AdminUsersPage() {
</ListItem>
))}
</List>
</Paper>
</SectionCard>
<Dialog open={createOpen} onClose={closeCreateDialog} maxWidth="sm" fullWidth>
<DialogTitle>New User</DialogTitle>
<DialogContent>
+16 -13
View File
@@ -22,6 +22,7 @@ import {
} from '@mui/material'
import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, APIKey } from '../api'
function formatUnix(value: number) {
@@ -135,7 +136,7 @@ export default function ApiKeysPage() {
setDeleteConfirm('')
await load()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete API key'
const message = err instanceof Error ? err.message : 'Failed to revoke API key'
setError(message)
} finally {
setDeleting(false)
@@ -172,13 +173,15 @@ export default function ApiKeysPage() {
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">API Keys</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New API Key
</Button>
</Box>
<Paper sx={{ p: 2 }}>
<Typography variant="h5" sx={{ mb: 2 }}>API Keys</Typography>
<SectionCard
title="API Keys"
actions={
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New API Key
</Button>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? (
<Typography variant="body2" color="text.secondary">Loading API keys...</Typography>
@@ -208,7 +211,7 @@ export default function ApiKeysPage() {
{key.disabled ? 'Enable' : 'Disable'}
</ListRowActionButton>
<ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => setDeleteTarget(key)}>
Delete
Revoke
</ListRowActionButton>
</ListRowActions>
</Box>
@@ -219,7 +222,7 @@ export default function ApiKeysPage() {
) : null}
</List>
)}
</Paper>
</SectionCard>
<Dialog open={createOpen} onClose={closeCreate} maxWidth="sm" fullWidth>
<DialogTitle>Create API Key</DialogTitle>
@@ -287,10 +290,10 @@ export default function ApiKeysPage() {
</Dialog>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Delete API Key</DialogTitle>
<DialogTitle>Revoke API Key</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the API key name to confirm deletion.
Type the API key name to confirm revocation.
</Typography>
<TextField
fullWidth
@@ -307,7 +310,7 @@ export default function ApiKeysPage() {
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
onClick={handleDelete}
>
{deleting ? 'Deleting...' : 'Delete'}
{deleting ? 'Revoking...' : 'Revoke'}
</Button>
</DialogActions>
</Dialog>
@@ -13,12 +13,12 @@ import {
List,
ListItem,
ListItemText,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
function fmt(value: number): string {
@@ -199,8 +199,7 @@ export default function ClientCertificatesPage() {
<Typography variant="h5">Client Certificates</Typography>
{error ? <Alert severity="error">{error}</Alert> : null}
<Paper sx={{ p: 1 }}>
<Typography variant="subtitle1" sx={{ px: 1, py: 0.5 }}>Available Profiles</Typography>
<SectionCard title="Available Profiles">
<List dense>
{profiles.map((profile) => (
<ListItem
@@ -230,10 +229,9 @@ export default function ClientCertificatesPage() {
</ListItem>
) : null}
</List>
</Paper>
</SectionCard>
<Paper sx={{ p: 1 }}>
<Typography variant="subtitle1" sx={{ px: 1, py: 0.5 }}>Issued Certificates</Typography>
<SectionCard title="Issued Certificates">
<List dense>
{issuances.map((item) => (
<ListItem
@@ -274,7 +272,7 @@ export default function ClientCertificatesPage() {
</ListItem>
) : null}
</List>
</Paper>
</SectionCard>
<Dialog open={Boolean(issueProfile)} onClose={() => { if (!busy) setIssueProfile(null) }} fullWidth maxWidth="sm">
<DialogTitle>Issue Client Certificate</DialogTitle>
+333 -18
View File
@@ -10,7 +10,6 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Divider from '@mui/material/Divider'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { Link, useOutletContext } from 'react-router-dom'
@@ -22,11 +21,13 @@ import {
PKIClientIssuance,
Project,
Repo,
RpmMirrorStatus,
SSHUserCAIssuance,
TLSListener,
TLSSettings,
User
} from '../api'
import SectionCard from '../components/SectionCard'
type LayoutContext = {
user: User | null
@@ -44,6 +45,15 @@ type MetricProps = {
helper?: string
}
type ActivityItem = {
id: string
when: number
kind: string
title: string
detail: string
to: string
}
function fmt(value: number): string {
if (!value || value <= 0) {
return '-'
@@ -52,19 +62,7 @@ function fmt(value: number): string {
}
function DashboardCard(props: DashboardCardProps) {
return (
<Paper sx={{ p: 2, display: 'grid', gap: 1, minWidth: 0 }}>
<Box sx={{ display: 'grid', gap: 0.25 }}>
<Typography variant="h6">{props.title}</Typography>
{props.subtitle ? (
<Typography variant="body2" color="text.secondary">
{props.subtitle}
</Typography>
) : null}
</Box>
{props.children}
</Paper>
)
return <SectionCard {...props} />
}
function Metric(props: MetricProps) {
@@ -108,6 +106,9 @@ export default function DashboardPage() {
const [allRepos, setAllRepos] = useState<Repo[]>([])
const [reposLoading, setReposLoading] = useState(false)
const [reposError, setReposError] = useState<string | null>(null)
const [rpmMirrors, setRpmMirrors] = useState<RpmMirrorStatus[]>([])
const [mirrorsLoading, setMirrorsLoading] = useState(false)
const [mirrorsError, setMirrorsError] = useState<string | null>(null)
const [pkiCerts, setPKICerts] = useState<PKICert[]>([])
const [acmeOrders, setACMEOrders] = useState<ACMEOrder[]>([])
@@ -125,26 +126,27 @@ export default function DashboardPage() {
loadCredentials().catch(() => {})
if (user?.is_admin) {
loadRepoSummary().catch(() => {})
loadMirrorStatus().catch(() => {})
loadCertWarnings().catch(() => {})
loadTLSHealth().catch(() => {})
}
}, [user?.is_admin])
const loadProjects = async () => {
let list: Project[]
let page: { items: Project[]; total: number }
let normalized: Project[]
setProjectsLoading(true)
setProjectsError(null)
try {
list = await api.listProjects()
normalized = Array.isArray(list) ? list.slice() : []
page = await api.listProjects(100, 0, '')
normalized = Array.isArray(page.items) ? page.items.slice() : []
normalized.sort((a, b) => {
const aTime = a.updated_at || a.created_at || 0
const bTime = b.updated_at || b.created_at || 0
return bTime - aTime
})
setRecentProjects(normalized.slice(0, 6))
setProjectCount(normalized.length)
setProjectCount(typeof page.total === 'number' ? page.total : normalized.length)
} catch (err) {
setProjectsError(err instanceof Error ? err.message : 'Failed to load projects')
setRecentProjects([])
@@ -194,6 +196,21 @@ export default function DashboardPage() {
}
}
const loadMirrorStatus = async () => {
let list: RpmMirrorStatus[]
setMirrorsLoading(true)
setMirrorsError(null)
try {
list = await api.listAdminRpmMirrors()
setRpmMirrors(Array.isArray(list) ? list : [])
} catch (err) {
setMirrorsError(err instanceof Error ? err.message : 'Failed to load mirror status')
setRpmMirrors([])
} finally {
setMirrorsLoading(false)
}
}
const loadCertWarnings = async () => {
let certList: PKICert[]
let orderList: ACMEOrder[]
@@ -295,6 +312,46 @@ export default function DashboardPage() {
return { git, rpm, docker }
}, [allRepos])
const runningMirrors = useMemo(() => {
return rpmMirrors.filter((item) => item.sync_running)
}, [rpmMirrors])
const suspendedMirrors = useMemo(() => {
return rpmMirrors.filter((item) => !item.sync_enabled)
}, [rpmMirrors])
const failedMirrors = useMemo(() => {
return rpmMirrors.filter((item) => item.sync_status === 'failed' || Boolean(item.sync_error))
}, [rpmMirrors])
const highlightedMirrors = useMemo(() => {
let list: RpmMirrorStatus[]
list = rpmMirrors.slice()
list.sort((a, b) => {
let aPriority = 0
let bPriority = 0
if (a.sync_running) {
aPriority = 3
} else if (a.sync_status === 'failed' || a.sync_error) {
aPriority = 2
} else if (!a.sync_enabled) {
aPriority = 1
}
if (b.sync_running) {
bPriority = 3
} else if (b.sync_status === 'failed' || b.sync_error) {
bPriority = 2
} else if (!b.sync_enabled) {
bPriority = 1
}
if (bPriority !== aPriority) {
return bPriority - aPriority
}
return (b.updated_at || 0) - (a.updated_at || 0)
})
return list.slice(0, 6)
}, [rpmMirrors])
const expiringPKICerts = useMemo(() => {
const list = pkiCerts
.filter((item) => item.status !== 'revoked' && item.status !== 'deleted' && item.not_after > nowUnix && item.not_after <= nowUnix + certSoonWindow)
@@ -332,6 +389,142 @@ export default function DashboardPage() {
return count
}, [tlsSettings, tlsListeners])
const recentActivity = useMemo(() => {
let items: ActivityItem[]
items = []
recentProjects.forEach((project) => {
const when = project.updated_at || project.created_at || 0
if (!when) {
return
}
items.push({
id: `project-${project.id}-${when}`,
when,
kind: 'Project',
title: project.name,
detail: project.updated_at && project.created_at && project.updated_at > project.created_at ? 'Updated project' : 'Created project',
to: `/projects/${project.id}`
})
})
apiKeys.forEach((item) => {
if (item.created_at > 0) {
items.push({
id: `apikey-created-${item.id}`,
when: item.created_at,
kind: 'API Key',
title: item.name,
detail: 'Created API key',
to: '/api-keys'
})
}
if (item.last_used_at > 0) {
items.push({
id: `apikey-used-${item.id}`,
when: item.last_used_at,
kind: 'API Key',
title: item.name,
detail: 'Used API key',
to: '/api-keys'
})
}
})
clientIssuances.forEach((item) => {
if (item.created_at > 0) {
items.push({
id: `client-cert-issued-${item.id}`,
when: item.created_at,
kind: 'Client Cert',
title: item.common_name,
detail: `Issued via ${item.profile_name}`,
to: '/client-certs'
})
}
if (item.revoked_at > 0) {
items.push({
id: `client-cert-revoked-${item.id}`,
when: item.revoked_at,
kind: 'Client Cert',
title: item.common_name,
detail: item.revocation_reason ? `Revoked: ${item.revocation_reason}` : 'Revoked certificate',
to: '/client-certs'
})
}
})
sshIssuances.forEach((item) => {
if (item.created_at <= 0) {
return
}
items.push({
id: `ssh-cert-${item.id}`,
when: item.created_at,
kind: 'SSH Cert',
title: item.key_id || item.ca_name || item.ca_id,
detail: `Issued for ${item.principals.join(', ') || 'no principals'}`,
to: '/ssh-certs'
})
})
if (user?.is_admin) {
pkiCerts.forEach((item) => {
if (item.created_at > 0) {
items.push({
id: `pki-cert-issued-${item.id}`,
when: item.created_at,
kind: 'PKI',
title: item.common_name,
detail: item.is_ca ? 'Issued CA certificate' : 'Issued certificate',
to: '/admin/pki'
})
}
if (item.revoked_at > 0) {
items.push({
id: `pki-cert-revoked-${item.id}`,
when: item.revoked_at,
kind: 'PKI',
title: item.common_name,
detail: item.revocation_reason ? `Revoked: ${item.revocation_reason}` : 'Revoked certificate',
to: '/admin/pki'
})
}
})
acmeOrders.forEach((item) => {
if ((item.updated_at || 0) <= 0) {
return
}
items.push({
id: `acme-order-${item.id}-${item.updated_at || 0}`,
when: item.updated_at || 0,
kind: 'ACME',
title: item.common_name,
detail: item.last_error ? `Order error: ${item.status}` : `Order ${item.status}`,
to: '/admin/pki'
})
})
tlsListeners.forEach((item) => {
if ((item.updated_at || 0) <= 0) {
return
}
items.push({
id: `tls-listener-${item.id}-${item.updated_at || 0}`,
when: item.updated_at || 0,
kind: 'TLS',
title: item.name,
detail: item.enabled ? 'Updated enabled listener' : 'Updated disabled listener',
to: '/admin/tls'
})
})
}
items.sort((a, b) => b.when - a.when)
return items.slice(0, 8)
}, [recentProjects, apiKeys, clientIssuances, sshIssuances, user?.is_admin, pkiCerts, acmeOrders, tlsListeners])
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1.5fr 1fr' }, gap: 1 }}>
@@ -385,6 +578,58 @@ export default function DashboardPage() {
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1.2fr 1fr' }, gap: 1 }}>
<DashboardCard title="Recent Activity" subtitle="Latest events across your workspace and credentials">
{projectsLoading || credentialsLoading || (user?.is_admin && (certsLoading || tlsLoading)) ? (
<Typography variant="body2" color="text.secondary">
Loading activity...
</Typography>
) : null}
{!projectsLoading && !credentialsLoading && recentActivity.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No recent activity available.
</Typography>
) : null}
<Box sx={{ display: 'grid', gap: 0 }}>
{recentActivity.map((item) => (
<Box
key={item.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
py: 1,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ minWidth: 0, display: 'grid', gap: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, flexWrap: 'wrap' }}>
<Chip size="small" variant="outlined" label={item.kind} />
<Button
component={Link}
to={item.to}
sx={{
justifyContent: 'flex-start',
p: 0,
minWidth: 0,
textTransform: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.title}
</Button>
</Box>
<Typography variant="caption" color="text.secondary">
{item.detail} · {fmt(item.when)}
</Typography>
</Box>
</Box>
))}
</Box>
</DashboardCard>
<DashboardCard title="Recent Projects" subtitle="Most recently updated visible projects">
{projectsError ? <Alert severity="error">{projectsError}</Alert> : null}
{projectsLoading ? <Typography variant="body2" color="text.secondary">Loading projects...</Typography> : null}
@@ -498,6 +743,76 @@ export default function DashboardPage() {
)}
</DashboardCard>
<DashboardCard title="Mirror Status" subtitle="RPM mirror directories and sync state">
{mirrorsError ? <Alert severity="error">{mirrorsError}</Alert> : null}
{mirrorsLoading ? <Typography variant="body2" color="text.secondary">Loading mirror status...</Typography> : null}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1 }}>
<Metric label="Mirrors" value={String(rpmMirrors.length)} />
<Metric label="Running" value={String(runningMirrors.length)} helper={`${failedMirrors.length} failed`} />
<Metric label="Suspended" value={String(suspendedMirrors.length)} />
<Metric label="Pending" value={String(rpmMirrors.filter((item) => item.sync_enabled && !item.sync_running && item.dirty).length)} helper="dirty and enabled" />
</Box>
<Divider />
{!mirrorsLoading && highlightedMirrors.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No RPM mirrors configured.
</Typography>
) : (
<Box sx={{ display: 'grid', gap: 0 }}>
{highlightedMirrors.map((item) => (
<Box
key={`${item.repo_id}:${item.path}`}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
py: 1,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ minWidth: 0, display: 'grid', gap: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, flexWrap: 'wrap' }}>
<Chip
size="small"
variant="outlined"
color={item.sync_running ? 'warning' : item.sync_status === 'failed' || item.sync_error ? 'error' : !item.sync_enabled ? 'default' : 'success'}
label={item.sync_running ? 'running' : item.sync_status === 'failed' || item.sync_error ? 'failed' : !item.sync_enabled ? 'suspended' : item.sync_status || 'idle'}
/>
<Button
component={Link}
to={`/projects/${item.project_id}/repos/${item.repo_id}?statusPath=${encodeURIComponent(item.path)}`}
sx={{
justifyContent: 'flex-start',
p: 0,
minWidth: 0,
textTransform: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.project_slug} / {item.repo_name} / {item.path}
</Button>
</Box>
<Typography variant="caption" color="text.secondary">
{item.sync_running
? `Progress ${item.sync_done}/${item.sync_total || 0} · failed ${item.sync_failed} · deleted ${item.sync_deleted}`
: item.sync_error
? item.sync_error
: item.last_sync_finished_at > 0
? `Last sync ${fmt(item.last_sync_finished_at)}`
: item.next_sync_at > 0
? `Next sync ${fmt(item.next_sync_at)}`
: 'No sync yet'}
</Typography>
</Box>
</Box>
))}
</Box>
)}
</DashboardCard>
<DashboardCard title="System Health" subtitle="Listener and TLS summary">
{tlsError ? <Alert severity="error">{tlsError}</Alert> : null}
{tlsLoading ? <Typography variant="body2" color="text.secondary">Loading TLS health...</Typography> : null}
+83 -74
View File
@@ -1,7 +1,8 @@
import { Alert, Box, Button, Divider, MenuItem, Paper, TextField, Typography } from '@mui/material'
import { Alert, Autocomplete, Box, Button, Divider, MenuItem, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, Repo } from '../api'
import SectionCard from '../components/SectionCard'
export default function GlobalReposPage() {
const [repos, setRepos] = useState<Repo[]>([])
@@ -46,84 +47,49 @@ export default function GlobalReposPage() {
const totalPages = totalRepos > 0 ? Math.floor((totalRepos - 1) / pageSize) + 1 : 1
const handlePageSizeBlur = () => {
const next = Number(pageSizeInput)
if (!Number.isFinite(next) || next <= 0) return
setPageSize(next)
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Repositories</Typography>
</Box>
<Paper sx={{ p: 1, mb: 1 }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'minmax(260px, 1fr) 140px 110px auto auto',
alignItems: 'center',
gap: 1
}}
>
<TextField
label="Search"
size="small"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPage(0)
}}
sx={{ minWidth: 240 }}
/>
<TextField
select
label="Type"
size="small"
value={typeFilter}
onChange={(event) => {
setTypeFilter(event.target.value as 'all' | 'git' | 'rpm' | 'docker')
setPage(0)
}}
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="git">Git</MenuItem>
<MenuItem value="rpm">RPM</MenuItem>
<MenuItem value="docker">Docker</MenuItem>
</TextField>
<TextField
label="Items"
size="small"
value={pageSizeInput}
onChange={(event) => setPageSizeInput(event.target.value)}
onBlur={handlePageSizeBlur}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handlePageSizeBlur()
}
}}
sx={{ width: 100 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'right' }}>
Page {page + 1} / {totalPages}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<Button size="small" onClick={() => setPage(Math.max(page - 1, 0))} disabled={page <= 0}>
Prev
</Button>
<Button size="small" onClick={() => setPage(Math.min(page + 1, totalPages - 1))} disabled={page >= totalPages - 1}>
Next
</Button>
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<Typography variant="h6">Repositories</Typography>
<TextField
label="Search"
size="small"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPage(0)
}}
sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
/>
<TextField
select
label="Type"
size="small"
value={typeFilter}
onChange={(event) => {
setTypeFilter(event.target.value as 'all' | 'git' | 'rpm' | 'docker')
setPage(0)
}}
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="git">Git</MenuItem>
<MenuItem value="rpm">RPM</MenuItem>
<MenuItem value="docker">Docker</MenuItem>
</TextField>
</Box>
</Box>
</Paper>
{reposError ? (
<Alert severity="error" sx={{ mb: 1 }}>
{reposError}
</Alert>
) : null}
<Paper sx={{ p: 1 }}>
}
>
{reposError ? (
<Alert severity="error">
{reposError}
</Alert>
) : null}
<Box sx={{ display: 'grid', gap: 0.75 }}>
{reposLoading ? (
<Typography variant="body2" color="text.secondary">
@@ -178,7 +144,50 @@ export default function GlobalReposPage() {
</Box>
))}
</Box>
</Paper>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: 'center' }}>
Page {page + 1} / {totalPages}
</Typography>
<Autocomplete
freeSolo
options={['10', '20', '50']}
value={pageSizeInput}
inputValue={pageSizeInput}
onChange={(_, value) => {
const nextValue = String(value || '')
const next = Number(nextValue)
setPageSizeInput(nextValue)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
onInputChange={(_, value) => {
const next = Number(value)
setPageSizeInput(value)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
sx={{ width: 100, flexShrink: 0 }}
renderInput={(params) => (
<TextField {...params} label="Items" size="small" />
)}
/>
<Button size="small" variant="outlined" onClick={() => setPage(Math.max(page - 1, 0))} disabled={page <= 0}>
Prev
</Button>
<Button
size="small"
variant="outlined"
onClick={() => setPage(Math.min(page + 1, totalPages - 1))}
disabled={page >= totalPages - 1}
>
Next
</Button>
</Box>
</SectionCard>
</Box>
)
}
+235 -223
View File
@@ -1,5 +1,5 @@
import Alert from '@mui/material/Alert'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, MenuItem, Paper, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Paper, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, Project, ProjectGroupRole, ProjectMember, User, UserGroup } from '../api'
@@ -7,6 +7,10 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton'
import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import DeleteIcon from '@mui/icons-material/Delete'
export default function ProjectHomePage() {
@@ -330,234 +334,242 @@ export default function ProjectHomePage() {
</Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<HeaderActionButton variant="outlined" onClick={openEdit} disabled={!project}>
Edit Project
</HeaderActionButton>
<HeaderActionButton variant="outlined" color="error" onClick={openDelete} disabled={!project}>
Delete Project
</HeaderActionButton>
</Box>
<Paper sx={{ p: 2 }}>
{project ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{project.created_by_name || project.created_by ? (
<Typography variant="body2" color="text.secondary">
Created by: {project.created_by_name || project.created_by}
</Typography>
) : null}
{project.created_at ? (
<Typography variant="body2" color="text.secondary">
Created: {new Date(project.created_at * 1000).toLocaleString()}
</Typography>
) : null}
{project.updated_by_name || project.updated_by ? (
<Typography variant="body2" color="text.secondary">
Last updated by: {project.updated_by_name || project.updated_by}
</Typography>
) : null}
{project.updated_at ? (
<Typography variant="body2" color="text.secondary">
Last updated: {new Date(project.updated_at * 1000).toLocaleString()}
</Typography>
) : null}
<Box sx={{ display: 'grid', gap: 1 }}>
<SectionCard
title="Overview"
actions={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}>
Edit Project
</HeaderActionButton>
<HeaderActionButton variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={openDelete} disabled={!project}>
Delete Project
</HeaderActionButton>
</Box>
{project.description ? (
<Box sx={{ '& p': { m: 0 } }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{project.description}</ReactMarkdown>
}
>
{project ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{project.created_by_name || project.created_by ? (
<Typography variant="body2" color="text.secondary">
Created by: {project.created_by_name || project.created_by}
</Typography>
) : null}
{project.created_at ? (
<Typography variant="body2" color="text.secondary">
Created: {new Date(project.created_at * 1000).toLocaleString()}
</Typography>
) : null}
{project.updated_by_name || project.updated_by ? (
<Typography variant="body2" color="text.secondary">
Last updated by: {project.updated_by_name || project.updated_by}
</Typography>
) : null}
{project.updated_at ? (
<Typography variant="body2" color="text.secondary">
Last updated: {new Date(project.updated_at * 1000).toLocaleString()}
</Typography>
) : null}
</Box>
) : (
{project.description ? (
<Box sx={{ '& p': { m: 0 } }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{project.description}</ReactMarkdown>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
No description.
</Typography>
)}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Loading project...
</Typography>
)}
</SectionCard>
<SectionCard
title="Members"
actions={
canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<TextField
select
size="small"
label="User"
value={newMemberUserID}
onChange={(event) => setNewMemberUserID(event.target.value)}
sx={{ minWidth: 260 }}
>
<MenuItem value="">Select user</MenuItem>
{memberUsers
.filter((user) => !members.some((member) => member.user_id === user.id))
.map((user) => (
<MenuItem key={user.id} value={user.id}>
{user.display_name ? `${user.display_name} (${user.username})` : user.username}
</MenuItem>
))}
</TextField>
<ToggleButtonGroup
size="small"
exclusive
value={newMemberRole}
onChange={(_, value) => {
if (value) {
setNewMemberRole(value as 'viewer' | 'writer' | 'admin')
}
}}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleAddMember} disabled={!newMemberUserID || addingMember}>
{addingMember ? 'Adding...' : 'Add'}
</Button>
</Box>
) : undefined
}
>
{membersError ? <Alert severity="error" sx={{ mb: 1 }}>{membersError}</Alert> : 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, flexWrap: 'wrap' }}
>
<Typography variant="body2">{memberName(member.user_id)}</Typography>
{canManageMembers ? (
<ListRowActions>
<ToggleButtonGroup
size="small"
exclusive
value={member.role}
onChange={(_, value) => {
if (value && value !== member.role) {
handleUpdateMemberRole(member.user_id, value)
}
}}
disabled={updatingMemberUserID === member.user_id}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<ListRowActionButton
color="error"
startIcon={<DeleteIcon />}
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingMemberUserID === member.user_id}
>
Remove
</ListRowActionButton>
</ListRowActions>
) : (
<Typography variant="body2" color="text.secondary">
{member.role}
</Typography>
)}
</Box>
))}
{members.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No description.
No members.
</Typography>
)}
) : null}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Loading project...
</Typography>
)}
</Paper>
<Paper sx={{ p: 2, mt: 1 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Members
</Typography>
{membersError ? <Alert severity="error" sx={{ mb: 1 }}>{membersError}</Alert> : null}
{canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1, flexWrap: 'wrap' }}>
<TextField
select
size="small"
label="User"
value={newMemberUserID}
onChange={(event) => setNewMemberUserID(event.target.value)}
sx={{ minWidth: 260 }}
>
<MenuItem value="">Select user</MenuItem>
{memberUsers
.filter((user) => !members.some((member) => member.user_id === user.id))
.map((user) => (
<MenuItem key={user.id} value={user.id}>
{user.display_name ? `${user.display_name} (${user.username})` : user.username}
</MenuItem>
))}
</TextField>
<ToggleButtonGroup
size="small"
exclusive
value={newMemberRole}
onChange={(_, value) => {
if (value) {
setNewMemberRole(value as 'viewer' | 'writer' | 'admin')
}
}}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" onClick={handleAddMember} disabled={!newMemberUserID || addingMember}>
{addingMember ? 'Adding...' : 'Add Member'}
</Button>
</SectionCard>
<SectionCard
title="Group Roles"
actions={
canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<TextField
select
size="small"
label="Group"
value={newGroupRoleGroupID}
onChange={(event) => setNewGroupRoleGroupID(event.target.value)}
sx={{ minWidth: 220 }}
>
<MenuItem value="">Select group</MenuItem>
{allGroups
.filter((group) => !groupRoles.some((role) => role.group_id === group.id))
.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<ToggleButtonGroup
size="small"
exclusive
value={newGroupRole}
onChange={(_, value) => {
if (value) {
setNewGroupRole(value as 'viewer' | 'writer' | 'admin')
}
}}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleAddGroupRole} disabled={!newGroupRoleGroupID || addingGroupRole}>
{addingGroupRole ? 'Adding...' : 'Add'}
</Button>
</Box>
) : undefined
}
>
<Box sx={{ display: 'grid', gap: 0.75 }}>
{groupRoles.map((groupRole) => (
<Box
key={groupRole.group_id}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="body2">{groupName(groupRole.group_id)}</Typography>
{canManageMembers ? (
<ListRowActions>
<ToggleButtonGroup
size="small"
exclusive
value={groupRole.role}
onChange={(_, value) => {
if (value && value !== groupRole.role) {
handleUpdateGroupRole(groupRole.group_id, value)
}
}}
disabled={updatingGroupRoleID === groupRole.group_id}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<ListRowActionButton
color="error"
startIcon={<DeleteIcon />}
onClick={() => handleRemoveGroupRole(groupRole.group_id)}
disabled={removingGroupRoleID === groupRole.group_id}
>
Remove
</ListRowActionButton>
</ListRowActions>
) : (
<Typography variant="body2" color="text.secondary">
{groupRole.role}
</Typography>
)}
</Box>
))}
{groupRoles.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No group roles.
</Typography>
) : null}
</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, flexWrap: 'wrap' }}
>
<Typography variant="body2">{memberName(member.user_id)}</Typography>
{canManageMembers ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ToggleButtonGroup
size="small"
exclusive
value={member.role}
onChange={(_, value) => {
if (value && value !== member.role) {
handleUpdateMemberRole(member.user_id, value)
}
}}
disabled={updatingMemberUserID === member.user_id}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<IconButton
size="small"
color="error"
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingMemberUserID === member.user_id}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
{member.role}
</Typography>
)}
</Box>
))}
{members.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No members.
</Typography>
) : null}
</Box>
</Paper>
<Paper sx={{ p: 2, mt: 1 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Group Roles
</Typography>
{canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1, flexWrap: 'wrap' }}>
<TextField
select
size="small"
label="Group"
value={newGroupRoleGroupID}
onChange={(event) => setNewGroupRoleGroupID(event.target.value)}
sx={{ minWidth: 220 }}
>
<MenuItem value="">Select group</MenuItem>
{allGroups
.filter((group) => !groupRoles.some((role) => role.group_id === group.id))
.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<ToggleButtonGroup
size="small"
exclusive
value={newGroupRole}
onChange={(_, value) => {
if (value) {
setNewGroupRole(value as 'viewer' | 'writer' | 'admin')
}
}}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" onClick={handleAddGroupRole} disabled={!newGroupRoleGroupID || addingGroupRole}>
{addingGroupRole ? 'Adding...' : 'Add Group Role'}
</Button>
</Box>
) : null}
<Box sx={{ display: 'grid', gap: 0.75 }}>
{groupRoles.map((groupRole) => (
<Box
key={groupRole.group_id}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="body2">{groupName(groupRole.group_id)}</Typography>
{canManageMembers ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ToggleButtonGroup
size="small"
exclusive
value={groupRole.role}
onChange={(_, value) => {
if (value && value !== groupRole.role) {
handleUpdateGroupRole(groupRole.group_id, value)
}
}}
disabled={updatingGroupRoleID === groupRole.group_id}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<IconButton
size="small"
color="error"
onClick={() => handleRemoveGroupRole(groupRole.group_id)}
disabled={removingGroupRoleID === groupRole.group_id}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
{groupRole.role}
</Typography>
)}
</Box>
))}
{groupRoles.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No group roles.
</Typography>
) : null}
</Box>
</Paper>
</SectionCard>
</Box>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle>
<DialogContent>
+110 -71
View File
@@ -1,3 +1,4 @@
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert'
@@ -8,6 +9,7 @@ import { Link } from 'react-router-dom'
import { api, Project, User } from '../api'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -42,6 +44,7 @@ export default function ProjectsPage() {
const [pageSizeInput, setPageSizeInput] = useState('20')
const [projectsError, setProjectsError] = useState<string | null>(null)
const [projectsLoading, setProjectsLoading] = useState(false)
const [totalProjects, setTotalProjects] = useState(0)
const truncateText = (text: string, max: number) => {
if (text.length <= max) return text
@@ -56,10 +59,15 @@ export default function ProjectsPage() {
setProjectsLoading(true)
api
.listProjects(pageSize, offset, query.trim())
.then((list) => setProjects(Array.isArray(list) ? list : []))
.then((result) => {
const data = Array.isArray(result.items) ? result.items : []
setTotalProjects(typeof result.total === 'number' ? result.total : 0)
setProjects(data)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load projects'
setProjectsError(message)
setTotalProjects(0)
setProjects([])
})
.finally(() => setProjectsLoading(false))
@@ -91,6 +99,7 @@ export default function ProjectsPage() {
try {
const created = await api.createProject(payload)
setProjects((prev) => [...prev, created])
setTotalProjects((prev) => prev + 1)
form.reset()
setCreateDescription('')
setCreateHomePage('info')
@@ -178,6 +187,7 @@ export default function ProjectsPage() {
try {
await api.deleteProject(deleteProject.id)
setProjects((prev) => prev.filter((project) => project.id !== deleteProject.id))
setTotalProjects((prev) => Math.max(0, prev - 1))
setDeleteProject(null)
setDeleteCounts(null)
setDeleteConfirm('')
@@ -204,74 +214,71 @@ export default function ProjectsPage() {
setCreateDescTab('write')
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Projects</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
{canCreateProject ? (
<HeaderActionButton variant="outlined" onClick={() => setCreateOpen(true)}>
New Project...
const totalPages = totalProjects > 0 ? Math.floor((totalProjects - 1) / pageSize) + 1 : 1
return (
<Box sx={{ minWidth: 0 }}>
<Typography variant="h5" sx={{ mb: 2 }}>Projects</Typography>
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<Typography variant="h6">Projects</Typography>
<TextField
size="small"
label="Search"
placeholder="Name or slug"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPage(0)
}}
sx={{ minWidth: 180, maxWidth: 360, flex: 1 }}
/>
</Box>
}
actions={
canCreateProject ? (
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New Project
</HeaderActionButton>
) : null}
</Box>
</Box>
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
size="small"
placeholder="Search by name or slug"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPage(0)
}}
fullWidth
/>
<Autocomplete
freeSolo
options={['10', '20', '50']}
value={pageSizeInput}
inputValue={pageSizeInput}
onChange={(_, value) => {
const nextValue = String(value || '')
const next = Number(nextValue)
setPageSizeInput(nextValue)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
onInputChange={(_, value) => {
const next = Number(value)
setPageSizeInput(value)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
renderInput={(params) => (
<TextField {...params} size="small" label="Items" sx={{ width: 120 }} />
)}
/>
</Box>
) : undefined
}
>
{projectsError ? <Alert severity="error">{projectsError}</Alert> : null}
{projectsLoading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading projects...
</Typography>
) : null}
<List>
{projects.map((project) => (
<ListItem key={project.id} divider sx={{ overflow: 'hidden', alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText
sx={{ minWidth: 0, flex: 1, m: 0 }}
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, width: '100%' }}>
<Button
component={Link}
to={`/projects/${project.id}`}
<List sx={{ width: '100%', minWidth: 0 }}>
{projects.map((project) => (
<ListItem key={project.id} divider sx={{ overflow: 'hidden', alignItems: 'flex-start' }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
width: '100%',
minWidth: 0,
flexWrap: { xs: 'wrap', md: 'nowrap' }
}}
>
<ListItemText
sx={{ minWidth: 0, flex: 1, m: 0 }}
primary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
minWidth: 0,
width: '100%',
flexWrap: { xs: 'wrap', lg: 'nowrap' }
}}
>
<Button
component={Link}
to={`/projects/${project.id}`}
size="small"
sx={{
justifyContent: 'flex-start',
@@ -309,11 +316,11 @@ export default function ProjectsPage() {
) : null}
</Box>
}
/>
<ListRowActions>
<ListRowActionButton startIcon={<EditIcon />} onClick={() => openEdit(project)}>
Edit
</ListRowActionButton>
/>
<ListRowActions>
<ListRowActionButton startIcon={<EditIcon />} onClick={() => openEdit(project)}>
Edit
</ListRowActionButton>
{user?.is_admin ? (
<ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => openDelete(project)}>
Delete
@@ -325,17 +332,49 @@ export default function ProjectsPage() {
))}
</List>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: 'center', mr: 1 }}>
Page {page + 1}
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: 'center' }}>
Page {page + 1} / {totalPages}
</Typography>
<Autocomplete
freeSolo
options={['10', '20', '50']}
value={pageSizeInput}
inputValue={pageSizeInput}
onChange={(_, value) => {
const nextValue = String(value || '')
const next = Number(nextValue)
setPageSizeInput(nextValue)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
onInputChange={(_, value) => {
const next = Number(value)
setPageSizeInput(value)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
sx={{ width: 120, flexShrink: 0 }}
renderInput={(params) => (
<TextField {...params} size="small" label="Items" />
)}
/>
<Button size="small" variant="outlined" disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
Prev
</Button>
<Button size="small" variant="outlined" onClick={() => setPage((p) => p + 1)}>
<Button
size="small"
variant="outlined"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => Math.min(p + 1, totalPages - 1))}
>
Next
</Button>
</Box>
</Paper>
</SectionCard>
<Dialog open={Boolean(editProject)} onClose={() => setEditProject(null)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle>
<DialogContent>
+60 -1
View File
@@ -23,7 +23,7 @@ import {
Typography
} from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmTreeEntry } from '../api'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
@@ -44,6 +44,7 @@ type RepoRpmDetailPageProps = {
export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
const { initialRepo } = props
const { projectId, repoId } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const [repo, setRepo] = useState<Repo | null>(initialRepo || null)
const [project, setProject] = useState<Project | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
@@ -122,6 +123,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
const [canWrite, setCanWrite] = useState(false)
const initRepoRef = useRef<string | null>(null)
const initProjectRef = useRef<string | null>(null)
const statusQueryRef = useRef('')
const statusQueryPath = (searchParams.get('statusPath') || '').trim()
useEffect(() => {
if (!repoId) return
@@ -191,6 +194,25 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
})
}, [repoId, repo, rpmPath, rpmTreeReloadTick])
useEffect(() => {
if (!statusQueryPath) {
statusQueryRef.current = ''
return
}
if (!repoId || !repo || repo.type !== 'rpm') return
if (statusQueryRef.current === statusQueryPath) return
statusQueryRef.current = statusQueryPath
openStatusDialogByPath(statusQueryPath).catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load mirror status'
setStatusPath(statusQueryPath)
setStatusName(statusQueryPath.split('/').filter(Boolean).slice(-1)[0] || statusQueryPath)
setStatusMode('mirror')
setStatusRuns([])
setStatusOpen(true)
setStatusError(message)
})
}, [repoId, repo, statusQueryPath])
const handleSelectRpm = async (pkg: RpmPackageSummary) => {
if (!repoId) return
setRpmSelected(pkg)
@@ -439,7 +461,34 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
}
}
const openStatusDialogByPath = async (path: string) => {
let cfg: Awaited<ReturnType<typeof api.getRpmSubdir>>
if (!repoId || !path) return
cfg = await api.getRpmSubdir(repoId, path)
setStatusPath(path)
setStatusName(path.split('/').filter(Boolean).slice(-1)[0] || path)
setStatusMode(cfg.mode === 'mirror' ? 'mirror' : 'local')
setStatusSyncStatus('')
setStatusSyncStep('')
setStatusSyncError('')
setStatusSyncEnabled(true)
setStatusSyncTotal(0)
setStatusSyncDone(0)
setStatusSyncFailed(0)
setStatusSyncDeleted(0)
setStatusRuns([])
setStatusError(null)
setStatusOpen(true)
await loadStatus(path)
}
const openStatusDialog = async (entry: RpmTreeEntry) => {
statusQueryRef.current = entry.path
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.set('statusPath', entry.path)
return next
}, { replace: true })
setStatusPath(entry.path)
setStatusName(entry.name)
setStatusMode(entry.repo_mode === 'mirror' ? 'mirror' : 'local')
@@ -1307,6 +1356,11 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
setStatusOpen(false)
setStatusError(null)
setClearRunsConfirmOpen(false)
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.delete('statusPath')
return next
}, { replace: true })
}}
maxWidth="sm"
fullWidth
@@ -1400,6 +1454,11 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
setStatusOpen(false)
setStatusError(null)
setClearRunsConfirmOpen(false)
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.delete('statusPath')
return next
}, { replace: true })
}}
>
Close
+108 -91
View File
@@ -1,3 +1,4 @@
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import LinkOffIcon from '@mui/icons-material/LinkOff'
@@ -13,7 +14,6 @@ import {
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemText,
@@ -27,6 +27,8 @@ import { Link, useParams } from 'react-router-dom'
import { api, AvailableRepo, Project, Repo, RepoStats } from '../api'
import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
export default function ReposPage() {
const { projectId } = useParams()
@@ -316,6 +318,9 @@ export default function ReposPage() {
return (
<Box>
<Typography variant="h5" sx={{ mb: 1 }}>
Repositories
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button component={Link} to="/projects" size="small">
@@ -344,85 +349,68 @@ export default function ReposPage() {
</Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<HeaderActionButton
variant="outlined"
onClick={() => {
setCreateType('git')
setCreateOpen(true)
}}
>
New Repository...
</HeaderActionButton>
<HeaderActionButton variant="outlined" onClick={openForeign} startIcon={<LinkIcon />}>
Add Foreign Repo...
</HeaderActionButton>
<HeaderActionButton
variant="outlined"
color="error"
disabled={selectedRepoIds.length === 0}
onClick={openBulkDelete}
>
Delete Selected
</HeaderActionButton>
</Box>
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
size="small"
placeholder="Search by name"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPage(0)
}}
fullWidth
/>
<TextField
select
size="small"
label="Type"
value={typeFilter}
onChange={(event) => {
setTypeFilter(event.target.value)
setPage(0)
}}
sx={{ width: 140 }}
>
<MenuItem value="all">All</MenuItem>
{repoTypes.map((item) => (
<MenuItem key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
</TextField>
<Autocomplete
freeSolo
options={['10', '20', '50']}
value={pageSizeInput}
inputValue={pageSizeInput}
onChange={(_, value) => {
const nextValue = String(value || '')
const next = Number(nextValue)
setPageSizeInput(nextValue)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<Typography variant="h6">Repositories</Typography>
<TextField
size="small"
label="Search"
placeholder="Name or id"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPage(0)
}
}}
onInputChange={(_, value) => {
const next = Number(value)
setPageSizeInput(value)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
}}
sx={{ minWidth: 180, maxWidth: 360, flex: 1 }}
/>
<TextField
select
size="small"
label="Type"
value={typeFilter}
onChange={(event) => {
setTypeFilter(event.target.value)
setPage(0)
}
}}
renderInput={(params) => (
<TextField {...params} size="small" label="Items" sx={{ width: 120 }} />
)}
/>
</Box>
}}
sx={{ width: 140, flexShrink: 0 }}
>
<MenuItem value="all">All</MenuItem>
{repoTypes.map((item) => (
<MenuItem key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
</TextField>
</Box>
}
actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<HeaderActionButton
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setCreateType('git')
setCreateOpen(true)
}}
>
New
</HeaderActionButton>
<HeaderActionButton variant="outlined" onClick={openForeign} startIcon={<LinkIcon />}>
Add Foreign
</HeaderActionButton>
<HeaderActionButton
variant="outlined"
color="error"
startIcon={<DeleteIcon />}
disabled={selectedRepoIds.length === 0}
onClick={openBulkDelete}
>
Delete Selected
</HeaderActionButton>
</Box>
}
>
{reposError ? <Alert severity="error">{reposError}</Alert> : null}
{reposLoading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
@@ -483,26 +471,55 @@ export default function ReposPage() {
}
/>
{!repo.is_foreign ? (
<>
<IconButton aria-label="edit repository" onClick={() => openEdit(repo)} size="small" sx={{ mr: 1 }}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton aria-label="delete repository" onClick={() => openDelete(repo)} size="small" sx={{ mr: 1 }}>
<DeleteIcon fontSize="small" />
</IconButton>
</>
<ListRowActions>
<ListRowActionButton startIcon={<EditIcon />} onClick={() => openEdit(repo)}>
Edit
</ListRowActionButton>
<ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => openDelete(repo)}>
Delete
</ListRowActionButton>
</ListRowActions>
) : (
<IconButton aria-label="detach repository" onClick={() => handleDetachForeign(repo.id)} size="small" sx={{ mr: 1 }}>
<LinkOffIcon fontSize="small" />
</IconButton>
<ListRowActions>
<ListRowActionButton startIcon={<LinkOffIcon />} onClick={() => handleDetachForeign(repo.id)}>
Detach
</ListRowActionButton>
</ListRowActions>
)}
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: 'center', mr: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: 'center' }}>
Page {page + 1} / {Math.max(1, Math.ceil(totalRepos / pageSize))}
</Typography>
<Autocomplete
freeSolo
options={['10', '20', '50']}
value={pageSizeInput}
inputValue={pageSizeInput}
onChange={(_, value) => {
const nextValue = String(value || '')
const next = Number(nextValue)
setPageSizeInput(nextValue)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
onInputChange={(_, value) => {
const next = Number(value)
setPageSizeInput(value)
if (Number.isFinite(next) && next > 0) {
setPageSize(next)
setPage(0)
}
}}
sx={{ width: 120, flexShrink: 0 }}
renderInput={(params) => (
<TextField {...params} size="small" label="Items" />
)}
/>
<Button size="small" variant="outlined" disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
Prev
</Button>
@@ -515,7 +532,7 @@ export default function ReposPage() {
Next
</Button>
</Box>
</Paper>
</SectionCard>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>New Repository</DialogTitle>
<DialogContent>
+22 -23
View File
@@ -15,7 +15,9 @@ import {
} from '@mui/material'
import VisibilityIcon from '@mui/icons-material/Visibility'
import { useEffect, useMemo, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance } from '../api'
function fmt(value: number): string {
@@ -209,10 +211,21 @@ export default function SSHCertificatesPage() {
}
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>SSH Certificates</Typography>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, display: 'grid', gap: 1 }}>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h5">SSH Certificates</Typography>
{error ? <Alert severity="error">{error}</Alert> : null}
<SectionCard
title="Certificates"
actions={
<HeaderActionButton
variant="contained"
onClick={sign}
disabled={busy || !caID || !publicKey.trim()}
>
{busy ? 'Signing...' : 'Sign Certificate'}
</HeaderActionButton>
}
>
{loading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
{!loading && cas.length === 0 ? <Typography variant="body2" color="text.secondary">No self-sign CAs available.</Typography> : null}
<TextField
@@ -256,17 +269,10 @@ export default function SSHCertificatesPage() {
<TextField label="Key ID (optional)" value={keyID} onChange={(event) => setKeyID(event.target.value)} />
<TextField label="Validity Seconds (optional)" value={validSeconds} onChange={(event) => setValidSeconds(event.target.value)} />
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
onClick={sign}
disabled={busy || !caID || !publicKey.trim()}
>
{busy ? 'Signing...' : 'Sign Certificate'}
</Button>
{result ? <Button variant="outlined" startIcon={<ContentCopyIcon />} onClick={copyCert}>Copy Certificate</Button> : null}
{result ? <Button variant="outlined" startIcon={<VisibilityIcon />} onClick={inspectCert} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</Button> : null}
</Box>
</Paper>
</SectionCard>
{result ? (
<Paper sx={{ p: 2, mt: 2, display: 'grid', gap: 1 }}>
@@ -284,22 +290,15 @@ export default function SSHCertificatesPage() {
</Paper>
) : null}
<Paper sx={{ p: 2, mt: 2, display: 'grid', gap: 1 }}>
<Typography variant="h6">Recent Self-Signed Certificates</Typography>
<SectionCard title="Recent Self-Signed Certificates">
{issuancesLoading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
{!issuancesLoading && issuances.length === 0 ? <Typography variant="body2" color="text.secondary">No issuance history found.</Typography> : null}
{issuances.map((item) => (
<Paper
<Box
key={item.id}
variant="outlined"
sx={{
p: 1,
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxShadow: 'none'
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minWidth: 0 }}>
@@ -319,9 +318,9 @@ export default function SSHCertificatesPage() {
<ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => inspectIssuanceCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</ListRowActionButton>
</ListRowActions>
</Box>
</Paper>
</Box>
))}
</Paper>
</SectionCard>
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Inspect</DialogTitle>