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