-
-
-
+
+
+
+
+
+
+
+ {workspaceMode === 'individual' && (
+
+
+
+ )}
)
}
-function ChangePasswordCard() {
+function ChangePasswordForm() {
const intl = useIntl()
const mutation = useChangePassword()
const [currentPassword, setCurrentPassword] = useState('')
@@ -45,12 +72,8 @@ function ChangePasswordCard() {
}
return (
-
- {intl.formatMessage({ id: 'account.password.title' })}
-
- {intl.formatMessage({ id: 'account.password.description' })}
-
-
-
+ >
)
}
-function ChangeEmailCard() {
+function ChangeEmailForm() {
const intl = useIntl()
const { user } = useAuth()
const mutation = useChangeEmail()
@@ -116,12 +139,8 @@ function ChangeEmailCard() {
}
return (
-
- {intl.formatMessage({ id: 'account.email.title' })}
-
- {intl.formatMessage({ id: 'account.email.description' })}
-
-
-
+ >
)
}
@@ -160,15 +179,12 @@ function ChangeEmailCard() {
* them would orphan the org, which needs an explicit ownership-transfer flow that
* does not exist yet. Hidden here is UX; the 404 is the real guard.
*/
-function DeleteAccountCard() {
+function DeleteAccountForm() {
const intl = useIntl()
- const workspaceMode = useWorkspaceMode()
const mutation = useDeleteAccount()
const logout = useLogout()
const [password, setPassword] = useState('')
- if (workspaceMode !== 'individual') return null
-
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!password) return
@@ -182,12 +198,11 @@ function DeleteAccountCard() {
}
return (
-
- {intl.formatMessage({ id: 'account.delete.title' })}
-
+ <>
+
{intl.formatMessage({ id: 'account.delete.description' })}
-
-
+ >
)
}
diff --git a/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx b/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx
index 3d8939b..3dbd083 100644
--- a/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx
+++ b/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx
@@ -38,7 +38,7 @@ describe('BugReportEntry', () => {
it('shows only a row until the button is pressed, then the form', async () => {
renderEntry(
)
expect(screen.queryByLabelText('Título')).not.toBeInTheDocument()
- await userEvent.click(screen.getByRole('button', { name: 'Reportar un problema' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Reportar' }))
expect(await screen.findByLabelText('Título')).toBeInTheDocument()
})
})
diff --git a/apps/skillnet-web/src/components/settings/BugReportEntry.tsx b/apps/skillnet-web/src/components/settings/BugReportEntry.tsx
index 74790bd..30ed133 100644
--- a/apps/skillnet-web/src/components/settings/BugReportEntry.tsx
+++ b/apps/skillnet-web/src/components/settings/BugReportEntry.tsx
@@ -1,67 +1,20 @@
-import { useRef, useState } from 'react'
import { useIntl } from 'react-intl'
-import { Button, Modal } from '../ui'
import { BugReportSection } from './BugReportSection'
import { BugReportsAdmin } from './BugReportsAdmin'
-import { SettingRow } from './SettingRow'
-
-/** A settings row whose button opens a window that grows from the button. */
-function WindowRow({
- title,
- description,
- action,
- windowTitle,
- size,
- children,
-}: {
- title: string
- description: string
- action: string
- windowTitle: string
- size: 'md' | 'lg'
- children: React.ReactNode
-}) {
- const [open, setOpen] = useState(false)
- const [origin, setOrigin] = useState
(null)
- const button = useRef(null)
-
- return (
- <>
-
-
-
- setOpen(false)} origin={origin} size={size}>
- {windowTitle}
- {children}
-
- >
- )
-}
+import { SettingsWindowRow } from './SettingsWindowRow'
/** Report a problem. Rare and technical, so it is one quiet row in Settings. */
export function BugReportEntry() {
const intl = useIntl()
return (
-
-
+
)
}
@@ -69,14 +22,13 @@ export function BugReportEntry() {
export function BugReportsAdminEntry() {
const intl = useIntl()
return (
-
-
+
)
}
diff --git a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx
index 0c5c741..4bdab96 100644
--- a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx
+++ b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { IntlProvider } from 'react-intl'
import type { ReactNode } from 'react'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
import { CapabilityHealthBanner } from './CapabilityHealthBanner'
import { es } from '../../i18n/es'
@@ -27,6 +27,8 @@ function renderWith(capabilities?: Partial) {
}
describe('', () => {
+ afterEach(() => window.localStorage.clear())
+
it('shows nothing when every capability is present', () => {
const { container } = renderWith(caps())
expect(container).toBeEmptyDOMElement()
@@ -77,4 +79,24 @@ describe('', () => {
await user.click(screen.getByRole('button', { name: es['capabilityBanner.dismiss'] }))
expect(screen.queryByText(es['capabilityBanner.ai'])).not.toBeInTheDocument()
})
+
+ it('stays dismissed on the next visit', async () => {
+ const user = userEvent.setup()
+ const first = renderWith(caps({ ai: blocked(), generation: blocked(), tutor: blocked() }))
+ await user.click(screen.getByRole('button', { name: es['capabilityBanner.dismiss'] }))
+ first.unmount()
+
+ const second = renderWith(caps({ ai: blocked(), generation: blocked(), tutor: blocked() }))
+ expect(second.container).toBeEmptyDOMElement()
+ })
+
+ it('comes back when a different capability is missing', async () => {
+ const user = userEvent.setup()
+ const first = renderWith(caps({ ai: blocked(), generation: blocked(), tutor: blocked() }))
+ await user.click(screen.getByRole('button', { name: es['capabilityBanner.dismiss'] }))
+ first.unmount()
+
+ renderWith(caps({ tts: degraded('provider_quota') }))
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
})
diff --git a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx
index 7f7d216..ce67683 100644
--- a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx
+++ b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx
@@ -3,11 +3,26 @@ import { useIntl } from 'react-intl'
import { isReady, useCapabilities, type CapabilityName } from '../../api/setup'
import { capabilityTag } from '../../lib/capabilityCopy'
+/**
+ * Where "I saw it, stop telling me" is remembered, in this browser. It stores *which*
+ * capabilities were missing when the notice was dismissed, not just that it was: if a different
+ * set is degraded later, that is news and the notice comes back.
+ */
+const DISMISSED_KEY = 'skillnet:capability-banner-dismissed'
+
+function readDismissed(): string | null {
+ try {
+ return window.localStorage.getItem(DISMISSED_KEY)
+ } catch {
+ return null
+ }
+}
+
/** Watched in severity order: `ai` first (nothing AI works without it), then voice, then images. */
const WATCHED: CapabilityName[] = ['ai', 'tts', 'images']
/**
- * Consolidated, dismissible "what is degraded" notice for the admin surface
+ * Consolidated, dismissible "what is degraded" notice, and it stays dismissed for the admin surface
* (docs/design/degraded-mode-ux.md §1). SkillNet keeps working when external keys
* are missing — it just degrades in concrete, invisible ways (robotic offline voice,
* infographics without a poster, no AI at all). This banner makes those states
@@ -28,7 +43,7 @@ const WATCHED: CapabilityName[] = ['ai', 'tts', 'images']
export function CapabilityHealthBanner() {
const intl = useIntl()
const capabilities = useCapabilities()
- const [dismissed, setDismissed] = useState(false)
+ const [dismissedFor, setDismissedFor] = useState(readDismissed)
const lines = WATCHED.filter((name) => !isReady(capabilities[name])).map((name) => ({
name,
@@ -37,7 +52,8 @@ export function CapabilityHealthBanner() {
hint: capabilities[name].hint ?? null,
}))
- if (lines.length === 0 || dismissed) return null
+ const signature = lines.map((line) => line.name).join(',')
+ if (lines.length === 0 || dismissedFor === signature) return null
return (