mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
支持限制用户可选择的系统
This commit is contained in:
@@ -43,6 +43,8 @@ const defaultForm: CreateContainerRequest = {
|
||||
ssh_auth_mode: 'auto_password',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
allowed_image_ids: [],
|
||||
image_limit_configured: false,
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
@@ -67,7 +69,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
setTemplates(data)
|
||||
setForm((prev) => {
|
||||
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
||||
return applyTemplateDefaults({ ...prev, template_id: templateID })
|
||||
const allowed = new Set(data.map((item) => item.id))
|
||||
const selectedAllowedIDs = (prev.allowed_image_ids || []).filter((id) => allowed.has(id))
|
||||
return applyTemplateDefaults({
|
||||
...prev,
|
||||
template_id: templateID,
|
||||
allowed_image_ids: prev.image_limit_configured ? selectedAllowedIDs : (templateID ? [templateID] : []),
|
||||
image_limit_configured: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
@@ -197,7 +206,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
await onSuccess(containers)
|
||||
onClose()
|
||||
setBatchCount(1)
|
||||
setForm({ ...defaultForm, template_id: templates[0]?.id || '' })
|
||||
setForm({ ...defaultForm, template_id: templates[0]?.id || '', allowed_image_ids: templates[0]?.id ? [templates[0].id] : [], image_limit_configured: true })
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
|
||||
@@ -241,7 +250,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', allowed_image_ids: [], image_limit_configured: false }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
LXC 容器
|
||||
@@ -252,7 +261,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||
onClick={() => {
|
||||
if (kvmAvailable) {
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', allowed_image_ids: [], image_limit_configured: false }))
|
||||
}
|
||||
}}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
@@ -270,7 +279,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
) : (
|
||||
<select
|
||||
value={form.template_id}
|
||||
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
||||
onChange={(event) => {
|
||||
const templateID = event.target.value
|
||||
const allowed = new Set(form.allowed_image_ids || [])
|
||||
if (templateID) allowed.add(templateID)
|
||||
setForm(applyTemplateDefaults({ ...form, template_id: templateID, allowed_image_ids: Array.from(allowed), image_limit_configured: true }))
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
@@ -283,6 +297,38 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
</Field>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<Field label="子用户可用镜像">
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs text-gray-500">默认勾选当前系统;取消后,子用户也不能重装该系统。</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{templates.map((template) => {
|
||||
const checked = (form.allowed_image_ids || []).includes(template.id)
|
||||
const current = template.id === form.template_id
|
||||
return (
|
||||
<label key={template.id} className={`flex cursor-pointer items-start gap-2 rounded border px-2.5 py-2 text-xs ${checked ? 'border-black bg-white' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const currentIDs = form.allowed_image_ids || []
|
||||
const next = checked ? currentIDs.filter((id) => id !== template.id) : [...currentIDs, template.id]
|
||||
setForm({ ...form, allowed_image_ids: next, image_limit_configured: true })
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-gray-800">{template.name}{current ? '(当前系统)' : ''}</span>
|
||||
<span className="block text-gray-500">{template.arch} · {template.distro} {template.release}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{linuxTemplate && (
|
||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
||||
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
||||
|
||||
@@ -523,10 +523,12 @@ export default function ContainerDetail() {
|
||||
|
||||
const openReinstall = async () => {
|
||||
try {
|
||||
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
||||
const res = await getEnabledImages(container?.virtualization || 'lxc', containerIdentifier)
|
||||
if (res.data.data) {
|
||||
setTemplates(res.data.data)
|
||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
||||
const data = res.data.data
|
||||
setTemplates(data)
|
||||
const currentTemplate = container?.template || ''
|
||||
setSelectedTemplate(data.some((template) => template.id === currentTemplate) ? currentTemplate : (data[0]?.id || ''))
|
||||
}
|
||||
setReinstallAuthMode('keep')
|
||||
setReinstallPasswordDraft('')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
|
||||
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import api, { AuditLog, LoginLog } from '../services/api'
|
||||
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
interface SubUserItem {
|
||||
@@ -9,6 +9,9 @@ interface SubUserItem {
|
||||
username: string
|
||||
container_names: string[]
|
||||
container_uuids: string[]
|
||||
allowed_image_ids?: string[]
|
||||
image_limit_configured?: boolean
|
||||
current_image_ids?: string[]
|
||||
container_name: string
|
||||
container_uuid: string
|
||||
access_code: string
|
||||
@@ -34,6 +37,11 @@ export default function SubUserManagement() {
|
||||
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
||||
const [modalTitle, setModalTitle] = useState('')
|
||||
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
||||
const [imageUser, setImageUser] = useState<SubUserItem | null>(null)
|
||||
const [images, setImages] = useState<ImageInfo[]>([])
|
||||
const [selectedImageIDs, setSelectedImageIDs] = useState<string[]>([])
|
||||
const [imagesLoading, setImagesLoading] = useState(false)
|
||||
const [savingImages, setSavingImages] = useState(false)
|
||||
const [rotatingPassword, setRotatingPassword] = useState(false)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
const [logPageSize, setLogPageSize] = useState(10)
|
||||
@@ -78,6 +86,46 @@ export default function SubUserManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const openImageLimit = async (user: SubUserItem) => {
|
||||
setImageUser(user)
|
||||
setSelectedImageIDs(user.allowed_image_ids || [])
|
||||
setImagesLoading(true)
|
||||
try {
|
||||
const res = await getImages()
|
||||
const currentIDs = new Set(user.current_image_ids || [])
|
||||
setImages((res.data.data || []).filter((image) => image.downloaded && (image.enabled || currentIDs.has(image.id))))
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('加载失败', error.response?.data?.message || '获取镜像列表失败')
|
||||
} finally {
|
||||
setImagesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleImageID = (id: string) => {
|
||||
setSelectedImageIDs((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])
|
||||
}
|
||||
|
||||
const saveImageLimit = async () => {
|
||||
if (!imageUser) return
|
||||
setSavingImages(true)
|
||||
try {
|
||||
const res = await updateSubUserImages(imageUser.id, selectedImageIDs)
|
||||
const updated = {
|
||||
...imageUser,
|
||||
allowed_image_ids: res.data.data?.allowed_image_ids || selectedImageIDs,
|
||||
image_limit_configured: true,
|
||||
}
|
||||
setUsers((prev) => prev.map((item) => (item.id === imageUser.id ? { ...item, allowed_image_ids: updated.allowed_image_ids, image_limit_configured: true } : item)))
|
||||
setImageUser(null)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('保存失败', error.response?.data?.message || '保存可用镜像失败')
|
||||
} finally {
|
||||
setSavingImages(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showAuditLogs = async (user: SubUserItem) => {
|
||||
try {
|
||||
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
||||
@@ -190,6 +238,14 @@ export default function SubUserManagement() {
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
登录日志
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openImageLimit(user)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors"
|
||||
title="可用镜像"
|
||||
>
|
||||
<HardDrive className="w-3.5 h-3.5" />
|
||||
可用镜像
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -253,6 +309,75 @@ export default function SubUserManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageUser && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">可用镜像</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{imageUser.username} · 默认勾选当前系统,取消后将禁止重装该系统</p>
|
||||
</div>
|
||||
<button onClick={() => setImageUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{imagesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-7 w-7 animate-spin rounded-full border-b-2 border-black" />
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-10 text-center text-sm text-gray-500">
|
||||
暂无已下载并启用的镜像
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{images.map((image) => {
|
||||
const checked = selectedImageIDs.includes(image.id)
|
||||
const current = (imageUser.current_image_ids || []).includes(image.id)
|
||||
return (
|
||||
<label
|
||||
key={image.id}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-3 text-sm transition-colors ${checked ? 'border-black bg-gray-50 dark:border-white dark:bg-gray-800' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleImageID(image.id)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-black dark:text-white">{image.name}{current ? '(当前系统)' : ''}</span>
|
||||
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
{image.type.toUpperCase()} · {image.arch} · {image.distro} {image.release}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">已选择 {selectedImageIDs.length} 个镜像</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setImageUser(null)} className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 rounded-md">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={saveImageLimit}
|
||||
disabled={savingImages || imagesLoading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{savingImages ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Modal */}
|
||||
{(auditLogs || loginLogs) && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
|
||||
@@ -164,6 +164,8 @@ export interface CreateContainerRequest {
|
||||
ssh_auth_mode?: string
|
||||
ssh_password?: string
|
||||
ssh_public_key?: string
|
||||
allowed_image_ids?: string[]
|
||||
image_limit_configured?: boolean
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
@@ -657,8 +659,8 @@ export const deleteImage = (templateId: string) =>
|
||||
export const toggleImage = (templateId: string, enabled: boolean) =>
|
||||
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
||||
|
||||
export const getEnabledImages = (virtualization = 'lxc') =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
|
||||
export const getEnabledImages = (virtualization = 'lxc', container?: ContainerIdentifier) =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization, ...(container ? { container: String(container) } : {}) } })
|
||||
|
||||
// Dashboard
|
||||
export const getDashboard = () =>
|
||||
@@ -771,6 +773,9 @@ export interface SubUser {
|
||||
password?: string
|
||||
container_names: string[]
|
||||
container_uuids?: string[]
|
||||
allowed_image_ids?: string[]
|
||||
image_limit_configured?: boolean
|
||||
current_image_ids?: string[]
|
||||
access_code: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -778,6 +783,9 @@ export interface SubUser {
|
||||
export const createSubUser = (containerId: ContainerIdentifier) =>
|
||||
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
|
||||
|
||||
export const updateSubUserImages = (id: string, allowedImageIds: string[]) =>
|
||||
api.put<APIResponse<SubUser>>(`/sub-users/${id}/images`, { allowed_image_ids: allowedImageIds })
|
||||
|
||||
// Audit Logs
|
||||
export interface AuditLog {
|
||||
time: string
|
||||
|
||||
Reference in New Issue
Block a user