diff --git a/app/(dashboard)/events-target/page.tsx b/app/(dashboard)/events-target/page.tsx index 297cb13..1466507 100644 --- a/app/(dashboard)/events-target/page.tsx +++ b/app/(dashboard)/events-target/page.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [searchTerm, setSearchTerm] = useState("") const [newFormOpen, setNewFormOpen] = useState(false) + const [notifyEnabled, setNotifyEnabled] = useState(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[] = useMemo( @@ -138,7 +163,12 @@ export default function EventsTargetPage() { cell: ({ row }) => isConfigSource(row.original.source) ? (
- @@ -148,7 +178,7 @@ export default function EventsTargetPage() { ), }, ], - [confirmDelete, t], + [canManageDestinations, confirmDelete, t], ) const { table } = useDataTable({ @@ -171,7 +201,7 @@ export default function EventsTargetPage() { className="w-full" />
- @@ -185,6 +215,13 @@ export default function EventsTargetPage() {

{t("Event Destinations")}

+ {!canManageDestinations ? ( + + {t("Notify is disabled")} + {t("Enable notify in Settings before managing event destinations.")} + + ) : null} + ([]) const [selectedProviderId, setSelectedProviderId] = useState(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 ( @@ -406,11 +432,19 @@ export default function OidcPage() { + {!canUpdateOidcProviders ? ( + + {t("Read-only OIDC settings")} + {t("You do not have permission to update OIDC providers.")} + + ) : null} +
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} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 9b0b82e..c10c37f 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -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({ - serverHost: "", - api: { baseURL: "" }, - s3: { endpoint: "", region: "" }, + const [snapshot, setSnapshot] = useState(null) + const [formValues, setFormValues] = useState({ + 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 ( + + +

{t("Settings")}

+
+
+ +
+
+ ) } return ( - + + + {canUpdateModuleSwitches ? ( + + ) : null} + + } + >

{t("Settings")}

-
-
-

{t("Current Configuration")}

-
- {currentItems.map((item) => ( -
-
{item.label}
-
{item.value}
-
- ))} -
-
- -
-

{t("Server Configuration")}

-
- - {t("Server Address")} - - setFormData({ serverHost: e.target.value })} - placeholder={t("Please enter server address (e.g., http://localhost:9000)")} - autoComplete="off" - /> - - {t("Example: http://localhost:9000 or https://your-domain.com")} - - -
- - -
-
- +
+ {hasEnvManagedSwitch ? ( - {t("Configuration Information")} + {t("Environment variables are controlling some switches")} -
    -
  • {t("Configuration is saved locally in your browser")}
  • -
  • {t("Page will refresh automatically after saving configuration")}
  • -
  • {t("Make sure the server address is accessible from your network")}
  • -
+ {t("Update the deployment environment first before changing ENV-managed module switches.")}
+ ) : null} + + {!canUpdateModuleSwitches ? ( + + {t("Read-only settings")} + {t("You do not have permission to update module switches.")} + + ) : null} + +
+ {switchItems.map((item) => { + const isEnvManaged = isEnvManagedSource(item.source) + return ( + +
+
+ + {item.label} + + + {item.checked ? t("Enabled") : t("Disabled")} + + {getSourceLabel(item.source, t)} +
+ {isEnvManaged ? ( + + {t("Controlled by {envKey}", { envKey: getModuleSwitchEnvKey(item.name) })} + + ) : null} +
+ + updateSwitch(item.name, checked)} + disabled={!canUpdateModuleSwitches || isEnvManaged || loading || saving} + /> + +
+ ) + })}
diff --git a/components/buckets/events-tab.tsx b/components/buckets/events-tab.tsx index 6f5a2c1..89d8ea3 100644 --- a/components/buckets/events-tab.tsx +++ b/components/buckets/events-tab.tsx @@ -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([]) const [loading, setLoading] = React.useState(false) const [newFormOpen, setNewFormOpen] = React.useState(false) + const [notifyEnabled, setNotifyEnabled] = React.useState(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((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({ @@ -161,7 +196,7 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {

{t("Events")}

{canEditEvents ? ( - @@ -173,6 +208,15 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
+ {canEditEvents && !canManageBucketEvents ? ( + + {t("Notify is disabled")} + + {t("Enable notify in Settings before managing bucket event subscriptions.")} + + + ) : null} + - +
) } diff --git a/components/events/new-form.tsx b/components/events/new-form.tsx index 1a57786..c457474 100644 --- a/components/events/new-form.tsx +++ b/components/events/new-form.tsx @@ -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 = { 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 {t("Amazon Resource Name")} - @@ -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")} /> @@ -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")} /> @@ -245,6 +253,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve handleEventChecked(event.value, v)} + disabled={disabled} className="mt-1" /> {t(event.labelKey)} @@ -261,7 +270,9 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess }: Eve - +
diff --git a/components/oidc/form.tsx b/components/oidc/form.tsx index 8adbad5..9cf51d4 100644 --- a/components/oidc/form.tsx +++ b/components/oidc/form.tsx @@ -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({

{isReadOnly - ? t("Environment-managed providers are read-only") + ? (readOnlyDescription ?? t("Environment-managed providers are read-only")) : t("Changes will take effect after RustFS restarts")}

- {isCreateMode ? ( + {isCreateMode && !isReadOnly ? ( @@ -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} /> {t("Only letters, numbers, underscores, and hyphens are allowed.")} diff --git a/components/oidc/provider-list.tsx b/components/oidc/provider-list.tsx index 452d7f0..3aabb7b 100644 --- a/components/oidc/provider-list.tsx +++ b/components/oidc/provider-list.tsx @@ -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({

{t("OIDC Providers")}

{t("View and manage persisted OIDC providers.")}

- + {canAddProvider ? ( + + ) : null}
diff --git a/config/navs.ts b/config/navs.ts index dd87982..0e4443a 100644 --- a/config/navs.ts +++ b/config/navs.ts @@ -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", diff --git a/hooks/use-module-switches.ts b/hooks/use-module-switches.ts new file mode 100644 index 0000000..bed3e98 --- /dev/null +++ b/hooks/use-module-switches.ts @@ -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, + } +} diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 529c6ff..8ae2916 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -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", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index a07cd50..2c1cdad 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -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.": "您的会话已过期。请重新登录。", diff --git a/lib/console-permissions.ts b/lib/console-permissions.ts index eefd308..5e6ae35 100644 --- a/lib/console-permissions.ts +++ b/lib/console-permissions.ts @@ -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 = { "/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], } diff --git a/lib/console-policy-parser.ts b/lib/console-policy-parser.ts index cbcdac0..0fcef25 100644 --- a/lib/console-policy-parser.ts +++ b/lib/console-policy-parser.ts @@ -133,6 +133,8 @@ const IMPLIED_SCOPES: Record = { [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:*"], } diff --git a/lib/dashboard-route-meta.ts b/lib/dashboard-route-meta.ts index 914d2dd..7733ce3 100644 --- a/lib/dashboard-route-meta.ts +++ b/lib/dashboard-route-meta.ts @@ -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", ] diff --git a/lib/event-destinations-access.js b/lib/event-destinations-access.js new file mode 100644 index 0000000..cee2614 --- /dev/null +++ b/lib/event-destinations-access.js @@ -0,0 +1,3 @@ +import { canManageNotifyBackedFeature } from "./notify-module-access.js" + +export const canManageEventDestinations = canManageNotifyBackedFeature diff --git a/lib/event-destinations-access.ts b/lib/event-destinations-access.ts new file mode 100644 index 0000000..1add8af --- /dev/null +++ b/lib/event-destinations-access.ts @@ -0,0 +1,3 @@ +import { canManageNotifyBackedFeature } from "@/lib/notify-module-access" + +export const canManageEventDestinations = canManageNotifyBackedFeature diff --git a/lib/icon-map.tsx b/lib/icon-map.tsx index f23fd54..ac9e9ad 100644 --- a/lib/icon-map.tsx +++ b/lib/icon-map.tsx @@ -12,6 +12,7 @@ import { RiStackLine, RiBookmark3Line, RiSecurePaymentLine, + RiSettingsLine, RiFingerprintLine, RiCopyrightLine, RiFileList3Line, @@ -34,6 +35,7 @@ const iconMap: Record> = { "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, diff --git a/lib/module-switches.js b/lib/module-switches.js new file mode 100644 index 0000000..03002cf --- /dev/null +++ b/lib/module-switches.js @@ -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") +} diff --git a/lib/module-switches.ts b/lib/module-switches.ts new file mode 100644 index 0000000..684aeed --- /dev/null +++ b/lib/module-switches.ts @@ -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 = { + 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") +} diff --git a/lib/notify-module-access.js b/lib/notify-module-access.js new file mode 100644 index 0000000..437d6ce --- /dev/null +++ b/lib/notify-module-access.js @@ -0,0 +1,3 @@ +export function canManageNotifyBackedFeature(notifyEnabled) { + return notifyEnabled !== false +} diff --git a/lib/notify-module-access.ts b/lib/notify-module-access.ts new file mode 100644 index 0000000..cd50ba1 --- /dev/null +++ b/lib/notify-module-access.ts @@ -0,0 +1,3 @@ +export function canManageNotifyBackedFeature(notifyEnabled: boolean | undefined) { + return notifyEnabled !== false +} diff --git a/tests/lib/event-destinations-access.test.js b/tests/lib/event-destinations-access.test.js new file mode 100644 index 0000000..780d049 --- /dev/null +++ b/tests/lib/event-destinations-access.test.js @@ -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) +}) diff --git a/tests/lib/module-switches.test.js b/tests/lib/module-switches.test.js new file mode 100644 index 0000000..65601cd --- /dev/null +++ b/tests/lib/module-switches.test.js @@ -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) +}) diff --git a/tests/lib/notify-module-access.test.js b/tests/lib/notify-module-access.test.js new file mode 100644 index 0000000..1b1ca2d --- /dev/null +++ b/tests/lib/notify-module-access.test.js @@ -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) +})