feat: system Settings

This commit is contained in:
马登山
2026-04-27 17:30:45 +08:00
parent 6649168d70
commit 7305fe8968
24 changed files with 548 additions and 175 deletions
+45 -8
View File
@@ -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}
+47 -10
View File
@@ -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
View File
@@ -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>
+58 -8
View File
@@ -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>
)
}
+14 -3
View File
@@ -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>
+5 -3
View File
@@ -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>
+8 -4
View File
@@ -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">
+5
View File
@@ -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",
+28
View File
@@ -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,
}
}
+19
View File
@@ -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",
+19
View File
@@ -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.": "您的会话已过期。请重新登录。",
+3
View File
@@ -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],
}
+2
View File
@@ -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:*"],
}
+3 -1
View File
@@ -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",
]
+3
View File
@@ -0,0 +1,3 @@
import { canManageNotifyBackedFeature } from "./notify-module-access.js"
export const canManageEventDestinations = canManageNotifyBackedFeature
+3
View File
@@ -0,0 +1,3 @@
import { canManageNotifyBackedFeature } from "@/lib/notify-module-access"
export const canManageEventDestinations = canManageNotifyBackedFeature
+2
View File
@@ -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,
+17
View File
@@ -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")
}
+34
View File
@@ -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")
}
+3
View File
@@ -0,0 +1,3 @@
export function canManageNotifyBackedFeature(notifyEnabled) {
return notifyEnabled !== false
}
+3
View File
@@ -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)
})
+27
View File
@@ -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)
})
+10
View File
@@ -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)
})