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", api.CreateACMEOrder)
router.Handle("POST", "/api/admin/pki/acme/orders/:id/finalize", api.FinalizeACMEOrder) 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("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("GET", "/api/admin/ssh/user-cas", api.ListSSHUserCAs)
router.Handle("POST", "/api/admin/ssh/user-cas", api.CreateSSHUserCA) router.Handle("POST", "/api/admin/ssh/user-cas", api.CreateSSHUserCA)
router.Handle("GET", "/api/admin/ssh/user-cas/:id", api.GetSSHUserCA) 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 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) { func (s *Store) DeleteRPMMirrorRuns(repoID string, path string) (int64, error) {
var res sql.Result var res sql.Result
var count int64 var count int64
+82
View File
@@ -1180,6 +1180,88 @@ func (s *Store) ListProjectsFilteredForUser(userID string, limit int, offset int
return projects, rows.Err() 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 { func (s *Store) DeleteProject(id string) error {
var err error var err error
_, err = s.DB.Exec(`DELETE FROM projects WHERE public_id = ?`, id) _, 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"` HomePage string `json:"home_page"`
} }
type projectListResponse struct {
Items []models.Project `json:"items"`
Total int `json:"total"`
}
type createRepoRequest struct { type createRepoRequest struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` 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) { func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var projects []models.Project var projects []models.Project
var resp projectListResponse
var err error var err error
var limit int var limit int
var offset int var offset int
var total int
var query string var query string
var v string var v string
var i int var i int
@@ -1353,21 +1360,33 @@ func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[strin
if user.IsAdmin { if user.IsAdmin {
if query != "" || limit > 0 || offset > 0 { if query != "" || limit > 0 || offset > 0 {
projects, err = api.Store.ListProjectsFiltered(limit, offset, query) projects, err = api.Store.ListProjectsFiltered(limit, offset, query)
if err == nil {
total, err = api.Store.CountProjectsFiltered(query)
}
} else { } else {
projects, err = api.Store.ListProjects() projects, err = api.Store.ListProjects()
total = len(projects)
} }
} else { } else {
if query != "" || limit > 0 || offset > 0 { if query != "" || limit > 0 || offset > 0 {
projects, err = api.Store.ListProjectsFilteredForUser(user.ID, limit, offset, query) projects, err = api.Store.ListProjectsFilteredForUser(user.ID, limit, offset, query)
if err == nil {
total, err = api.Store.CountProjectsFilteredForUser(user.ID, query)
}
} else { } else {
projects, err = api.Store.ListProjectsForUser(user.ID) projects, err = api.Store.ListProjectsForUser(user.ID)
total = len(projects)
} }
} }
if err != nil { if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return 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) { 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) 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) { func (api *API) RepoDockerDeleteTag(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo var repo models.Repo
var err error var err error
+23
View File
@@ -138,6 +138,29 @@ type RPMMirrorRun struct {
Error string `json:"error"` 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 { type Issue struct {
ID string `json:"id"` ID string `json:"id"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
+30 -1
View File
@@ -22,6 +22,11 @@ export interface Project {
updated_at?: number updated_at?: number
} }
export interface ProjectListPage {
items: Project[]
total: number
}
export interface Repo { export interface Repo {
id: string id: string
project_id: string project_id: string
@@ -148,6 +153,29 @@ export interface RpmMirrorRun {
error: string 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 { export interface RepoTypeItem {
value: string value: string
label: string label: string
@@ -804,6 +832,7 @@ export const api = {
const qs = params.toString() const qs = params.toString()
return request<ACMEOrder[]>(`/api/admin/pki/acme/orders${qs ? `?${qs}` : ''}`) 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[] }) => createACMEOrder: (payload: { profile_id: string; common_name: string; san_dns: string[] }) =>
request<ACMEOrder>('/api/admin/pki/acme/orders', { method: 'POST', body: JSON.stringify(payload) }), request<ACMEOrder>('/api/admin/pki/acme/orders', { method: 'POST', body: JSON.stringify(payload) }),
finalizeACMEOrder: (id: string, renew_mode?: 'override' | 'new_cert') => finalizeACMEOrder: (id: string, renew_mode?: 'override' | 'new_cert') =>
@@ -913,7 +942,7 @@ export const api = {
if (offset) params.set('offset', String(offset)) if (offset) params.set('offset', String(offset))
if (query) params.set('q', query) if (query) params.set('q', query)
const qs = params.toString() const qs = params.toString()
return request<Project[]>(`/api/projects${qs ? `?${qs}` : ''}`) return request<ProjectListPage>(`/api/projects${qs ? `?${qs}` : ''}`)
}, },
listAllRepos: (query?: string, repoType?: string) => { listAllRepos: (query?: string, repoType?: string) => {
const params = new URLSearchParams() const params = new URLSearchParams()
+22 -39
View File
@@ -1,19 +1,21 @@
import { CssBaseline, GlobalStyles } from '@mui/material' 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 { useEffect, useMemo, useState } from 'react'
import { useRoutes } from 'react-router-dom' import { useRoutes } from 'react-router-dom'
import { routes } from './routes' import { routes } from './routes'
import { ThemeModeContext } from './ThemeModeContext' import { ThemeModeContext } from './ThemeModeContext'
import { createAppTheme, isThemeScheme, ThemeScheme } from './theme'
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"'
export default function App() { export default function App() {
const element = useRoutes(routes) const element = useRoutes(routes)
const [mode, setMode] = useState<'light' | 'dark'>(() => { 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' return stored === 'dark' ? 'dark' : 'light'
}) })
const [scheme, setScheme] = useState<ThemeScheme>(() => {
const stored = localStorage.getItem('codit-theme-scheme')
return isThemeScheme(stored) ? stored : 'blue'
})
useEffect(() => { useEffect(() => {
let mounted = true let mounted = true
@@ -41,44 +43,25 @@ export default function App() {
} }
}, [mode]) }, [mode])
const toggleMode = () => { const updateMode = (next: 'light' | 'dark') => {
setMode((prev) => { localStorage.setItem('codit-theme-mode', next)
const next = prev === 'light' ? 'dark' : 'light' localStorage.setItem('codit-theme', next)
localStorage.setItem('codit-theme', next) setMode(next)
return next
})
} }
const theme = useMemo(() => { const toggleMode = () => {
return createTheme({ updateMode(mode === 'light' ? 'dark' : 'light')
palette: { }
mode
}, const updateScheme = (next: ThemeScheme) => {
typography: { localStorage.setItem('codit-theme-scheme', next)
fontFamily setScheme(next)
}, }
components: {
MuiDialog: { const theme = useMemo(() => createAppTheme(mode, scheme), [mode, scheme])
defaultProps: {
disableRestoreFocus: true
}
},
MuiPaper: {
styleOverrides: {
root: {
boxShadow: 'none',
backgroundImage: 'none',
border: '0',
padding: '1px !important'
}
}
}
}
})
}, [mode])
return ( return (
<ThemeModeContext.Provider value={{ mode, toggleMode }}> <ThemeModeContext.Provider value={{ mode, scheme, setMode: updateMode, toggleMode, setScheme: updateScheme }}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<GlobalStyles <GlobalStyles
+60 -5
View File
@@ -34,10 +34,10 @@ import HistoryIcon from '@mui/icons-material/History'
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts' import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'
import GroupWorkIcon from '@mui/icons-material/GroupWork' import GroupWorkIcon from '@mui/icons-material/GroupWork'
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser' import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
import DarkModeIcon from '@mui/icons-material/DarkMode' import PaletteIcon from '@mui/icons-material/Palette'
import LightModeIcon from '@mui/icons-material/LightMode'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import { ThemeModeContext } from './ThemeModeContext' import { ThemeModeContext } from './ThemeModeContext'
import { themeSchemeOptions } from './theme'
const drawerWidth = 220 const drawerWidth = 220
const drawerCollapsedWidth = 64 const drawerCollapsedWidth = 64
@@ -46,6 +46,7 @@ export default function Layout() {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(true) const [drawerOpen, setDrawerOpen] = useState(true)
const [appearanceMenuAnchor, setAppearanceMenuAnchor] = useState<HTMLElement | null>(null)
const [accountMenuAnchor, setAccountMenuAnchor] = useState<HTMLElement | null>(null) const [accountMenuAnchor, setAccountMenuAnchor] = useState<HTMLElement | null>(null)
const themeMode = useContext(ThemeModeContext) const themeMode = useContext(ThemeModeContext)
const navigate = useNavigate() const navigate = useNavigate()
@@ -99,10 +100,18 @@ export default function Layout() {
setAccountMenuAnchor(event.currentTarget) setAccountMenuAnchor(event.currentTarget)
} }
const openAppearanceMenu = (event: React.MouseEvent<HTMLElement>) => {
setAppearanceMenuAnchor(event.currentTarget)
}
const closeAccountMenu = () => { const closeAccountMenu = () => {
setAccountMenuAnchor(null) setAccountMenuAnchor(null)
} }
const closeAppearanceMenu = () => {
setAppearanceMenuAnchor(null)
}
const openAccountPage = () => { const openAccountPage = () => {
closeAccountMenu() closeAccountMenu()
navigate('/account') navigate('/account')
@@ -126,9 +135,55 @@ export default function Layout() {
<Typography variant="h6" sx={{ flexGrow: 1 }}> <Typography variant="h6" sx={{ flexGrow: 1 }}>
Codit Codit
</Typography> </Typography>
<IconButton color="inherit" onClick={themeMode.toggleMode} aria-label="Toggle theme"> <Button color="inherit" onClick={openAppearanceMenu} startIcon={<PaletteIcon />} endIcon={<KeyboardArrowDownIcon />}>
{themeMode.mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />} Appearance
</IconButton> </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 ? ( {user ? (
<> <>
<Button color="inherit" onClick={openAccountMenu} endIcon={<KeyboardArrowDownIcon />}> <Button color="inherit" onClick={openAccountMenu} endIcon={<KeyboardArrowDownIcon />}>
+8 -1
View File
@@ -1,11 +1,18 @@
import { createContext } from 'react' import { createContext } from 'react'
import { ThemeScheme } from './theme'
export type ThemeModeContextValue = { export type ThemeModeContextValue = {
mode: 'light' | 'dark' mode: 'light' | 'dark'
scheme: ThemeScheme
setMode: (mode: 'light' | 'dark') => void
toggleMode: () => void toggleMode: () => void
setScheme: (scheme: ThemeScheme) => void
} }
export const ThemeModeContext = createContext<ThemeModeContextValue>({ export const ThemeModeContext = createContext<ThemeModeContextValue>({
mode: 'light', 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', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 1, gap: 1,
justifyContent: 'space-between' justifyContent: 'space-between',
backgroundColor: 'transparent'
}, },
sx 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, ListItem,
ListItemText, ListItemText,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { AdminAPIKey, api, User } from '../api' import { AdminAPIKey, api, User } from '../api'
function formatUnix(value: number) { function formatUnix(value: number) {
@@ -43,11 +44,11 @@ export default function AdminApiKeysPage() {
const [bulkConfirm, setBulkConfirm] = useState('') const [bulkConfirm, setBulkConfirm] = useState('')
const [bulkDeleting, setBulkDeleting] = useState(false) const [bulkDeleting, setBulkDeleting] = useState(false)
const load = async () => { const load = async (nextUserID?: string, nextQuery?: string) => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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 : []) setKeys(Array.isArray(list) ? list : [])
setSelected([]) setSelected([])
} catch (err) { } catch (err) {
@@ -67,12 +68,12 @@ export default function AdminApiKeysPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
load() var timerID: number
}, [userID]) timerID = window.setTimeout(() => {
load(userID, query).catch(() => {})
const handleSearch = () => { }, 250)
load() return () => window.clearTimeout(timerID)
} }, [userID, query])
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) { if (!deleteTarget) {
@@ -149,50 +150,46 @@ export default function AdminApiKeysPage() {
<Typography variant="h5" sx={{ mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>
Admin: API Keys Admin: API Keys
</Typography> </Typography>
<Paper sx={{ p: 2, mb: 2 }}> <SectionCard
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> title={
<TextField <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
size="small" <Typography variant="h6">API Keys</Typography>
label="Search" <TextField
placeholder="key name, prefix, username, email" size="small"
value={query} label="Search"
onChange={(event) => setQuery(event.target.value)} placeholder="key name, prefix, username, email"
onKeyDown={(event) => { value={query}
if (event.key === 'Enter') { onChange={(event) => setQuery(event.target.value)}
handleSearch() sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
} />
}} <TextField
sx={{ minWidth: 280 }} size="small"
/> select
<TextField label="User"
size="small" value={userID}
select onChange={(event) => setUserID(event.target.value)}
label="User" sx={{ minWidth: 180 }}
value={userID} >
onChange={(event) => setUserID(event.target.value)} <MenuItem value="">All users</MenuItem>
sx={{ minWidth: 220 }} {users.map((user) => (
> <MenuItem key={user.id} value={user.id}>
<MenuItem value="">All users</MenuItem> {user.username}
{users.map((user) => ( </MenuItem>
<MenuItem key={user.id} value={user.id}> ))}
{user.username} </TextField>
</MenuItem> </Box>
))} }
</TextField> actions={
<Button variant="outlined" onClick={handleSearch}> <HeaderActionButton
Search
</Button>
<Button
variant="outlined" variant="outlined"
color="error" color="error"
disabled={!selected.length} disabled={!selected.length}
onClick={() => setBulkOpen(true)} onClick={() => setBulkOpen(true)}
> >
Revoke Selected ({selected.length}) Revoke Selected ({selected.length})
</Button> </HeaderActionButton>
</Box> }
</Paper> >
<Paper sx={{ p: 2 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? ( {loading ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -254,7 +251,7 @@ export default function AdminApiKeysPage() {
) : null} ) : null}
</List> </List>
)} )}
</Paper> </SectionCard>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth> <Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Revoke API Key</DialogTitle> <DialogTitle>Revoke API Key</DialogTitle>
<DialogContent> <DialogContent>
@@ -14,12 +14,13 @@ import {
ListItem, ListItem,
ListItemText, ListItemText,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, PKICA, PKIClientProfile, User, UserGroup } from '../api' import { api, PKICA, PKIClientProfile, User, UserGroup } from '../api'
function fmt(value: number): string { function fmt(value: number): string {
@@ -217,12 +218,16 @@ export default function AdminPKIClientProfilesPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>Admin: Client Cert Profiles</Typography>
<Typography variant="h5">Admin: Client Cert Profiles</Typography> <SectionCard
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New Profile</Button> title="Client Cert Profiles"
</Box> actions={
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} <HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
<Paper sx={{ p: 1 }}> New Profile
</HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<List dense> <List dense>
{items.map((item) => ( {items.map((item) => (
<ListItem <ListItem
@@ -255,7 +260,7 @@ export default function AdminPKIClientProfilesPage() {
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
</Paper> </SectionCard>
<Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} fullWidth maxWidth="md"> <Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} fullWidth maxWidth="md">
<DialogTitle>{editID ? 'Edit Client Certificate Profile' : 'New Client Certificate Profile'}</DialogTitle> <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 DownloadIcon from '@mui/icons-material/Download'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import { import {
@@ -14,12 +13,13 @@ import {
ListItem, ListItem,
ListItemText, ListItemText,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { ACMEOrder, ACMEProfile, api, PKICA, PKICADetail, PKICert, PKICertDetail } from '../api' import { ACMEOrder, ACMEProfile, api, PKICA, PKICADetail, PKICert, PKICertDetail } from '../api'
function fmt(ts: number): string { function fmt(ts: number): string {
@@ -63,6 +63,8 @@ export default function AdminPKIPage() {
const [acmeProfiles, setACMEProfiles] = useState<ACMEProfile[]>([]) const [acmeProfiles, setACMEProfiles] = useState<ACMEProfile[]>([])
const [acmeOrders, setACMEOrders] = useState<ACMEOrder[]>([]) const [acmeOrders, setACMEOrders] = useState<ACMEOrder[]>([])
const [selectedCA, setSelectedCA] = useState('') const [selectedCA, setSelectedCA] = useState('')
const [caQuery, setCAQuery] = useState('')
const [certQuery, setCertQuery] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [dialogError, setDialogError] = 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 ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: PKI</Typography> <Typography variant="h5">Admin: PKI</Typography>
<Box sx={{ display: 'flex', gap: 1 }}> <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>
</Box> </Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, mb: 2 }}> <Box sx={{ display: 'grid', gap: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Certificate Authorities</Typography> <SectionCard
<List> title={
{cas.map((ca) => ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
<ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}> <Typography variant="h6">Certificate Authorities</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <TextField
<ListItemText size="small"
primary={`${ca.name} (${ca.id})`} label="Search"
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`} placeholder="Name or id"
sx={{ minWidth: 0, m: 0 }} value={caQuery}
/> onChange={(event) => setCAQuery(event.target.value)}
<ListRowActions> sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
<ListRowActionButton />
onClick={async () => { </Box>
const data = await api.getPKICRL(ca.id) }
const blob = new Blob([data.crl_pem], { type: 'application/x-pem-file' }) actions={
const url = URL.createObjectURL(blob) <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
window.open(url, '_blank') <HeaderActionButton variant="outlined" onClick={() => { setDialogError(null); setRootOpen(true) }}>
}} New Root CA
> </HeaderActionButton>
CRL <HeaderActionButton
</ListRowActionButton> variant="outlined"
<ListRowActionButton onClick={() => openCAView(ca.id)}> onClick={() => {
View setDialogError(null)
</ListRowActionButton> setInterCertPEM('')
<ListRowActionButton onClick={() => openCAEdit(ca.id)}> setInterKeyPEM('')
Edit setInterOpen(true)
</ListRowActionButton> }}
<ListRowActionButton >
color="error" New Intermediate CA
onClick={() => { </HeaderActionButton>
setDialogError(null) </Box>
setDeleteCAID(ca.id) }
setDeleteCAName(ca.name) >
setDeleteCAConfirm('') <List>
setDeleteCAForce(false) {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 }}>
Delete <ListItemText
</ListRowActionButton> primary={`${ca.name} (${ca.id})`}
</ListRowActions> secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
</Box> sx={{ minWidth: 0, m: 0 }}
</ListItem> />
))} <ListRowActions>
</List> <ListRowActionButton
</Paper> onClick={async () => {
const data = await api.getPKICRL(ca.id)
<Paper sx={{ p: 2 }}> const blob = new Blob([data.crl_pem], { type: 'application/x-pem-file' })
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> const url = URL.createObjectURL(blob)
<Typography variant="h6">Issued Certificates</Typography> window.open(url, '_blank')
<TextField }}
select >
size="small" CRL
label="CA" </ListRowActionButton>
value={selectedCA} <ListRowActionButton onClick={() => openCAView(ca.id)}>
onChange={(event) => setSelectedCA(event.target.value)} View
sx={{ minWidth: 280 }} </ListRowActionButton>
> <ListRowActionButton onClick={() => openCAEdit(ca.id)}>
<MenuItem value="">(all)</MenuItem> Edit
<MenuItem value="standalone">(standalone)</MenuItem> </ListRowActionButton>
{cas.map((ca) => ( <ListRowActionButton
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem> color="error"
onClick={() => {
setDialogError(null)
setDeleteCAID(ca.id)
setDeleteCAName(ca.name)
setDeleteCAConfirm('')
setDeleteCAForce(false)
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))} ))}
</TextField> {filteredCAs.length === 0 ? (
</Box> <ListItem>
<List> <ListItemText primary="No certificate authorities found." />
{certs.map((cert) => ( </ListItem>
<ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}> ) : null}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> </List>
<ListItemText </SectionCard>
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>
<Paper sx={{ p: 2, mt: 2 }}> <SectionCard
<Typography variant="h6" sx={{ mb: 1 }}>ACME Profiles</Typography> title={
<List> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
{acmeProfiles.map((item) => ( <Typography variant="h6">Issued Certificates</Typography>
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <TextField
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> size="small"
<ListItemText label="Search"
primary={`${item.name} (${item.id})`} placeholder="CN, id, serial, CA"
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}` : ''}`} value={certQuery}
sx={{ minWidth: 0, m: 0 }} onChange={(event) => setCertQuery(event.target.value)}
/> sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
<ListRowActions> />
<ListRowActionButton onClick={() => openEditACME(item)}> <TextField
Edit select
</ListRowActionButton> size="small"
<ListRowActionButton color="error" onClick={() => setACMEDeleteID(item.id)}> label="CA"
Delete value={selectedCA}
</ListRowActionButton> onChange={(event) => setSelectedCA(event.target.value)}
</ListRowActions> sx={{ minWidth: 280 }}
</Box> >
</ListItem> <MenuItem value="">(all)</MenuItem>
))} <MenuItem value="standalone">(standalone)</MenuItem>
</List> {cas.map((ca) => (
</Paper> <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> <Dialog open={acmeOpen} onClose={() => setACMEOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle> <DialogTitle>
@@ -837,8 +902,14 @@ export default function AdminPKIPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Paper sx={{ p: 2, mt: 2 }}> <SectionCard
<Typography variant="h6" sx={{ mb: 1 }}>ACME Orders (DNS-01)</Typography> title="ACME Orders (DNS-01)"
actions={
<HeaderActionButton variant="outlined" onClick={openNewACMEOrder} disabled={acmeProfiles.length === 0}>
New ACME Order
</HeaderActionButton>
}
>
<List> <List>
{acmeOrders.map((item) => ( {acmeOrders.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
@@ -863,7 +934,7 @@ export default function AdminPKIPage() {
</ListItem> </ListItem>
))} ))}
</List> </List>
</Paper> </SectionCard>
<Dialog open={acmeOrderOpen} onClose={() => setACMEOrderOpen(false)} maxWidth="sm" fullWidth> <Dialog open={acmeOrderOpen} onClose={() => setACMEOrderOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle> <DialogTitle>
@@ -14,12 +14,13 @@ import {
ListItem, ListItem,
ListItemText, ListItemText,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHPrincipalGrant, User, UserGroup } from '../api' import { api, SSHPrincipalGrant, User, UserGroup } from '../api'
function fmt(value: number): string { function fmt(value: number): string {
@@ -228,59 +229,65 @@ export default function AdminSSHPrincipalGrantsPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Principal Grants</Typography>
<Typography variant="h5">Admin: SSH Principal Grants</Typography> <SectionCard
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New Grant</Button> title={
</Box> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} <Typography variant="h6">SSH Principal Grants</Typography>
<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' ? (
<TextField <TextField
select select
size="small" size="small"
label="User" label="Filter"
value={filterTargetID} value={filterTargetType}
onChange={(event) => setFilterTargetID(event.target.value)} onChange={(event) => {
sx={{ minWidth: 260 }} setFilterTargetType(event.target.value as 'user' | 'group' | '')
setFilterTargetID('')
}}
sx={{ minWidth: 160 }}
> >
<MenuItem value="">(all users)</MenuItem> <MenuItem value="">(all)</MenuItem>
{users.map((item) => ( <MenuItem value="user">User</MenuItem>
<MenuItem key={item.id} value={item.id}>{item.display_name ? `${item.display_name} (${item.username})` : item.username}</MenuItem> <MenuItem value="group">Group</MenuItem>
))}
</TextField> </TextField>
) : null} {filterTargetType === 'user' ? (
{filterTargetType === 'group' ? ( <TextField
<TextField select
select size="small"
size="small" label="User"
label="Group" value={filterTargetID}
value={filterTargetID} onChange={(event) => setFilterTargetID(event.target.value)}
onChange={(event) => setFilterTargetID(event.target.value)} sx={{ minWidth: 240 }}
sx={{ minWidth: 220 }} >
> <MenuItem value="">(all users)</MenuItem>
<MenuItem value="">(all groups)</MenuItem> {users.map((item) => (
{groups.map((item) => ( <MenuItem key={item.id} value={item.id}>{item.display_name ? `${item.display_name} (${item.username})` : item.username}</MenuItem>
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem> ))}
))} </TextField>
</TextField> ) : null}
) : null} {filterTargetType === 'group' ? (
</Box> <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 ? <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 && items.length === 0 ? <Typography variant="body2" color="text.secondary">No principal grants configured.</Typography> : null}
{!loading ? ( {!loading ? (
@@ -306,7 +313,7 @@ export default function AdminSSHPrincipalGrantsPage() {
))} ))}
</List> </List>
) : null} ) : null}
</Paper> </SectionCard>
<Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} maxWidth="md" fullWidth> <Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} maxWidth="md" fullWidth>
<DialogTitle>{editID ? 'Edit Principal Grant' : 'New Principal Grant'}</DialogTitle> <DialogTitle>{editID ? 'Edit Principal Grant' : 'New Principal Grant'}</DialogTitle>
+38 -46
View File
@@ -7,12 +7,12 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHUserCA, SSHUserCAIssuance } from '../api' import { api, SSHUserCA, SSHUserCAIssuance } from '../api'
function fmt(value: number): string { function fmt(value: number): string {
@@ -88,57 +88,49 @@ export default function AdminSSHSignHistoryPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Signing History</Typography>
<Typography variant="h5">Admin: SSH Signing History</Typography>
</Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, mb: 2, display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> <SectionCard title="SSH Signing History">
<TextField <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
select <TextField
size="small" select
label="CA" size="small"
value={caID} label="CA"
onChange={(event) => { value={caID}
const nextCAID = event.target.value onChange={(event) => {
setCAID(nextCAID) const nextCAID = event.target.value
load(nextCAID).catch(() => {}) setCAID(nextCAID)
}} load(nextCAID).catch(() => {})
sx={{ minWidth: 280 }} }}
> sx={{ minWidth: 280 }}
<MenuItem value="">(all)</MenuItem> >
{cas.map((item) => ( <MenuItem value="">(all)</MenuItem>
<MenuItem key={item.id} value={item.id}> {cas.map((item) => (
{item.name} ({item.id}) <MenuItem key={item.id} value={item.id}>
</MenuItem> {item.name} ({item.id})
))} </MenuItem>
</TextField> ))}
<TextField </TextField>
size="small" <TextField
label="Limit" size="small"
value={limitText} label="Limit"
onChange={(event) => setLimitText(event.target.value)} value={limitText}
sx={{ width: 120 }} onChange={(event) => setLimitText(event.target.value)}
/> sx={{ width: 120 }}
<Button variant="outlined" onClick={() => load().catch(() => {})} disabled={loading}> />
{loading ? 'Loading...' : 'Refresh'} <Button variant="outlined" onClick={() => load().catch(() => {})} disabled={loading}>
</Button> {loading ? 'Loading...' : 'Refresh'}
</Paper> </Button>
</Box>
<Paper sx={{ p: 2, display: 'grid', gap: 1 }}>
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null} {!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null}
{items.map((item) => ( {items.map((item) => (
<Paper <Box
key={item.id} key={item.id}
variant="outlined"
sx={{ sx={{
p: 1, p: 1,
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 0, minWidth: 0
boxShadow: 'none'
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, 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> <ListRowActionButton onClick={() => inspectCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box> </Box>
</Paper> </Box>
))} ))}
</Paper> </SectionCard>
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth> <Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Inspect</DialogTitle> <DialogTitle>Certificate Inspect</DialogTitle>
+13 -8
View File
@@ -14,12 +14,13 @@ import {
ListItem, ListItem,
ListItemText, ListItemText,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHSignUserKeyResponse, SSHUserCA } from '../api' import { api, SSHSignUserKeyResponse, SSHUserCA } from '../api'
function fmt(value: number): string { function fmt(value: number): string {
@@ -278,12 +279,16 @@ export default function AdminSSHUserCAPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH User CA</Typography>
<Typography variant="h5">Admin: SSH User CA</Typography> <SectionCard
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New SSH User CA</Button> title="SSH User CAs"
</Box> actions={
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} <HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
<Paper sx={{ p: 2 }}> New SSH User CA
</HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? ( {loading ? (
<Typography variant="body2" color="text.secondary">Loading SSH User CAs...</Typography> <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} {!items.length ? <Typography variant="body2" color="text.secondary">No SSH User CAs found.</Typography> : null}
</List> </List>
)} )}
</Paper> </SectionCard>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth> <Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth>
<DialogTitle> <DialogTitle>
@@ -14,6 +14,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, CertPrincipalBinding, PKICert, PrincipalProjectRole, Project, ServicePrincipal } from '../api' import { api, CertPrincipalBinding, PKICert, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
function fmt(ts: number): string { function fmt(ts: number): string {
@@ -47,17 +48,18 @@ export default function AdminServicePrincipalsPage() {
const [roleSearch, setRoleSearch] = useState('') const [roleSearch, setRoleSearch] = useState('')
const load = async () => { const load = async () => {
let allProjectsPage: { items: Project[]; total: number }
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const p = await api.listServicePrincipals() const p = await api.listServicePrincipals()
const b = await api.listCertPrincipalBindings() const b = await api.listCertPrincipalBindings()
const c = await api.listPKICerts() const c = await api.listPKICerts()
const allProjects = await api.listProjects(1000, 0, '') allProjectsPage = await api.listProjects(1000, 0, '')
setPrincipals(Array.isArray(p) ? p : []) setPrincipals(Array.isArray(p) ? p : [])
setBindings(Array.isArray(b) ? b : []) setBindings(Array.isArray(b) ? b : [])
setPKICerts(Array.isArray(c) ? c : []) setPKICerts(Array.isArray(c) ? c : [])
setProjects(Array.isArray(allProjects) ? allProjects : []) setProjects(Array.isArray(allProjectsPage.items) ? allProjectsPage.items : [])
const roleMap: Record<string, PrincipalProjectRole[]> = {} const roleMap: Record<string, PrincipalProjectRole[]> = {}
let i: number let i: number
let roles: PrincipalProjectRole[] let roles: PrincipalProjectRole[]
@@ -254,11 +256,14 @@ export default function AdminServicePrincipalsPage() {
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Service Principals</Typography> <Typography variant="h5" sx={{ mb: 2 }}>Admin: Service Principals</Typography>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Paper sx={{ p: 2 }}> <SectionCard
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> title="Principals"
<Typography variant="h6">Principals</Typography> actions={
<Button variant="outlined" onClick={() => { setDialogError(null); setCreateOpen(true) }}>New Principal</Button> <Button variant="outlined" onClick={() => { setDialogError(null); setCreateOpen(true) }}>
</Box> New Principal
</Button>
}
>
{loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null} {loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
<Box sx={{ display: 'grid', gap: 0 }}> <Box sx={{ display: 'grid', gap: 0 }}>
{principals.map((item) => ( {principals.map((item) => (
@@ -267,6 +272,8 @@ export default function AdminServicePrincipalsPage() {
variant="outlined" variant="outlined"
sx={{ sx={{
p: 1, p: 1,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none', borderLeft: 'none',
borderRight: 'none', borderRight: 'none',
borderTop: 'none', borderTop: 'none',
@@ -300,13 +307,9 @@ export default function AdminServicePrincipalsPage() {
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}> <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. Global permissions are managed on each subject page. No service-principal global permissions are available yet.
</Typography> </Typography>
</Paper> </SectionCard>
<Paper sx={{ p: 2 }}> <SectionCard title="Project Role Assignments" subtitle="Define what each principal can do per project.">
<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>
{dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{dialogError}</Alert> : null} {dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{dialogError}</Alert> : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr auto', gap: 1, mb: 1 }}> <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"> <TextField select label="Principal" value={rolePrincipalID} onChange={(event) => setRolePrincipalID(event.target.value)} size="small">
@@ -377,6 +380,8 @@ export default function AdminServicePrincipalsPage() {
variant="outlined" variant="outlined"
sx={{ sx={{
p: 1, p: 1,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none', borderLeft: 'none',
borderRight: 'none', borderRight: 'none',
borderTop: 'none', borderTop: 'none',
@@ -399,13 +404,16 @@ export default function AdminServicePrincipalsPage() {
</Paper> </Paper>
))} ))}
</Box> </Box>
</Paper> </SectionCard>
<Paper sx={{ p: 2 }}> <SectionCard
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> title="Cert Fingerprint Bindings"
<Typography variant="h6">Cert Fingerprint Bindings</Typography> actions={
<Button variant="outlined" onClick={() => { setDialogError(null); setBindSource('pki'); setBindPKICertID(''); setBindFingerprint(''); setBindPrincipalID(''); setBindingOpen(true) }}>Add Binding</Button> <Button variant="outlined" onClick={() => { setDialogError(null); setBindSource('pki'); setBindPKICertID(''); setBindFingerprint(''); setBindPrincipalID(''); setBindingOpen(true) }}>
</Box> Add Binding
</Button>
}
>
<Box sx={{ display: 'grid', gap: 0 }}> <Box sx={{ display: 'grid', gap: 0 }}>
{bindings.map((item) => ( {bindings.map((item) => (
<Paper <Paper
@@ -413,6 +421,8 @@ export default function AdminServicePrincipalsPage() {
variant="outlined" variant="outlined"
sx={{ sx={{
p: 1, p: 1,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none', borderLeft: 'none',
borderRight: 'none', borderRight: 'none',
borderTop: 'none', borderTop: 'none',
@@ -449,7 +459,7 @@ export default function AdminServicePrincipalsPage() {
</Paper> </Paper>
))} ))}
</Box> </Box>
</Paper> </SectionCard>
</Box> </Box>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth> <Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
+24 -18
View File
@@ -14,6 +14,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, PKICA, PKICert, ServicePrincipal, TLSListener, TLSSettings } from '../api' import { api, PKICA, PKICert, ServicePrincipal, TLSListener, TLSSettings } from '../api'
type ListenerForm = Omit<TLSListener, 'id' | 'created_at' | 'updated_at'> type ListenerForm = Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>
@@ -417,19 +418,20 @@ export default function AdminTLSSettingsPage() {
<Typography variant="h5" sx={{ mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>
Admin: Site TLS Admin: Site TLS
</Typography> </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} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{(loading || listenersLoading) ? ( {(loading || listenersLoading) ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading... Loading...
</Typography> </Typography>
) : null} ) : 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 }}> <Box sx={{ display: 'grid', gap: 0 }}>
<Paper <Paper
variant="outlined" variant="outlined"
@@ -437,6 +439,8 @@ export default function AdminTLSSettingsPage() {
p: 1, p: 1,
display: 'grid', display: 'grid',
gap: 0.5, gap: 0.5,
backgroundColor: 'transparent',
backgroundImage: 'none',
borderLeft: 'none', borderLeft: 'none',
borderRight: 'none', borderRight: 'none',
borderTop: 'none', borderTop: 'none',
@@ -470,13 +474,15 @@ export default function AdminTLSSettingsPage() {
<Paper <Paper
key={item.id} key={item.id}
variant="outlined" variant="outlined"
sx={{ sx={{
p: 1, p: 1,
display: 'grid', display: 'grid',
gap: 0.5, gap: 0.5,
borderLeft: 'none', backgroundColor: 'transparent',
borderRight: 'none', backgroundImage: 'none',
borderTop: 'none', borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 0, borderRadius: 0,
boxShadow: 'none' boxShadow: 'none'
@@ -519,10 +525,10 @@ export default function AdminTLSSettingsPage() {
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> <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' : ''} 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> </Typography>
</Paper> </Paper>
))} ))}
</Box> </Box>
</Paper> </SectionCard>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle> <DialogTitle>
+19 -16
View File
@@ -22,6 +22,7 @@ import {
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import SectionCard from '../components/SectionCard'
import { api, SubjectPermission, User, UserGroup, UserGroupMember } from '../api' import { api, SubjectPermission, User, UserGroup, UserGroupMember } from '../api'
const projectCreatePermission = 'project.create' const projectCreatePermission = 'project.create'
@@ -311,7 +312,7 @@ export default function AdminUserGroupsPage() {
</Paper> </Paper>
<Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}> <Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}>
<Paper sx={{ p: 2 }}> <SectionCard title="Selected Group">
{selectedGroup ? ( {selectedGroup ? (
<Box sx={{ display: 'grid', gap: 0.75 }}> <Box sx={{ display: 'grid', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> <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. Select a group to manage its members and permissions.
</Typography> </Typography>
)} )}
</Paper> </SectionCard>
<Paper sx={{ p: 2 }}> <SectionCard
<Typography variant="subtitle1" sx={{ mb: 1, fontWeight: 600 }}> title="Members"
Members actions={
</Typography> selectedGroup ? (
{selectedGroup ? ( <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1, flexWrap: 'wrap' }}>
<TextField <TextField
select select
size="small" size="small"
@@ -371,8 +370,15 @@ export default function AdminUserGroupsPage() {
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
<Button variant="outlined" onClick={addMember} disabled={busy || !newMemberUserID}>Add Member</Button> <Button variant="outlined" onClick={addMember} disabled={busy || !newMemberUserID}>
Add Member
</Button>
</Box> </Box>
) : undefined
}
>
{selectedGroup ? (
<>
<Box sx={{ display: 'grid', gap: 0.75 }}> <Box sx={{ display: 'grid', gap: 0.75 }}>
{members.map((member) => ( {members.map((member) => (
<Box <Box
@@ -397,12 +403,9 @@ export default function AdminUserGroupsPage() {
Select a group. Select a group.
</Typography> </Typography>
)} )}
</Paper> </SectionCard>
<Paper sx={{ p: 2 }}> <SectionCard title="Global Permissions">
<Typography variant="subtitle1" sx={{ mb: 1, fontWeight: 600 }}>
Global Permissions
</Typography>
{selectedGroup ? ( {selectedGroup ? (
<FormControlLabel <FormControlLabel
control={ control={
@@ -419,7 +422,7 @@ export default function AdminUserGroupsPage() {
Select a group. Select a group.
</Typography> </Typography>
)} )}
</Paper> </SectionCard>
</Box> </Box>
</Box> </Box>
+12 -9
View File
@@ -12,12 +12,13 @@ import {
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SubjectPermission, User } from '../api' import { api, SubjectPermission, User } from '../api'
const projectCreatePermission = 'project.create' const projectCreatePermission = 'project.create'
@@ -230,13 +231,15 @@ export default function AdminUsersPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography>
<Typography variant="h5">Admin: Users</Typography> <SectionCard
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}> title="Users"
New User actions={
</Button> <HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
</Box> New User
<Paper sx={{ p: 2, mb: 2 }}> </HeaderActionButton>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<List> <List>
{users.map((u) => ( {users.map((u) => (
@@ -272,7 +275,7 @@ export default function AdminUsersPage() {
</ListItem> </ListItem>
))} ))}
</List> </List>
</Paper> </SectionCard>
<Dialog open={createOpen} onClose={closeCreateDialog} maxWidth="sm" fullWidth> <Dialog open={createOpen} onClose={closeCreateDialog} maxWidth="sm" fullWidth>
<DialogTitle>New User</DialogTitle> <DialogTitle>New User</DialogTitle>
<DialogContent> <DialogContent>
+16 -13
View File
@@ -22,6 +22,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, APIKey } from '../api' import { api, APIKey } from '../api'
function formatUnix(value: number) { function formatUnix(value: number) {
@@ -135,7 +136,7 @@ export default function ApiKeysPage() {
setDeleteConfirm('') setDeleteConfirm('')
await load() await load()
} catch (err) { } 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) setError(message)
} finally { } finally {
setDeleting(false) setDeleting(false)
@@ -172,13 +173,15 @@ export default function ApiKeysPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> <Typography variant="h5" sx={{ mb: 2 }}>API Keys</Typography>
<Typography variant="h5">API Keys</Typography> <SectionCard
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}> title="API Keys"
New API Key actions={
</Button> <Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
</Box> New API Key
<Paper sx={{ p: 2 }}> </Button>
}
>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? ( {loading ? (
<Typography variant="body2" color="text.secondary">Loading API keys...</Typography> <Typography variant="body2" color="text.secondary">Loading API keys...</Typography>
@@ -208,7 +211,7 @@ export default function ApiKeysPage() {
{key.disabled ? 'Enable' : 'Disable'} {key.disabled ? 'Enable' : 'Disable'}
</ListRowActionButton> </ListRowActionButton>
<ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => setDeleteTarget(key)}> <ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => setDeleteTarget(key)}>
Delete Revoke
</ListRowActionButton> </ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box> </Box>
@@ -219,7 +222,7 @@ export default function ApiKeysPage() {
) : null} ) : null}
</List> </List>
)} )}
</Paper> </SectionCard>
<Dialog open={createOpen} onClose={closeCreate} maxWidth="sm" fullWidth> <Dialog open={createOpen} onClose={closeCreate} maxWidth="sm" fullWidth>
<DialogTitle>Create API Key</DialogTitle> <DialogTitle>Create API Key</DialogTitle>
@@ -287,10 +290,10 @@ export default function ApiKeysPage() {
</Dialog> </Dialog>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth> <Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Delete API Key</DialogTitle> <DialogTitle>Revoke API Key</DialogTitle>
<DialogContent> <DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <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> </Typography>
<TextField <TextField
fullWidth fullWidth
@@ -307,7 +310,7 @@ export default function ApiKeysPage() {
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name} disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
onClick={handleDelete} onClick={handleDelete}
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? 'Revoking...' : 'Revoke'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@@ -13,12 +13,12 @@ import {
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api' import { api, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
function fmt(value: number): string { function fmt(value: number): string {
@@ -199,8 +199,7 @@ export default function ClientCertificatesPage() {
<Typography variant="h5">Client Certificates</Typography> <Typography variant="h5">Client Certificates</Typography>
{error ? <Alert severity="error">{error}</Alert> : null} {error ? <Alert severity="error">{error}</Alert> : null}
<Paper sx={{ p: 1 }}> <SectionCard title="Available Profiles">
<Typography variant="subtitle1" sx={{ px: 1, py: 0.5 }}>Available Profiles</Typography>
<List dense> <List dense>
{profiles.map((profile) => ( {profiles.map((profile) => (
<ListItem <ListItem
@@ -230,10 +229,9 @@ export default function ClientCertificatesPage() {
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
</Paper> </SectionCard>
<Paper sx={{ p: 1 }}> <SectionCard title="Issued Certificates">
<Typography variant="subtitle1" sx={{ px: 1, py: 0.5 }}>Issued Certificates</Typography>
<List dense> <List dense>
{issuances.map((item) => ( {issuances.map((item) => (
<ListItem <ListItem
@@ -274,7 +272,7 @@ export default function ClientCertificatesPage() {
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
</Paper> </SectionCard>
<Dialog open={Boolean(issueProfile)} onClose={() => { if (!busy) setIssueProfile(null) }} fullWidth maxWidth="sm"> <Dialog open={Boolean(issueProfile)} onClose={() => { if (!busy) setIssueProfile(null) }} fullWidth maxWidth="sm">
<DialogTitle>Issue Client Certificate</DialogTitle> <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 Button from '@mui/material/Button'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { ReactNode, useEffect, useMemo, useState } from 'react' import { ReactNode, useEffect, useMemo, useState } from 'react'
import { Link, useOutletContext } from 'react-router-dom' import { Link, useOutletContext } from 'react-router-dom'
@@ -22,11 +21,13 @@ import {
PKIClientIssuance, PKIClientIssuance,
Project, Project,
Repo, Repo,
RpmMirrorStatus,
SSHUserCAIssuance, SSHUserCAIssuance,
TLSListener, TLSListener,
TLSSettings, TLSSettings,
User User
} from '../api' } from '../api'
import SectionCard from '../components/SectionCard'
type LayoutContext = { type LayoutContext = {
user: User | null user: User | null
@@ -44,6 +45,15 @@ type MetricProps = {
helper?: string helper?: string
} }
type ActivityItem = {
id: string
when: number
kind: string
title: string
detail: string
to: string
}
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
@@ -52,19 +62,7 @@ function fmt(value: number): string {
} }
function DashboardCard(props: DashboardCardProps) { function DashboardCard(props: DashboardCardProps) {
return ( return <SectionCard {...props} />
<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>
)
} }
function Metric(props: MetricProps) { function Metric(props: MetricProps) {
@@ -108,6 +106,9 @@ export default function DashboardPage() {
const [allRepos, setAllRepos] = useState<Repo[]>([]) const [allRepos, setAllRepos] = useState<Repo[]>([])
const [reposLoading, setReposLoading] = useState(false) const [reposLoading, setReposLoading] = useState(false)
const [reposError, setReposError] = useState<string | null>(null) 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 [pkiCerts, setPKICerts] = useState<PKICert[]>([])
const [acmeOrders, setACMEOrders] = useState<ACMEOrder[]>([]) const [acmeOrders, setACMEOrders] = useState<ACMEOrder[]>([])
@@ -125,26 +126,27 @@ export default function DashboardPage() {
loadCredentials().catch(() => {}) loadCredentials().catch(() => {})
if (user?.is_admin) { if (user?.is_admin) {
loadRepoSummary().catch(() => {}) loadRepoSummary().catch(() => {})
loadMirrorStatus().catch(() => {})
loadCertWarnings().catch(() => {}) loadCertWarnings().catch(() => {})
loadTLSHealth().catch(() => {}) loadTLSHealth().catch(() => {})
} }
}, [user?.is_admin]) }, [user?.is_admin])
const loadProjects = async () => { const loadProjects = async () => {
let list: Project[] let page: { items: Project[]; total: number }
let normalized: Project[] let normalized: Project[]
setProjectsLoading(true) setProjectsLoading(true)
setProjectsError(null) setProjectsError(null)
try { try {
list = await api.listProjects() page = await api.listProjects(100, 0, '')
normalized = Array.isArray(list) ? list.slice() : [] normalized = Array.isArray(page.items) ? page.items.slice() : []
normalized.sort((a, b) => { normalized.sort((a, b) => {
const aTime = a.updated_at || a.created_at || 0 const aTime = a.updated_at || a.created_at || 0
const bTime = b.updated_at || b.created_at || 0 const bTime = b.updated_at || b.created_at || 0
return bTime - aTime return bTime - aTime
}) })
setRecentProjects(normalized.slice(0, 6)) setRecentProjects(normalized.slice(0, 6))
setProjectCount(normalized.length) setProjectCount(typeof page.total === 'number' ? page.total : normalized.length)
} catch (err) { } catch (err) {
setProjectsError(err instanceof Error ? err.message : 'Failed to load projects') setProjectsError(err instanceof Error ? err.message : 'Failed to load projects')
setRecentProjects([]) 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 () => { const loadCertWarnings = async () => {
let certList: PKICert[] let certList: PKICert[]
let orderList: ACMEOrder[] let orderList: ACMEOrder[]
@@ -295,6 +312,46 @@ export default function DashboardPage() {
return { git, rpm, docker } return { git, rpm, docker }
}, [allRepos]) }, [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 expiringPKICerts = useMemo(() => {
const list = pkiCerts const list = pkiCerts
.filter((item) => item.status !== 'revoked' && item.status !== 'deleted' && item.not_after > nowUnix && item.not_after <= nowUnix + certSoonWindow) .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 return count
}, [tlsSettings, tlsListeners]) }, [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 ( return (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1.5fr 1fr' }, 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>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1.2fr 1fr' }, gap: 1 }}> <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"> <DashboardCard title="Recent Projects" subtitle="Most recently updated visible projects">
{projectsError ? <Alert severity="error">{projectsError}</Alert> : null} {projectsError ? <Alert severity="error">{projectsError}</Alert> : null}
{projectsLoading ? <Typography variant="body2" color="text.secondary">Loading projects...</Typography> : null} {projectsLoading ? <Typography variant="body2" color="text.secondary">Loading projects...</Typography> : null}
@@ -498,6 +743,76 @@ export default function DashboardPage() {
)} )}
</DashboardCard> </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"> <DashboardCard title="System Health" subtitle="Listener and TLS summary">
{tlsError ? <Alert severity="error">{tlsError}</Alert> : null} {tlsError ? <Alert severity="error">{tlsError}</Alert> : null}
{tlsLoading ? <Typography variant="body2" color="text.secondary">Loading TLS health...</Typography> : 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 { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api, Repo } from '../api' import { api, Repo } from '../api'
import SectionCard from '../components/SectionCard'
export default function GlobalReposPage() { export default function GlobalReposPage() {
const [repos, setRepos] = useState<Repo[]>([]) 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 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 ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Repositories</Typography> <Typography variant="h6">Repositories</Typography>
</Box> </Box>
<Paper sx={{ p: 1, mb: 1 }}> <SectionCard
<Box title={
sx={{ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
display: 'grid', <Typography variant="h6">Repositories</Typography>
gridTemplateColumns: 'minmax(260px, 1fr) 140px 110px auto auto', <TextField
alignItems: 'center', label="Search"
gap: 1 size="small"
}} value={query}
> onChange={(event) => {
<TextField setQuery(event.target.value)
label="Search" setPage(0)
size="small" }}
value={query} sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
onChange={(event) => { />
setQuery(event.target.value) <TextField
setPage(0) select
}} label="Type"
sx={{ minWidth: 240 }} size="small"
/> value={typeFilter}
<TextField onChange={(event) => {
select setTypeFilter(event.target.value as 'all' | 'git' | 'rpm' | 'docker')
label="Type" setPage(0)
size="small" }}
value={typeFilter} sx={{ minWidth: 120 }}
onChange={(event) => { >
setTypeFilter(event.target.value as 'all' | 'git' | 'rpm' | 'docker') <MenuItem value="all">All</MenuItem>
setPage(0) <MenuItem value="git">Git</MenuItem>
}} <MenuItem value="rpm">RPM</MenuItem>
sx={{ minWidth: 120 }} <MenuItem value="docker">Docker</MenuItem>
> </TextField>
<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>
</Box> </Box>
</Box> }
</Paper> >
{reposError ? ( {reposError ? (
<Alert severity="error" sx={{ mb: 1 }}> <Alert severity="error">
{reposError} {reposError}
</Alert> </Alert>
) : null} ) : null}
<Paper sx={{ p: 1 }}>
<Box sx={{ display: 'grid', gap: 0.75 }}> <Box sx={{ display: 'grid', gap: 0.75 }}>
{reposLoading ? ( {reposLoading ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -178,7 +144,50 @@ export default function GlobalReposPage() {
</Box> </Box>
))} ))}
</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> </Box>
) )
} }
+235 -223
View File
@@ -1,5 +1,5 @@
import Alert from '@mui/material/Alert' 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 { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, Project, ProjectGroupRole, ProjectMember, User, UserGroup } from '../api' import { api, Project, ProjectGroupRole, ProjectMember, User, UserGroup } from '../api'
@@ -7,6 +7,10 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton' 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' import DeleteIcon from '@mui/icons-material/Delete'
export default function ProjectHomePage() { export default function ProjectHomePage() {
@@ -330,234 +334,242 @@ export default function ProjectHomePage() {
</Box> </Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null} {projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<HeaderActionButton variant="outlined" onClick={openEdit} disabled={!project}> <SectionCard
Edit Project title="Overview"
</HeaderActionButton> actions={
<HeaderActionButton variant="outlined" color="error" onClick={openDelete} disabled={!project}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
Delete Project <HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}>
</HeaderActionButton> Edit Project
</Box> </HeaderActionButton>
<Paper sx={{ p: 2 }}> <HeaderActionButton variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={openDelete} disabled={!project}>
{project ? ( Delete Project
<Box sx={{ display: 'grid', gap: 1 }}> </HeaderActionButton>
<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> </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> </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"> <Typography variant="body2" color="text.secondary">
No description. No members.
</Typography> </Typography>
)} ) : null}
</Box> </Box>
) : ( </SectionCard>
<Typography variant="body2" color="text.secondary"> <SectionCard
Loading project... title="Group Roles"
</Typography> actions={
)} canManageMembers ? (
</Paper> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Paper sx={{ p: 2, mt: 1 }}> <TextField
<Typography variant="subtitle1" sx={{ mb: 1 }}> select
Members size="small"
</Typography> label="Group"
{membersError ? <Alert severity="error" sx={{ mb: 1 }}>{membersError}</Alert> : null} value={newGroupRoleGroupID}
{canManageMembers ? ( onChange={(event) => setNewGroupRoleGroupID(event.target.value)}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1, flexWrap: 'wrap' }}> sx={{ minWidth: 220 }}
<TextField >
select <MenuItem value="">Select group</MenuItem>
size="small" {allGroups
label="User" .filter((group) => !groupRoles.some((role) => role.group_id === group.id))
value={newMemberUserID} .map((group) => (
onChange={(event) => setNewMemberUserID(event.target.value)} <MenuItem key={group.id} value={group.id}>
sx={{ minWidth: 260 }} {group.name}
> </MenuItem>
<MenuItem value="">Select user</MenuItem> ))}
{memberUsers </TextField>
.filter((user) => !members.some((member) => member.user_id === user.id)) <ToggleButtonGroup
.map((user) => ( size="small"
<MenuItem key={user.id} value={user.id}> exclusive
{user.display_name ? `${user.display_name} (${user.username})` : user.username} value={newGroupRole}
</MenuItem> onChange={(_, value) => {
))} if (value) {
</TextField> setNewGroupRole(value as 'viewer' | 'writer' | 'admin')
<ToggleButtonGroup }
size="small" }}
exclusive >
value={newMemberRole} <ToggleButton value="viewer">Viewer</ToggleButton>
onChange={(_, value) => { <ToggleButton value="writer">Writer</ToggleButton>
if (value) { <ToggleButton value="admin">Admin</ToggleButton>
setNewMemberRole(value as 'viewer' | 'writer' | 'admin') </ToggleButtonGroup>
} <Button variant="outlined" startIcon={<AddIcon />} onClick={handleAddGroupRole} disabled={!newGroupRoleGroupID || addingGroupRole}>
}} {addingGroupRole ? 'Adding...' : 'Add'}
> </Button>
<ToggleButton value="viewer">Viewer</ToggleButton> </Box>
<ToggleButton value="writer">Writer</ToggleButton> ) : undefined
<ToggleButton value="admin">Admin</ToggleButton> }
</ToggleButtonGroup> >
<Button variant="outlined" onClick={handleAddMember} disabled={!newMemberUserID || addingMember}> <Box sx={{ display: 'grid', gap: 0.75 }}>
{addingMember ? 'Adding...' : 'Add Member'} {groupRoles.map((groupRole) => (
</Button> <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> </Box>
) : null} </SectionCard>
<Box sx={{ display: 'grid', gap: 0.75 }}> </Box>
{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>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth> <Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle> <DialogTitle>Edit Project</DialogTitle>
<DialogContent> <DialogContent>
+110 -71
View File
@@ -1,3 +1,4 @@
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
@@ -8,6 +9,7 @@ import { Link } from 'react-router-dom'
import { api, Project, User } from '../api' import { api, Project, User } from '../api'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -42,6 +44,7 @@ export default function ProjectsPage() {
const [pageSizeInput, setPageSizeInput] = useState('20') const [pageSizeInput, setPageSizeInput] = useState('20')
const [projectsError, setProjectsError] = useState<string | null>(null) const [projectsError, setProjectsError] = useState<string | null>(null)
const [projectsLoading, setProjectsLoading] = useState(false) const [projectsLoading, setProjectsLoading] = useState(false)
const [totalProjects, setTotalProjects] = useState(0)
const truncateText = (text: string, max: number) => { const truncateText = (text: string, max: number) => {
if (text.length <= max) return text if (text.length <= max) return text
@@ -56,10 +59,15 @@ export default function ProjectsPage() {
setProjectsLoading(true) setProjectsLoading(true)
api api
.listProjects(pageSize, offset, query.trim()) .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) => { .catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load projects' const message = err instanceof Error ? err.message : 'Failed to load projects'
setProjectsError(message) setProjectsError(message)
setTotalProjects(0)
setProjects([]) setProjects([])
}) })
.finally(() => setProjectsLoading(false)) .finally(() => setProjectsLoading(false))
@@ -91,6 +99,7 @@ export default function ProjectsPage() {
try { try {
const created = await api.createProject(payload) const created = await api.createProject(payload)
setProjects((prev) => [...prev, created]) setProjects((prev) => [...prev, created])
setTotalProjects((prev) => prev + 1)
form.reset() form.reset()
setCreateDescription('') setCreateDescription('')
setCreateHomePage('info') setCreateHomePage('info')
@@ -178,6 +187,7 @@ export default function ProjectsPage() {
try { try {
await api.deleteProject(deleteProject.id) await api.deleteProject(deleteProject.id)
setProjects((prev) => prev.filter((project) => project.id !== deleteProject.id)) setProjects((prev) => prev.filter((project) => project.id !== deleteProject.id))
setTotalProjects((prev) => Math.max(0, prev - 1))
setDeleteProject(null) setDeleteProject(null)
setDeleteCounts(null) setDeleteCounts(null)
setDeleteConfirm('') setDeleteConfirm('')
@@ -204,74 +214,71 @@ export default function ProjectsPage() {
setCreateDescTab('write') setCreateDescTab('write')
} }
return ( const totalPages = totalProjects > 0 ? Math.floor((totalProjects - 1) / pageSize) + 1 : 1
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> return (
<Typography variant="h5">Projects</Typography> <Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}> <Typography variant="h5" sx={{ mb: 2 }}>Projects</Typography>
{canCreateProject ? ( <SectionCard
<HeaderActionButton variant="outlined" onClick={() => setCreateOpen(true)}> title={
New Project... <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> </HeaderActionButton>
) : null} ) : undefined
</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>
{projectsError ? <Alert severity="error">{projectsError}</Alert> : null} {projectsError ? <Alert severity="error">{projectsError}</Alert> : null}
{projectsLoading ? ( {projectsLoading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading projects... Loading projects...
</Typography> </Typography>
) : null} ) : null}
<List> <List sx={{ width: '100%', minWidth: 0 }}>
{projects.map((project) => ( {projects.map((project) => (
<ListItem key={project.id} divider sx={{ overflow: 'hidden', alignItems: 'flex-start' }}> <ListItem key={project.id} divider sx={{ overflow: 'hidden', alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box
<ListItemText sx={{
sx={{ minWidth: 0, flex: 1, m: 0 }} display: 'flex',
primary={ alignItems: 'flex-start',
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, width: '100%' }}> gap: 1,
<Button width: '100%',
component={Link} minWidth: 0,
to={`/projects/${project.id}`} 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" size="small"
sx={{ sx={{
justifyContent: 'flex-start', justifyContent: 'flex-start',
@@ -309,11 +316,11 @@ export default function ProjectsPage() {
) : null} ) : null}
</Box> </Box>
} }
/> />
<ListRowActions> <ListRowActions>
<ListRowActionButton startIcon={<EditIcon />} onClick={() => openEdit(project)}> <ListRowActionButton startIcon={<EditIcon />} onClick={() => openEdit(project)}>
Edit Edit
</ListRowActionButton> </ListRowActionButton>
{user?.is_admin ? ( {user?.is_admin ? (
<ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => openDelete(project)}> <ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => openDelete(project)}>
Delete Delete
@@ -325,17 +332,49 @@ export default function ProjectsPage() {
))} ))}
</List> </List>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}> <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} Page {page + 1} / {totalPages}
</Typography> </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))}> <Button size="small" variant="outlined" disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
Prev Prev
</Button> </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 Next
</Button> </Button>
</Box> </Box>
</Paper> </SectionCard>
<Dialog open={Boolean(editProject)} onClose={() => setEditProject(null)} maxWidth="sm" fullWidth> <Dialog open={Boolean(editProject)} onClose={() => setEditProject(null)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle> <DialogTitle>Edit Project</DialogTitle>
<DialogContent> <DialogContent>
+60 -1
View File
@@ -23,7 +23,7 @@ import {
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useRef, useState } from 'react' 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 { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmTreeEntry } from '../api'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight' import ChevronRightIcon from '@mui/icons-material/ChevronRight'
@@ -44,6 +44,7 @@ type RepoRpmDetailPageProps = {
export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) { export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
const { initialRepo } = props const { initialRepo } = props
const { projectId, repoId } = useParams() const { projectId, repoId } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const [repo, setRepo] = useState<Repo | null>(initialRepo || null) const [repo, setRepo] = useState<Repo | null>(initialRepo || null)
const [project, setProject] = useState<Project | null>(null) const [project, setProject] = useState<Project | null>(null)
const [loadError, setLoadError] = useState<string | 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 [canWrite, setCanWrite] = useState(false)
const initRepoRef = useRef<string | null>(null) const initRepoRef = useRef<string | null>(null)
const initProjectRef = useRef<string | null>(null) const initProjectRef = useRef<string | null>(null)
const statusQueryRef = useRef('')
const statusQueryPath = (searchParams.get('statusPath') || '').trim()
useEffect(() => { useEffect(() => {
if (!repoId) return if (!repoId) return
@@ -191,6 +194,25 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
}) })
}, [repoId, repo, rpmPath, rpmTreeReloadTick]) }, [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) => { const handleSelectRpm = async (pkg: RpmPackageSummary) => {
if (!repoId) return if (!repoId) return
setRpmSelected(pkg) 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) => { 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) setStatusPath(entry.path)
setStatusName(entry.name) setStatusName(entry.name)
setStatusMode(entry.repo_mode === 'mirror' ? 'mirror' : 'local') setStatusMode(entry.repo_mode === 'mirror' ? 'mirror' : 'local')
@@ -1307,6 +1356,11 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
setStatusOpen(false) setStatusOpen(false)
setStatusError(null) setStatusError(null)
setClearRunsConfirmOpen(false) setClearRunsConfirmOpen(false)
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.delete('statusPath')
return next
}, { replace: true })
}} }}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
@@ -1400,6 +1454,11 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
setStatusOpen(false) setStatusOpen(false)
setStatusError(null) setStatusError(null)
setClearRunsConfirmOpen(false) setClearRunsConfirmOpen(false)
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.delete('statusPath')
return next
}, { replace: true })
}} }}
> >
Close Close
+108 -91
View File
@@ -1,3 +1,4 @@
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import LinkOffIcon from '@mui/icons-material/LinkOff' import LinkOffIcon from '@mui/icons-material/LinkOff'
@@ -13,7 +14,6 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
IconButton,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
@@ -27,6 +27,8 @@ import { Link, useParams } from 'react-router-dom'
import { api, AvailableRepo, Project, Repo, RepoStats } from '../api' import { api, AvailableRepo, Project, Repo, RepoStats } from '../api'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
export default function ReposPage() { export default function ReposPage() {
const { projectId } = useParams() const { projectId } = useParams()
@@ -316,6 +318,9 @@ export default function ReposPage() {
return ( return (
<Box> <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', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button component={Link} to="/projects" size="small"> <Button component={Link} to="/projects" size="small">
@@ -344,85 +349,68 @@ export default function ReposPage() {
</Box> </Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null} {projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}> <SectionCard
<HeaderActionButton title={
variant="outlined" <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
onClick={() => { <Typography variant="h6">Repositories</Typography>
setCreateType('git') <TextField
setCreateOpen(true) size="small"
}} label="Search"
> placeholder="Name or id"
New Repository... value={query}
</HeaderActionButton> onChange={(event) => {
<HeaderActionButton variant="outlined" onClick={openForeign} startIcon={<LinkIcon />}> setQuery(event.target.value)
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)
setPage(0) setPage(0)
} }}
}} sx={{ minWidth: 180, maxWidth: 360, flex: 1 }}
onInputChange={(_, value) => { />
const next = Number(value) <TextField
setPageSizeInput(value) select
if (Number.isFinite(next) && next > 0) { size="small"
setPageSize(next) label="Type"
value={typeFilter}
onChange={(event) => {
setTypeFilter(event.target.value)
setPage(0) setPage(0)
} }}
}} sx={{ width: 140, flexShrink: 0 }}
renderInput={(params) => ( >
<TextField {...params} size="small" label="Items" sx={{ width: 120 }} /> <MenuItem value="all">All</MenuItem>
)} {repoTypes.map((item) => (
/> <MenuItem key={item.value} value={item.value}>
</Box> {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} {reposError ? <Alert severity="error">{reposError}</Alert> : null}
{reposLoading ? ( {reposLoading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
@@ -483,26 +471,55 @@ export default function ReposPage() {
} }
/> />
{!repo.is_foreign ? ( {!repo.is_foreign ? (
<> <ListRowActions>
<IconButton aria-label="edit repository" onClick={() => openEdit(repo)} size="small" sx={{ mr: 1 }}> <ListRowActionButton startIcon={<EditIcon />} onClick={() => openEdit(repo)}>
<EditIcon fontSize="small" /> Edit
</IconButton> </ListRowActionButton>
<IconButton aria-label="delete repository" onClick={() => openDelete(repo)} size="small" sx={{ mr: 1 }}> <ListRowActionButton startIcon={<DeleteIcon />} color="error" onClick={() => openDelete(repo)}>
<DeleteIcon fontSize="small" /> Delete
</IconButton> </ListRowActionButton>
</> </ListRowActions>
) : ( ) : (
<IconButton aria-label="detach repository" onClick={() => handleDetachForeign(repo.id)} size="small" sx={{ mr: 1 }}> <ListRowActions>
<LinkOffIcon fontSize="small" /> <ListRowActionButton startIcon={<LinkOffIcon />} onClick={() => handleDetachForeign(repo.id)}>
</IconButton> Detach
</ListRowActionButton>
</ListRowActions>
)} )}
</ListItem> </ListItem>
))} ))}
</List> </List>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}> <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))} Page {page + 1} / {Math.max(1, Math.ceil(totalRepos / pageSize))}
</Typography> </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))}> <Button size="small" variant="outlined" disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
Prev Prev
</Button> </Button>
@@ -515,7 +532,7 @@ export default function ReposPage() {
Next Next
</Button> </Button>
</Box> </Box>
</Paper> </SectionCard>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth> <Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>New Repository</DialogTitle> <DialogTitle>New Repository</DialogTitle>
<DialogContent> <DialogContent>
+22 -23
View File
@@ -15,7 +15,9 @@ import {
} from '@mui/material' } from '@mui/material'
import VisibilityIcon from '@mui/icons-material/Visibility' import VisibilityIcon from '@mui/icons-material/Visibility'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance } from '../api' import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance } from '../api'
function fmt(value: number): string { function fmt(value: number): string {
@@ -209,10 +211,21 @@ export default function SSHCertificatesPage() {
} }
return ( return (
<Box> <Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h5" sx={{ mb: 2 }}>SSH Certificates</Typography> <Typography variant="h5">SSH Certificates</Typography>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error">{error}</Alert> : null}
<Paper sx={{ p: 2, display: 'grid', gap: 1 }}> <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 ? <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} {!loading && cas.length === 0 ? <Typography variant="body2" color="text.secondary">No self-sign CAs available.</Typography> : null}
<TextField <TextField
@@ -256,17 +269,10 @@ export default function SSHCertificatesPage() {
<TextField label="Key ID (optional)" value={keyID} onChange={(event) => setKeyID(event.target.value)} /> <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)} /> <TextField label="Validity Seconds (optional)" value={validSeconds} onChange={(event) => setValidSeconds(event.target.value)} />
<Box sx={{ display: 'flex', gap: 1 }}> <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={<ContentCopyIcon />} onClick={copyCert}>Copy Certificate</Button> : null}
{result ? <Button variant="outlined" startIcon={<VisibilityIcon />} onClick={inspectCert} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</Button> : null} {result ? <Button variant="outlined" startIcon={<VisibilityIcon />} onClick={inspectCert} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</Button> : null}
</Box> </Box>
</Paper> </SectionCard>
{result ? ( {result ? (
<Paper sx={{ p: 2, mt: 2, display: 'grid', gap: 1 }}> <Paper sx={{ p: 2, mt: 2, display: 'grid', gap: 1 }}>
@@ -284,22 +290,15 @@ export default function SSHCertificatesPage() {
</Paper> </Paper>
) : null} ) : null}
<Paper sx={{ p: 2, mt: 2, display: 'grid', gap: 1 }}> <SectionCard title="Recent Self-Signed Certificates">
<Typography variant="h6">Recent Self-Signed Certificates</Typography>
{issuancesLoading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null} {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} {!issuancesLoading && issuances.length === 0 ? <Typography variant="body2" color="text.secondary">No issuance history found.</Typography> : null}
{issuances.map((item) => ( {issuances.map((item) => (
<Paper <Box
key={item.id} key={item.id}
variant="outlined"
sx={{ sx={{
p: 1, p: 1,
borderLeft: 'none',
borderRight: 'none',
borderTop: 'none',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxShadow: 'none'
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minWidth: 0 }}> <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> <ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => inspectIssuanceCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box> </Box>
</Paper> </Box>
))} ))}
</Paper> </SectionCard>
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth> <Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Inspect</DialogTitle> <DialogTitle>Certificate Inspect</DialogTitle>