mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
feat: system Settings
This commit is contained in:
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin5Line } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Page } from "@/components/page"
|
||||
@@ -12,7 +13,9 @@ import { SearchInput } from "@/components/search-input"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useEventTarget } from "@/hooks/use-event-target"
|
||||
import { useModuleSwitches } from "@/hooks/use-module-switches"
|
||||
import { EventsTargetNewForm } from "@/components/events-target/new-form"
|
||||
import { canManageEventDestinations } from "@/lib/event-destinations-access"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
@@ -33,24 +36,40 @@ export default function EventsTargetPage() {
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { getEventsTargetList, deleteEventTarget } = useEventTarget()
|
||||
const { getModuleSwitches } = useModuleSwitches()
|
||||
|
||||
const [data, setData] = useState<RowData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [newFormOpen, setNewFormOpen] = useState(false)
|
||||
const [notifyEnabled, setNotifyEnabled] = useState<boolean | undefined>(undefined)
|
||||
|
||||
const canManageDestinations = canManageEventDestinations(notifyEnabled)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getEventsTargetList()
|
||||
const list = (response?.notification_endpoints ?? []) as RowData[]
|
||||
const [response, switches] = await Promise.allSettled([
|
||||
getEventsTargetList(),
|
||||
getModuleSwitches({ suppress403Redirect: true }),
|
||||
])
|
||||
|
||||
if (switches.status === "fulfilled" && switches.value) {
|
||||
setNotifyEnabled(switches.value.notify_enabled)
|
||||
}
|
||||
|
||||
if (response.status === "rejected") {
|
||||
throw response.reason
|
||||
}
|
||||
|
||||
const list = (response.value?.notification_endpoints ?? []) as RowData[]
|
||||
setData(list)
|
||||
} catch {
|
||||
setData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [getEventsTargetList])
|
||||
}, [getEventsTargetList, getModuleSwitches])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -69,6 +88,11 @@ export default function EventsTargetPage() {
|
||||
|
||||
const deleteItem = useCallback(
|
||||
async (row: RowData) => {
|
||||
if (!canManageDestinations) {
|
||||
message.warning(t("Notify is disabled. Enable notify before managing event destinations."))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteEventTarget(`notify_${row.service}`, row.account_id)
|
||||
message.success(t("Delete Success"))
|
||||
@@ -79,11 +103,12 @@ export default function EventsTargetPage() {
|
||||
message.error(msg)
|
||||
}
|
||||
},
|
||||
[deleteEventTarget, loadData, message, t],
|
||||
[canManageDestinations, deleteEventTarget, loadData, message, t],
|
||||
)
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(row: RowData) => {
|
||||
if (!canManageDestinations) return
|
||||
if (!isConfigSource(row.source)) return
|
||||
|
||||
dialog.error({
|
||||
@@ -94,7 +119,7 @@ export default function EventsTargetPage() {
|
||||
onPositiveClick: () => deleteItem(row),
|
||||
})
|
||||
},
|
||||
[deleteItem, dialog, t],
|
||||
[canManageDestinations, deleteItem, dialog, t],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<RowData>[] = useMemo(
|
||||
@@ -138,7 +163,12 @@ export default function EventsTargetPage() {
|
||||
cell: ({ row }) =>
|
||||
isConfigSource(row.original.source) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
disabled={!canManageDestinations}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
@@ -148,7 +178,7 @@ export default function EventsTargetPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[confirmDelete, t],
|
||||
[canManageDestinations, confirmDelete, t],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<RowData>({
|
||||
@@ -171,7 +201,7 @@ export default function EventsTargetPage() {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!canManageDestinations}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Event Destination")}</span>
|
||||
</Button>
|
||||
@@ -185,6 +215,13 @@ export default function EventsTargetPage() {
|
||||
<h1 className="text-2xl font-bold">{t("Event Destinations")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
{!canManageDestinations ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Notify is disabled")}</AlertTitle>
|
||||
<AlertDescription>{t("Enable notify in Settings before managing event destinations.")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { ProviderList } from "@/components/oidc/provider-list"
|
||||
import { OidcForm } from "@/components/oidc/form"
|
||||
import { useOidcConfig } from "@/hooks/use-oidc-config"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { CONSOLE_SCOPES } from "@/lib/console-permissions"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type {
|
||||
@@ -164,6 +166,8 @@ export default function OidcPage() {
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { getOidcConfig, saveOidcConfig, deleteOidcConfig, validateOidcConfig } = useOidcConfig()
|
||||
const { isAdmin, hasPermission } = usePermissions()
|
||||
const canUpdateOidcProviders = isAdmin || hasPermission(["admin:ConfigUpdate", CONSOLE_SCOPES.CONSOLE_ADMIN], false)
|
||||
|
||||
const [providers, setProviders] = useState<OidcConfigProvider[]>([])
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
|
||||
@@ -182,7 +186,8 @@ export default function OidcPage() {
|
||||
[providers, selectedProviderId],
|
||||
)
|
||||
const isCreateMode = selectedProvider === null
|
||||
const isReadOnly = selectedProvider?.editable === false || selectedProvider?.source === "env"
|
||||
const isProviderReadOnly = selectedProvider?.editable === false || selectedProvider?.source === "env"
|
||||
const isReadOnly = !canUpdateOidcProviders || isProviderReadOnly
|
||||
const isDirty = JSON.stringify(formValues) !== JSON.stringify(baselineValues)
|
||||
|
||||
const applySelection = useCallback((providerId: string | null, nextProviders: OidcConfigProvider[]) => {
|
||||
@@ -262,6 +267,8 @@ export default function OidcPage() {
|
||||
|
||||
const requestSelection = useCallback(
|
||||
(providerId: string | null) => {
|
||||
if (providerId === null && !canUpdateOidcProviders) return
|
||||
|
||||
const isSameSelection =
|
||||
(providerId === null && selectedProviderIdRef.current === null) || providerId === selectedProviderIdRef.current
|
||||
if (isSameSelection) return
|
||||
@@ -273,7 +280,7 @@ export default function OidcPage() {
|
||||
}
|
||||
select()
|
||||
},
|
||||
[applySelection, confirmDiscardChanges, isDirty, providers],
|
||||
[applySelection, canUpdateOidcProviders, confirmDiscardChanges, isDirty, providers],
|
||||
)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
@@ -291,7 +298,11 @@ export default function OidcPage() {
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isReadOnly) return
|
||||
if (!canUpdateOidcProviders) {
|
||||
message.warning(t("You do not have permission to update OIDC providers."))
|
||||
return
|
||||
}
|
||||
if (isProviderReadOnly) return
|
||||
|
||||
const errors = validateForm(
|
||||
formValues,
|
||||
@@ -323,10 +334,15 @@ export default function OidcPage() {
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [formValues, isCreateMode, isReadOnly, loadProviders, message, saveOidcConfig, t])
|
||||
}, [canUpdateOidcProviders, formValues, isCreateMode, isProviderReadOnly, loadProviders, message, saveOidcConfig, t])
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
if (!isCreateMode || isReadOnly) return
|
||||
if (!isCreateMode) return
|
||||
if (!canUpdateOidcProviders) {
|
||||
message.warning(t("You do not have permission to update OIDC providers."))
|
||||
return
|
||||
}
|
||||
if (isProviderReadOnly) return
|
||||
|
||||
const errors = validateForm(
|
||||
formValues,
|
||||
@@ -359,10 +375,15 @@ export default function OidcPage() {
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
}, [formValues, isCreateMode, isReadOnly, message, t, validateOidcConfig])
|
||||
}, [canUpdateOidcProviders, formValues, isCreateMode, isProviderReadOnly, message, t, validateOidcConfig])
|
||||
|
||||
const performDelete = useCallback(async () => {
|
||||
if (!selectedProvider || isReadOnly) return
|
||||
if (!selectedProvider) return
|
||||
if (!canUpdateOidcProviders) {
|
||||
message.warning(t("You do not have permission to update OIDC providers."))
|
||||
return
|
||||
}
|
||||
if (isProviderReadOnly) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
@@ -377,10 +398,15 @@ export default function OidcPage() {
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [deleteOidcConfig, isReadOnly, loadProviders, message, selectedProvider, t])
|
||||
}, [canUpdateOidcProviders, deleteOidcConfig, isProviderReadOnly, loadProviders, message, selectedProvider, t])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedProvider || isReadOnly) return
|
||||
if (!selectedProvider) return
|
||||
if (!canUpdateOidcProviders) {
|
||||
message.warning(t("You do not have permission to update OIDC providers."))
|
||||
return
|
||||
}
|
||||
if (isProviderReadOnly) return
|
||||
|
||||
dialog.error({
|
||||
title: t("Delete"),
|
||||
@@ -389,7 +415,7 @@ export default function OidcPage() {
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: performDelete,
|
||||
})
|
||||
}, [dialog, isReadOnly, performDelete, selectedProvider, t])
|
||||
}, [canUpdateOidcProviders, dialog, isProviderReadOnly, message, performDelete, selectedProvider, t])
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -406,11 +432,19 @@ export default function OidcPage() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{!canUpdateOidcProviders ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Read-only OIDC settings")}</AlertTitle>
|
||||
<AlertDescription>{t("You do not have permission to update OIDC providers.")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
loading={loading}
|
||||
selectedProviderId={selectedProviderId}
|
||||
canAddProvider={canUpdateOidcProviders}
|
||||
onAddProvider={() => requestSelection(null)}
|
||||
onSelectProvider={(providerId) => requestSelection(providerId)}
|
||||
/>
|
||||
@@ -423,6 +457,9 @@ export default function OidcPage() {
|
||||
isReadOnly={isReadOnly}
|
||||
secretConfigured={selectedProvider?.client_secret_configured ?? false}
|
||||
restartRequired={restartRequired}
|
||||
readOnlyDescription={
|
||||
!canUpdateOidcProviders ? t("You do not have permission to update OIDC providers.") : undefined
|
||||
}
|
||||
isSaving={saving}
|
||||
isValidating={validating}
|
||||
validateResult={validateResult}
|
||||
|
||||
+180
-138
@@ -1,177 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { RiRefreshLine, RiSave3Line } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { configManager } from "@/lib/config"
|
||||
import { useModuleSwitches } from "@/hooks/use-module-switches"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import {
|
||||
getModuleSwitchEnvKey,
|
||||
isEnvManagedSource,
|
||||
isModuleSwitchEnvConflictError,
|
||||
type ModuleSwitchName,
|
||||
type ModuleSwitchPayload,
|
||||
type ModuleSwitchSnapshot,
|
||||
type ModuleSwitchSource,
|
||||
} from "@/lib/module-switches"
|
||||
import { CONSOLE_SCOPES } from "@/lib/console-permissions"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
|
||||
interface CurrentConfig {
|
||||
serverHost: string
|
||||
api: { baseURL: string }
|
||||
s3: { endpoint: string; region: string }
|
||||
function getSourceLabel(source: ModuleSwitchSource, t: (key: string) => string) {
|
||||
if (source === "env") return t("ENV")
|
||||
if (source === "console") return t("Console")
|
||||
return t("Default")
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { getModuleSwitches, saveModuleSwitches } = useModuleSwitches()
|
||||
const { isAdmin, hasPermission } = usePermissions()
|
||||
const canUpdateModuleSwitches = isAdmin || hasPermission(["admin:ConfigUpdate", CONSOLE_SCOPES.CONSOLE_ADMIN], false)
|
||||
|
||||
const [currentConfig, setCurrentConfig] = useState<CurrentConfig>({
|
||||
serverHost: "",
|
||||
api: { baseURL: "" },
|
||||
s3: { endpoint: "", region: "" },
|
||||
const [snapshot, setSnapshot] = useState<ModuleSwitchSnapshot | null>(null)
|
||||
const [formValues, setFormValues] = useState<ModuleSwitchPayload>({
|
||||
notify_enabled: false,
|
||||
audit_enabled: false,
|
||||
})
|
||||
const [formData, setFormData] = useState({ serverHost: "" })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadCurrentConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await configManager.loadConfig()
|
||||
setCurrentConfig({
|
||||
serverHost: config.serverHost,
|
||||
api: { baseURL: config.api.baseURL },
|
||||
s3: {
|
||||
endpoint: config.s3.endpoint ?? "",
|
||||
region: config.s3.region ?? "",
|
||||
},
|
||||
})
|
||||
setFormData({ serverHost: config.serverHost })
|
||||
} catch {
|
||||
message.error(t("Failed to load current configuration"))
|
||||
}
|
||||
}, [message, t])
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentConfig()
|
||||
}, [loadCurrentConfig])
|
||||
|
||||
const currentItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("Server Host"),
|
||||
value: currentConfig.serverHost || t("Not configured"),
|
||||
},
|
||||
{
|
||||
label: t("API Base URL"),
|
||||
value: currentConfig.api.baseURL || t("Not configured"),
|
||||
},
|
||||
{
|
||||
label: t("S3 Endpoint"),
|
||||
value: currentConfig.s3.endpoint || t("Not configured"),
|
||||
},
|
||||
{
|
||||
label: t("S3 Region"),
|
||||
value: currentConfig.s3.region || t("Not configured"),
|
||||
},
|
||||
],
|
||||
[currentConfig, t],
|
||||
)
|
||||
|
||||
const saveConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formData.serverHost) {
|
||||
message.error(t("Please enter server address"))
|
||||
return
|
||||
}
|
||||
const applySnapshot = useCallback((nextSnapshot: ModuleSwitchSnapshot) => {
|
||||
setSnapshot(nextSnapshot)
|
||||
setFormValues({
|
||||
notify_enabled: nextSnapshot.notify_enabled,
|
||||
audit_enabled: nextSnapshot.audit_enabled,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadSwitches = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
let urlToValidate = formData.serverHost.trim()
|
||||
if (!/^https?:\/\//.test(urlToValidate)) {
|
||||
urlToValidate = "https://" + urlToValidate
|
||||
}
|
||||
|
||||
new URL(urlToValidate)
|
||||
const urlToSave = /^https?:\/\//.test(formData.serverHost) ? formData.serverHost : urlToValidate
|
||||
|
||||
localStorage.setItem("rustfs-server-host", urlToSave)
|
||||
|
||||
if (!/^https?:\/\//.test(formData.serverHost)) {
|
||||
setFormData({ serverHost: urlToValidate })
|
||||
}
|
||||
|
||||
configManager.clearCache()
|
||||
message.success(t("Configuration saved successfully"))
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 200)
|
||||
} catch {
|
||||
message.error(t("Invalid server address format"))
|
||||
applySnapshot(await getModuleSwitches())
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Failed to load module switches"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [applySnapshot, getModuleSwitches, message, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadSwitches()
|
||||
}, [loadSwitches])
|
||||
|
||||
const hasEnvManagedSwitch = snapshot
|
||||
? isEnvManagedSource(snapshot.notify_source) || isEnvManagedSource(snapshot.audit_source)
|
||||
: false
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!snapshot) return false
|
||||
return formValues.notify_enabled !== snapshot.notify_enabled || formValues.audit_enabled !== snapshot.audit_enabled
|
||||
}, [formValues, snapshot])
|
||||
|
||||
const updateSwitch = (moduleName: ModuleSwitchName, checked: boolean) => {
|
||||
if (!canUpdateModuleSwitches) return
|
||||
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
[`${moduleName}_enabled`]: checked,
|
||||
}))
|
||||
}
|
||||
|
||||
const resetConfig = () => {
|
||||
localStorage.removeItem("rustfs-server-host")
|
||||
configManager.clearCache()
|
||||
message.success(t("Configuration reset successfully"))
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 200)
|
||||
const handleSave = async () => {
|
||||
if (!snapshot) return
|
||||
if (!canUpdateModuleSwitches) {
|
||||
message.warning(t("You do not have permission to update module switches."))
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
applySnapshot(await saveModuleSwitches(formValues))
|
||||
message.success(t("Module switches saved"))
|
||||
} catch (error) {
|
||||
const description = (error as Error).message || t("Save Failed")
|
||||
if (isModuleSwitchEnvConflictError(error)) {
|
||||
message.error(t("Module switch is managed by environment variables"), { description })
|
||||
} else {
|
||||
message.error(description)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const switchItems = useMemo(
|
||||
() =>
|
||||
snapshot
|
||||
? [
|
||||
{
|
||||
name: "notify" as const,
|
||||
label: t("Notify"),
|
||||
checked: formValues.notify_enabled,
|
||||
source: snapshot.notify_source,
|
||||
},
|
||||
{
|
||||
name: "audit" as const,
|
||||
label: t("Audit"),
|
||||
checked: formValues.audit_enabled,
|
||||
source: snapshot.audit_source,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[formValues, snapshot, t],
|
||||
)
|
||||
|
||||
if (loading && !snapshot) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">{t("Settings")}</h1>
|
||||
</PageHeader>
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={loadSwitches} disabled={loading || saving}>
|
||||
<RiRefreshLine className="me-2 size-4" aria-hidden />
|
||||
{t("Sync")}
|
||||
</Button>
|
||||
{canUpdateModuleSwitches ? (
|
||||
<Button type="button" onClick={handleSave} disabled={!snapshot || !isDirty || loading || saving}>
|
||||
{saving ? <Spinner className="me-2 size-4" /> : <RiSave3Line className="me-2 size-4" aria-hidden />}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Settings")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("Current Configuration")}</h2>
|
||||
<dl className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{currentItems.map((item) => (
|
||||
<div key={item.label} className="rounded-md border p-3">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{item.label}</dt>
|
||||
<dd className="mt-1 text-sm">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("Server Configuration")}</h2>
|
||||
<form className="space-y-4" onSubmit={saveConfig}>
|
||||
<Field>
|
||||
<FieldLabel>{t("Server Address")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
value={formData.serverHost}
|
||||
onChange={(e) => setFormData({ serverHost: e.target.value })}
|
||||
placeholder={t("Please enter server address (e.g., http://localhost:9000)")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>{t("Example: http://localhost:9000 or https://your-domain.com")}</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" variant="default" disabled={loading}>
|
||||
{t("Save Configuration")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={resetConfig}>
|
||||
{t("Reset to Default")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4">
|
||||
{hasEnvManagedSwitch ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Configuration Information")}</AlertTitle>
|
||||
<AlertTitle>{t("Environment variables are controlling some switches")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc space-y-1 text-sm">
|
||||
<li>{t("Configuration is saved locally in your browser")}</li>
|
||||
<li>{t("Page will refresh automatically after saving configuration")}</li>
|
||||
<li>{t("Make sure the server address is accessible from your network")}</li>
|
||||
</ul>
|
||||
{t("Update the deployment environment first before changing ENV-managed module switches.")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!canUpdateModuleSwitches ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Read-only settings")}</AlertTitle>
|
||||
<AlertDescription>{t("You do not have permission to update module switches.")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="divide-y rounded-none border">
|
||||
{switchItems.map((item) => {
|
||||
const isEnvManaged = isEnvManagedSource(item.source)
|
||||
return (
|
||||
<Field key={item.name} className="grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FieldLabel htmlFor={`${item.name}-module-switch`} className="text-sm font-medium">
|
||||
{item.label}
|
||||
</FieldLabel>
|
||||
<Badge variant={item.checked ? "default" : "secondary"}>
|
||||
{item.checked ? t("Enabled") : t("Disabled")}
|
||||
</Badge>
|
||||
<Badge variant={isEnvManaged ? "destructive" : "outline"}>{getSourceLabel(item.source, t)}</Badge>
|
||||
</div>
|
||||
{isEnvManaged ? (
|
||||
<FieldDescription>
|
||||
{t("Controlled by {envKey}", { envKey: getModuleSwitchEnvKey(item.name) })}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<FieldContent className="md:justify-self-end">
|
||||
<Switch
|
||||
id={`${item.name}-module-switch`}
|
||||
checked={item.checked}
|
||||
onCheckedChange={(checked) => updateSwitch(item.name, checked)}
|
||||
disabled={!canUpdateModuleSwitches || isEnvManaged || loading || saving}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { EventsNewForm } from "@/components/events/new-form"
|
||||
import { getEventsColumns } from "@/components/events/columns"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { useModuleSwitches } from "@/hooks/use-module-switches"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { canManageNotifyBackedFeature } from "@/lib/notify-module-access"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type { NotificationItem } from "@/lib/events"
|
||||
@@ -24,17 +27,34 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
const dialog = useDialog()
|
||||
const { canCapability } = usePermissions()
|
||||
const { listBucketNotifications, putBucketNotifications } = useBucket()
|
||||
const { getModuleSwitches } = useModuleSwitches()
|
||||
const eventsContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
|
||||
const canEditEvents = canCapability("bucket.events.edit", eventsContext)
|
||||
|
||||
const [data, setData] = React.useState<NotificationItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [newFormOpen, setNewFormOpen] = React.useState(false)
|
||||
const [notifyEnabled, setNotifyEnabled] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
const canManageBucketEvents = canEditEvents && canManageNotifyBackedFeature(notifyEnabled)
|
||||
|
||||
const loadData = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await listBucketNotifications(bucketName)
|
||||
const [response, switches] = await Promise.allSettled([
|
||||
listBucketNotifications(bucketName),
|
||||
getModuleSwitches({ suppress403Redirect: true }),
|
||||
])
|
||||
|
||||
if (switches.status === "fulfilled" && switches.value) {
|
||||
setNotifyEnabled(switches.value.notify_enabled)
|
||||
}
|
||||
|
||||
if (response.status === "rejected") {
|
||||
throw response.reason
|
||||
}
|
||||
|
||||
const responseValue = response.value
|
||||
const notifications: NotificationItem[] = []
|
||||
|
||||
const addFromConfig = (
|
||||
@@ -71,7 +91,7 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const r = response as {
|
||||
const r = responseValue as {
|
||||
LambdaFunctionConfigurations?: unknown[]
|
||||
QueueConfigurations?: unknown[]
|
||||
TopicConfigurations?: unknown[]
|
||||
@@ -87,7 +107,7 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [bucketName, listBucketNotifications])
|
||||
}, [bucketName, getModuleSwitches, listBucketNotifications])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData()
|
||||
@@ -96,6 +116,11 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
const handleRowDelete = React.useCallback(
|
||||
async (row: NotificationItem) => {
|
||||
if (!canEditEvents) return
|
||||
if (!canManageBucketEvents) {
|
||||
message.warning(t("Notify is disabled. Enable notify before managing bucket event subscriptions."))
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
dialog.warning({
|
||||
title: t("Confirm Delete"),
|
||||
@@ -141,12 +166,22 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[bucketName, canEditEvents, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t],
|
||||
[
|
||||
bucketName,
|
||||
canEditEvents,
|
||||
canManageBucketEvents,
|
||||
dialog,
|
||||
listBucketNotifications,
|
||||
loadData,
|
||||
message,
|
||||
putBucketNotifications,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
const columns = React.useMemo(
|
||||
() => getEventsColumns(t, handleRowDelete, canEditEvents),
|
||||
[canEditEvents, t, handleRowDelete],
|
||||
() => getEventsColumns(t, handleRowDelete, canManageBucketEvents),
|
||||
[canManageBucketEvents, t, handleRowDelete],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<NotificationItem>({
|
||||
@@ -161,7 +196,7 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
<h2 className="text-lg font-medium">{t("Events")}</h2>
|
||||
<div className="flex gap-2">
|
||||
{canEditEvents ? (
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!canManageBucketEvents}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Event Subscription")}</span>
|
||||
</Button>
|
||||
@@ -173,6 +208,15 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEditEvents && !canManageBucketEvents ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Notify is disabled")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("Enable notify in Settings before managing bucket event subscriptions.")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
@@ -180,7 +224,13 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
emptyDescription={t("Add Event Subscription to get started")}
|
||||
/>
|
||||
|
||||
<EventsNewForm open={newFormOpen} onOpenChange={setNewFormOpen} bucketName={bucketName} onSuccess={loadData} />
|
||||
<EventsNewForm
|
||||
open={newFormOpen}
|
||||
onOpenChange={setNewFormOpen}
|
||||
bucketName={bucketName}
|
||||
onSuccess={loadData}
|
||||
disabled={!canManageBucketEvents}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface EventsNewFormProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
bucketName: string
|
||||
onSuccess?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
@@ -42,7 +43,7 @@ const EVENT_MAPPING: Record<string, string[]> = {
|
||||
SCANNER: ["s3:Scanner:ManyVersions", "s3:Scanner:BigPrefix"],
|
||||
}
|
||||
|
||||
export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: EventsNewFormProps) {
|
||||
export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disabled = false }: EventsNewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { getEventTargetArnList } = useEventTarget()
|
||||
@@ -100,6 +101,11 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (disabled) {
|
||||
message.warning(t("Notify is disabled. Enable notify before managing bucket event subscriptions."))
|
||||
return
|
||||
}
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
try {
|
||||
@@ -195,7 +201,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve
|
||||
<Field>
|
||||
<FieldLabel htmlFor="event-resource-name">{t("Amazon Resource Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select value={resourceName} onValueChange={setResourceName} disabled={!arnList.length}>
|
||||
<Select value={resourceName} onValueChange={setResourceName} disabled={disabled || !arnList.length}>
|
||||
<SelectTrigger id="event-resource-name">
|
||||
<SelectValue placeholder={t("Please select resource name")} />
|
||||
</SelectTrigger>
|
||||
@@ -218,6 +224,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve
|
||||
id="event-prefix"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={t("Please enter prefix")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -230,6 +237,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve
|
||||
id="event-suffix"
|
||||
value={suffix}
|
||||
onChange={(e) => setSuffix(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={t("Please enter suffix")}
|
||||
/>
|
||||
</FieldContent>
|
||||
@@ -245,6 +253,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve
|
||||
<Checkbox
|
||||
checked={events.includes(event.value)}
|
||||
onCheckedChange={(v) => handleEventChecked(event.value, v)}
|
||||
disabled={disabled}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>{t(event.labelKey)}</span>
|
||||
@@ -261,7 +270,9 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>{t("Save")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={disabled}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface OidcFormProps {
|
||||
isReadOnly: boolean
|
||||
secretConfigured: boolean
|
||||
restartRequired: boolean
|
||||
readOnlyDescription?: string
|
||||
isSaving: boolean
|
||||
isValidating: boolean
|
||||
validateResult: ValidateOidcConfigResponse | null
|
||||
@@ -44,6 +45,7 @@ export function OidcForm({
|
||||
isReadOnly,
|
||||
secretConfigured,
|
||||
restartRequired,
|
||||
readOnlyDescription,
|
||||
isSaving,
|
||||
isValidating,
|
||||
validateResult,
|
||||
@@ -74,13 +76,13 @@ export function OidcForm({
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isReadOnly
|
||||
? t("Environment-managed providers are read-only")
|
||||
? (readOnlyDescription ?? t("Environment-managed providers are read-only"))
|
||||
: t("Changes will take effect after RustFS restarts")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isCreateMode ? (
|
||||
{isCreateMode && !isReadOnly ? (
|
||||
<Button type="button" variant="outline" onClick={onValidate} disabled={isValidating || isSaving}>
|
||||
{isValidating ? t("Validating...") : t("Validate")}
|
||||
</Button>
|
||||
@@ -143,7 +145,7 @@ export function OidcForm({
|
||||
value={values.provider_id}
|
||||
onChange={(event) => onChange("provider_id", event.target.value)}
|
||||
placeholder={t("Provider ID")}
|
||||
disabled={!isCreateMode}
|
||||
disabled={!isCreateMode || isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>{t("Only letters, numbers, underscores, and hyphens are allowed.")}</FieldDescription>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ProviderListProps {
|
||||
providers: OidcConfigProvider[]
|
||||
loading?: boolean
|
||||
selectedProviderId: string | null
|
||||
canAddProvider?: boolean
|
||||
onAddProvider: () => void
|
||||
onSelectProvider: (providerId: string) => void
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export function ProviderList({
|
||||
providers,
|
||||
loading = false,
|
||||
selectedProviderId,
|
||||
canAddProvider = true,
|
||||
onAddProvider,
|
||||
onSelectProvider,
|
||||
}: ProviderListProps) {
|
||||
@@ -36,10 +38,12 @@ export function ProviderList({
|
||||
<h2 className="text-sm font-semibold">{t("OIDC Providers")}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("View and manage persisted OIDC providers.")}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAddProvider}>
|
||||
<RiAddLine className="size-4" />
|
||||
{t("Add Provider")}
|
||||
</Button>
|
||||
{canAddProvider ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAddProvider}>
|
||||
<RiAddLine className="size-4" />
|
||||
{t("Add Provider")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
|
||||
@@ -90,6 +90,11 @@ export default [
|
||||
to: "/oidc",
|
||||
icon: "ri:fingerprint-line",
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
to: "/settings",
|
||||
icon: "ri:settings-line",
|
||||
},
|
||||
{
|
||||
label: "divider",
|
||||
key: "divider-2",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useApi } from "@/contexts/api-context"
|
||||
import type { ModuleSwitchPayload, ModuleSwitchSnapshot } from "@/lib/module-switches"
|
||||
|
||||
export function useModuleSwitches() {
|
||||
const api = useApi()
|
||||
|
||||
const getModuleSwitches = useCallback(
|
||||
async (options?: { suppress403Redirect?: boolean }) => {
|
||||
return (await api.get("/module-switches", options)) as ModuleSwitchSnapshot
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const saveModuleSwitches = useCallback(
|
||||
async (payload: ModuleSwitchPayload) => {
|
||||
return (await api.put("/module-switches", payload)) as ModuleSwitchSnapshot
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
return {
|
||||
getModuleSwitches,
|
||||
saveModuleSwitches,
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,8 @@
|
||||
"Confirm New Password": "Confirm New Password",
|
||||
"Confirm Remove Encryption": "Confirm Remove Encryption",
|
||||
"Contact Support": "Contact Support",
|
||||
"Console": "Console",
|
||||
"Controlled by {envKey}": "Controlled by {envKey}",
|
||||
"Copy": "Copy",
|
||||
"Copy Failed": "Copy Failed",
|
||||
"Copy Success": "Copy Success",
|
||||
@@ -203,6 +205,7 @@
|
||||
"Days After": "Days After",
|
||||
"Default Key ID": "Default Key ID",
|
||||
"Default master key ID for SSE-KMS": "Default master key ID for SSE-KMS",
|
||||
"Default": "Default",
|
||||
"Delete": "Delete",
|
||||
"Delete Failed": "Delete Failed",
|
||||
"Delete Key": "Delete Key",
|
||||
@@ -235,8 +238,11 @@
|
||||
"Edit Policy": "Edit Policy",
|
||||
"Edit Success": "Edit Success",
|
||||
"Edit User": "Edit User",
|
||||
"ENV": "ENV",
|
||||
"Emergency Response": "Emergency Response",
|
||||
"Enable Cache": "Enable Cache",
|
||||
"Enable notify in Settings before managing bucket event subscriptions.": "Enable notify in Settings before managing bucket event subscriptions.",
|
||||
"Enable notify in Settings before managing event destinations.": "Enable notify in Settings before managing event destinations.",
|
||||
"Enable Storage Encryption": "Enable Storage Encryption",
|
||||
"Enable caching for better performance, default: true": "Enable caching for better performance, default: true",
|
||||
"Enable secure transport when connecting to endpoint.": "Enable secure transport when connecting to endpoint.",
|
||||
@@ -256,6 +262,7 @@
|
||||
"Enterprise License": "Enterprise License",
|
||||
"Enterprise Service Level": "Enterprise Service Level",
|
||||
"Error": "Error",
|
||||
"Environment variables are controlling some switches": "Environment variables are controlling some switches",
|
||||
"Event Destinations": "Event Destinations",
|
||||
"Event Target created successfully": "Event Target created successfully",
|
||||
"Events": "Events",
|
||||
@@ -291,6 +298,7 @@
|
||||
"Failed to load bucket list": "Failed to load bucket list",
|
||||
"Failed to load current configuration": "Failed to load current configuration",
|
||||
"Failed to load key list": "Failed to load key list",
|
||||
"Failed to load module switches": "Failed to load module switches",
|
||||
"Failed to refresh key list": "Failed to refresh key list",
|
||||
"Failed to refresh status": "Failed to refresh status",
|
||||
"Failed to remove bucket encryption": "Failed to remove bucket encryption",
|
||||
@@ -475,6 +483,8 @@
|
||||
"Mode": "Mode",
|
||||
"Monday": "Monday",
|
||||
"Monitor overall storage usage and recent scanner activity at a glance.": "Monitor overall storage usage and recent scanner activity at a glance.",
|
||||
"Module switch is managed by environment variables": "Module switch is managed by environment variables",
|
||||
"Module switches saved": "Module switches saved",
|
||||
"More Configurations": "More Configurations",
|
||||
"Multi-Cloud Storage": "Multi-Cloud Storage",
|
||||
"Multipart Upload": "Multipart Upload",
|
||||
@@ -489,6 +499,10 @@
|
||||
"New Secret Key": "New Secret Key",
|
||||
"New Policy": "New Policy",
|
||||
"New user has been created": "New user has been created",
|
||||
"Notify": "Notify",
|
||||
"Notify is disabled": "Notify is disabled",
|
||||
"Notify is disabled. Enable notify before managing bucket event subscriptions.": "Notify is disabled. Enable notify before managing bucket event subscriptions.",
|
||||
"Notify is disabled. Enable notify before managing event destinations.": "Notify is disabled. Enable notify before managing event destinations.",
|
||||
"Next": "Next",
|
||||
"Next Page": "Next Page",
|
||||
"No": "No",
|
||||
@@ -634,6 +648,8 @@
|
||||
"Public": "Public",
|
||||
"Public, Private, Custom": "Public, Private, Custom",
|
||||
"Read/Write Performance": "Read/Write Performance",
|
||||
"Read-only OIDC settings": "Read-only OIDC settings",
|
||||
"Read-only settings": "Read-only settings",
|
||||
"Reading Folder Files": "Reading Folder Files",
|
||||
"Ready to import: {filename}": "Ready to import: {filename}",
|
||||
"Real-time status of cluster servers and backend storage devices.": "Real-time status of cluster servers and backend storage devices.",
|
||||
@@ -852,6 +868,7 @@
|
||||
"Update Success": "Update Success",
|
||||
"Update failed": "Update failed",
|
||||
"Update success": "Update success",
|
||||
"Update the deployment environment first before changing ENV-managed module switches.": "Update the deployment environment first before changing ENV-managed module switches.",
|
||||
"Updated At": "Updated At",
|
||||
"Updated successfully": "Updated successfully",
|
||||
"Upload": "Upload",
|
||||
@@ -941,6 +958,8 @@
|
||||
"JSON is already formatted": "JSON is already formatted",
|
||||
"Access Denied": "Access Denied",
|
||||
"You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.": "You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.",
|
||||
"You do not have permission to update OIDC providers.": "You do not have permission to update OIDC providers.",
|
||||
"You do not have permission to update module switches.": "You do not have permission to update module switches.",
|
||||
"Back to Home": "Back to Home",
|
||||
"Your session has expired. Please log in again.": "Your session has expired. Please log in again.",
|
||||
"Add Provider": "Add Provider",
|
||||
|
||||
@@ -158,6 +158,8 @@
|
||||
"Confirm Remove Encryption": "确认移除加密",
|
||||
"Contact Support": "联系支持",
|
||||
"Continue Upload": "继续上传",
|
||||
"Console": "Console",
|
||||
"Controlled by {envKey}": "由 {envKey} 控制",
|
||||
"Copy": "复制",
|
||||
"Copy Failed": "复制失败",
|
||||
"Copy Success": "复制成功",
|
||||
@@ -205,6 +207,7 @@
|
||||
"Days must be between 0 and 7": "天数必须在0到7之间",
|
||||
"Default Key ID": "默认密钥ID",
|
||||
"Default master key ID for SSE-KMS": "SSE-KMS 的默认主密钥 ID",
|
||||
"Default": "默认",
|
||||
"Delete": "删除",
|
||||
"Delete All Versions": "删除所有版本",
|
||||
"Delete Failed": "删除失败",
|
||||
@@ -238,8 +241,11 @@
|
||||
"Edit Policy": "编辑策略",
|
||||
"Edit Success": "修改成功",
|
||||
"Edit User": "编辑用户",
|
||||
"ENV": "环境变量",
|
||||
"Emergency Response": "紧急响应",
|
||||
"Enable Cache": "启用缓存",
|
||||
"Enable notify in Settings before managing bucket event subscriptions.": "请先在设置中启用 notify,再管理存储桶事件订阅。",
|
||||
"Enable notify in Settings before managing event destinations.": "请先在设置中启用 notify,再管理事件目标。",
|
||||
"Enable Storage Encryption": "启用存储空间加密",
|
||||
"Enable caching for better performance, default: true": "启用缓存以提高性能,默认:true",
|
||||
"Enable secure transport when connecting to endpoint.": "连接到端点时启用安全传输。",
|
||||
@@ -259,6 +265,7 @@
|
||||
"Enterprise License": "企业版许可证",
|
||||
"Enterprise Service Level": "企业服务级别",
|
||||
"Error": "错误",
|
||||
"Environment variables are controlling some switches": "部分开关由环境变量控制",
|
||||
"Event Destinations": "事件目标",
|
||||
"Event Target created successfully": "事件目标创建成功",
|
||||
"Events": "事件",
|
||||
@@ -295,6 +302,7 @@
|
||||
"Failed to load bucket list": "加载存储桶列表失败",
|
||||
"Failed to load current configuration": "加载当前配置失败",
|
||||
"Failed to load key list": "密钥列表加载失败",
|
||||
"Failed to load module switches": "加载模块开关失败",
|
||||
"Failed to refresh key list": "刷新密钥列表失败",
|
||||
"Failed to refresh status": "刷新状态失败",
|
||||
"Failed to remove bucket encryption": "移除存储桶加密失败",
|
||||
@@ -492,6 +500,8 @@
|
||||
"Mode": "复制模式",
|
||||
"Monday": "星期一",
|
||||
"Monitor overall storage usage and recent scanner activity at a glance.": "一目了然地监控整体存储使用情况和最近的扫描器活动。",
|
||||
"Module switch is managed by environment variables": "模块开关由环境变量管理",
|
||||
"Module switches saved": "模块开关已保存",
|
||||
"More Configurations": "更多配置",
|
||||
"Multi-Cloud Storage": "多云存储",
|
||||
"Multipart Upload": "分片上传",
|
||||
@@ -506,6 +516,10 @@
|
||||
"New Policy": "新建策略",
|
||||
"New Secret Key": "新密钥",
|
||||
"New user has been created": "新用户已创建",
|
||||
"Notify": "通知",
|
||||
"Notify is disabled": "Notify 已禁用",
|
||||
"Notify is disabled. Enable notify before managing bucket event subscriptions.": "Notify 已禁用。请先启用 notify,再管理存储桶事件订阅。",
|
||||
"Notify is disabled. Enable notify before managing event destinations.": "Notify 已禁用。请先启用 notify,再管理事件目标。",
|
||||
"Next": "下一页",
|
||||
"Next Page": "下一页",
|
||||
"No": "否",
|
||||
@@ -652,6 +666,8 @@
|
||||
"Quota Size": "配额大小",
|
||||
"Quota Warning Content": "当前选择上传的文件总大小为 {total},已超过存储桶剩余可用配额 {remaining}。是否继续上传?",
|
||||
"Read/Write Performance": "读/写性能",
|
||||
"Read-only OIDC settings": "只读 OIDC 设置",
|
||||
"Read-only settings": "只读设置",
|
||||
"Reading Folder Files": "正在读取文件夹文件",
|
||||
"Ready to import: {filename}": "准备导入:{filename}",
|
||||
"Real-time status of cluster servers and backend storage devices.": "集群服务器和后端存储设备的实时状态。",
|
||||
@@ -862,6 +878,7 @@
|
||||
"Update Success": "更新成功",
|
||||
"Update failed": "更新失败",
|
||||
"Update success": "更新成功",
|
||||
"Update the deployment environment first before changing ENV-managed module switches.": "修改由环境变量管理的模块开关前,请先更新部署环境。",
|
||||
"Updated At": "更新时间",
|
||||
"Updated successfully": "更新成功",
|
||||
"Upload": "上传",
|
||||
@@ -917,6 +934,8 @@
|
||||
"Year": "年",
|
||||
"Yes": "是",
|
||||
"You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.": "您没有访问该页面的权限,可能是权限不足或未登录。",
|
||||
"You do not have permission to update OIDC providers.": "您没有权限更新 OIDC 提供方。",
|
||||
"You do not have permission to update module switches.": "您没有权限更新模块开关。",
|
||||
"Your browser does not support the audio tag": "您的浏览器不支持音频标签",
|
||||
"Your browser does not support the video tag": "您的浏览器不支持视频标签",
|
||||
"Your session has expired. Please log in again.": "您的会话已过期。请重新登录。",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const CONSOLE_SCOPES = {
|
||||
VIEW_EVENT_DESTINATIONS: "console:EventDestinations",
|
||||
VIEW_SSE_SETTINGS: "console:SSESettings",
|
||||
VIEW_OIDC_SETTINGS: "console:OIDCSettings",
|
||||
VIEW_SETTINGS: "console:Settings",
|
||||
VIEW_LICENSE: "console:License",
|
||||
} as const
|
||||
|
||||
@@ -36,5 +37,7 @@ export const PAGE_PERMISSIONS: Record<string, ConsoleScope[]> = {
|
||||
"/tiers": [CONSOLE_SCOPES.VIEW_TIERED_STORAGE],
|
||||
"/events-target": [CONSOLE_SCOPES.VIEW_EVENT_DESTINATIONS],
|
||||
"/sse": [CONSOLE_SCOPES.VIEW_SSE_SETTINGS],
|
||||
"/oidc": [CONSOLE_SCOPES.VIEW_OIDC_SETTINGS],
|
||||
"/settings": [CONSOLE_SCOPES.VIEW_SETTINGS],
|
||||
"/license": [CONSOLE_SCOPES.VIEW_LICENSE],
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ const IMPLIED_SCOPES: Record<string, string[]> = {
|
||||
[CONSOLE_SCOPES.VIEW_TIERED_STORAGE]: ["admin:ConfigUpdate", "admin:*"],
|
||||
[CONSOLE_SCOPES.VIEW_EVENT_DESTINATIONS]: ["admin:ConfigUpdate", "admin:*"],
|
||||
[CONSOLE_SCOPES.VIEW_SSE_SETTINGS]: ["admin:ConfigUpdate", "admin:*", "kms:*"],
|
||||
[CONSOLE_SCOPES.VIEW_OIDC_SETTINGS]: ["admin:ServerInfo", "admin:ConfigUpdate", "admin:*"],
|
||||
[CONSOLE_SCOPES.VIEW_SETTINGS]: ["admin:ServerInfo", "admin:ConfigUpdate", "admin:*"],
|
||||
[CONSOLE_SCOPES.VIEW_LICENSE]: ["admin:ServerInfo", "admin:*"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const ADMIN_ONLY_DASHBOARD_ROUTES = ["/oidc"] as const
|
||||
// These routes require the backend admin identity, not a console policy scope.
|
||||
export const ADMIN_ONLY_DASHBOARD_ROUTES = [] as const
|
||||
export const DASHBOARD_ROUTE_FALLBACK = "/403/"
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export const MENU_CONTROLLED_DASHBOARD_ROUTES: readonly string[] = [
|
||||
"/events-target",
|
||||
"/sse",
|
||||
"/oidc",
|
||||
"/settings",
|
||||
"/license",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { canManageNotifyBackedFeature } from "./notify-module-access.js"
|
||||
|
||||
export const canManageEventDestinations = canManageNotifyBackedFeature
|
||||
@@ -0,0 +1,3 @@
|
||||
import { canManageNotifyBackedFeature } from "@/lib/notify-module-access"
|
||||
|
||||
export const canManageEventDestinations = canManageNotifyBackedFeature
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RiStackLine,
|
||||
RiBookmark3Line,
|
||||
RiSecurePaymentLine,
|
||||
RiSettingsLine,
|
||||
RiFingerprintLine,
|
||||
RiCopyrightLine,
|
||||
RiFileList3Line,
|
||||
@@ -34,6 +35,7 @@ const iconMap: Record<string, ComponentType<{ className?: string }>> = {
|
||||
"ri:stack-line": RiStackLine,
|
||||
"ri:bookmark-3-line": RiBookmark3Line,
|
||||
"ri:secure-payment-line": RiSecurePaymentLine,
|
||||
"ri:settings-line": RiSettingsLine,
|
||||
"ri:fingerprint-line": RiFingerprintLine,
|
||||
"ri:copyright-line": RiCopyrightLine,
|
||||
"ri:file-list-3-line": RiFileList3Line,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const MODULE_SWITCH_ENV_KEYS = {
|
||||
notify: "RUSTFS_NOTIFY_ENABLE",
|
||||
audit: "RUSTFS_AUDIT_ENABLE",
|
||||
}
|
||||
|
||||
export function isEnvManagedSource(source) {
|
||||
return source === "env"
|
||||
}
|
||||
|
||||
export function getModuleSwitchEnvKey(moduleName) {
|
||||
return MODULE_SWITCH_ENV_KEYS[moduleName]
|
||||
}
|
||||
|
||||
export function isModuleSwitchEnvConflictError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.includes("managed by environment variable")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type ModuleSwitchName = "notify" | "audit"
|
||||
export type ModuleSwitchSource = "env" | "console" | "default"
|
||||
|
||||
export interface ModuleSwitchSnapshot {
|
||||
notify_enabled: boolean
|
||||
audit_enabled: boolean
|
||||
persisted_notify_enabled: boolean
|
||||
persisted_audit_enabled: boolean
|
||||
notify_source: ModuleSwitchSource
|
||||
audit_source: ModuleSwitchSource
|
||||
}
|
||||
|
||||
export interface ModuleSwitchPayload {
|
||||
notify_enabled: boolean
|
||||
audit_enabled: boolean
|
||||
}
|
||||
|
||||
const MODULE_SWITCH_ENV_KEYS: Record<ModuleSwitchName, string> = {
|
||||
notify: "RUSTFS_NOTIFY_ENABLE",
|
||||
audit: "RUSTFS_AUDIT_ENABLE",
|
||||
}
|
||||
|
||||
export function isEnvManagedSource(source: ModuleSwitchSource) {
|
||||
return source === "env"
|
||||
}
|
||||
|
||||
export function getModuleSwitchEnvKey(moduleName: ModuleSwitchName) {
|
||||
return MODULE_SWITCH_ENV_KEYS[moduleName]
|
||||
}
|
||||
|
||||
export function isModuleSwitchEnvConflictError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.includes("managed by environment variable")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function canManageNotifyBackedFeature(notifyEnabled) {
|
||||
return notifyEnabled !== false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function canManageNotifyBackedFeature(notifyEnabled: boolean | undefined) {
|
||||
return notifyEnabled !== false
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { canManageEventDestinations } from "../../lib/event-destinations-access.js"
|
||||
|
||||
test("canManageEventDestinations blocks changes only when notify is disabled", () => {
|
||||
assert.equal(canManageEventDestinations(false), false)
|
||||
assert.equal(canManageEventDestinations(true), true)
|
||||
assert.equal(canManageEventDestinations(undefined), true)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { getModuleSwitchEnvKey, isEnvManagedSource, isModuleSwitchEnvConflictError } from "../../lib/module-switches.js"
|
||||
|
||||
test("isEnvManagedSource returns true only for env-backed module switches", () => {
|
||||
assert.equal(isEnvManagedSource("env"), true)
|
||||
assert.equal(isEnvManagedSource("console"), false)
|
||||
assert.equal(isEnvManagedSource("default"), false)
|
||||
})
|
||||
|
||||
test("getModuleSwitchEnvKey returns the deployment variable for a module", () => {
|
||||
assert.equal(getModuleSwitchEnvKey("notify"), "RUSTFS_NOTIFY_ENABLE")
|
||||
assert.equal(getModuleSwitchEnvKey("audit"), "RUSTFS_AUDIT_ENABLE")
|
||||
})
|
||||
|
||||
test("isModuleSwitchEnvConflictError detects backend environment takeover errors", () => {
|
||||
assert.equal(
|
||||
isModuleSwitchEnvConflictError(
|
||||
new Error(
|
||||
"notify module is managed by environment variable RUSTFS_NOTIFY_ENABLE=true; update the environment value first",
|
||||
),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(isModuleSwitchEnvConflictError(new Error("storage layer not initialized")), false)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { canManageNotifyBackedFeature } from "../../lib/notify-module-access.js"
|
||||
|
||||
test("canManageNotifyBackedFeature blocks changes only when notify is disabled", () => {
|
||||
assert.equal(canManageNotifyBackedFeature(false), false)
|
||||
assert.equal(canManageNotifyBackedFeature(true), true)
|
||||
assert.equal(canManageNotifyBackedFeature(undefined), true)
|
||||
})
|
||||
Reference in New Issue
Block a user