Skip to content
32 changes: 17 additions & 15 deletions apps/skillnet-web/src/components/settings/AccountSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ function renderSection() {
)
}

/** The card is the `<h3>` title's grandparent (Card > div > h3). */
async function findCard(title: string) {
const heading = await screen.findByRole('heading', { name: title })
return heading.parentElement as HTMLElement
/** Each row opens its form in a window: press the row's button and work inside the dialog. */
async function openWindow(title: string) {
const name = await screen.findByText(title)
const row = name.parentElement!.parentElement as HTMLElement
await userEvent.click(within(row).getByRole('button'))
return screen.findByRole('dialog')
}

beforeEach(() => {
Expand All @@ -124,7 +126,7 @@ describe('AccountSection — change password', () => {
it('submits current and new password to the change-password endpoint', async () => {
installFetch()
renderSection()
const card = await findCard('Contraseña')
const card = await openWindow('Contraseña')

await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'old-password')
await userEvent.type(within(card).getByLabelText('Contraseña nueva'), 'new-password-123')
Expand All @@ -143,7 +145,7 @@ describe('AccountSection — change password', () => {
it('shows the field-level validation hint for a too-short new password', async () => {
installFetch()
renderSection()
const card = await findCard('Contraseña')
const card = await openWindow('Contraseña')

await userEvent.type(within(card).getByLabelText('Contraseña nueva'), 'short')
await userEvent.tab()
Expand All @@ -154,7 +156,7 @@ describe('AccountSection — change password', () => {
it('surfaces the server error when the current password is wrong', async () => {
installFetch({ changePasswordStatus: 422 })
renderSection()
const card = await findCard('Contraseña')
const card = await openWindow('Contraseña')

await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'wrong')
await userEvent.type(within(card).getByLabelText('Contraseña nueva'), 'new-password-123')
Expand All @@ -168,7 +170,7 @@ describe('AccountSection — change email', () => {
it('submits the new email and current password', async () => {
installFetch()
renderSection()
const card = await findCard('Correo electrónico')
const card = await openWindow('Correo electrónico')

const emailInput = within(card).getByLabelText('Correo nuevo')
await userEvent.clear(emailInput)
Expand All @@ -189,7 +191,7 @@ describe('AccountSection — change email', () => {
it('surfaces the conflict when the email is already taken', async () => {
installFetch({ changeEmailStatus: 409 })
renderSection()
const card = await findCard('Correo electrónico')
const card = await openWindow('Correo electrónico')

const emailInput = within(card).getByLabelText('Correo nuevo')
await userEvent.clear(emailInput)
Expand All @@ -202,20 +204,20 @@ describe('AccountSection — change email', () => {
})

describe('AccountSection — delete account (workspace gating)', () => {
it('hides the delete-account card in an organization workspace', async () => {
it('hides the delete-account row in an organization workspace', async () => {
installFetch({ workspaceMode: 'organization' })
renderSection()

// Wait for /auth/me to resolve before asserting absence.
await screen.findByRole('heading', { name: 'Contraseña' })
expect(screen.queryByRole('heading', { name: 'Borrar cuenta' })).toBeNull()
await screen.findByText('Contraseña')
expect(screen.queryByText('Borrar cuenta')).toBeNull()
})

it('shows the delete-account card in an individual workspace and submits it', async () => {
it('shows the delete-account row in an individual workspace and submits it', async () => {
installFetch({ workspaceMode: 'individual' })
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderSection()
const card = await findCard('Borrar cuenta')
const card = await openWindow('Borrar cuenta')

await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'my-password')
await userEvent.click(within(card).getByRole('button', { name: 'Borrar mi cuenta' }))
Expand All @@ -234,7 +236,7 @@ describe('AccountSection — delete account (workspace gating)', () => {
installFetch({ workspaceMode: 'individual' })
vi.spyOn(window, 'confirm').mockReturnValue(false)
renderSection()
const card = await findCard('Borrar cuenta')
const card = await openWindow('Borrar cuenta')

await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'my-password')
await userEvent.click(within(card).getByRole('button', { name: 'Borrar mi cuenta' }))
Expand Down
77 changes: 46 additions & 31 deletions apps/skillnet-web/src/components/settings/AccountSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from 'react'
import { useIntl } from 'react-intl'
import { Button, Card, CardTitle, Input } from '../ui'
import { Button, Input } from '../ui'
import { SettingsWindowRow } from './SettingsWindowRow'
import { useChangeEmail, useChangePassword, useDeleteAccount } from '../../api/users'
import { useLogout } from '../../api/auth'
import { useAuth, useWorkspaceMode } from '../../hooks/useAuth'
Expand All @@ -9,20 +10,46 @@ import { apiErrorMessage } from '../../lib/apiErrors'
/**
* Self-service account management: change password, change email, and (in an
* individual workspace only) delete the account. Shared between admin Settings
* and the employee Learning Preferences page — same forms either way, there is
* and the employee Learning Preferences page — same rows either way, there is
* nothing role-specific about "what is my password".
*
* Each one is a quiet row; the form lives in a window that opens from its button, so the page
* is not six open fields that almost nobody needs.
*/
export function AccountSection() {
const intl = useIntl()
const workspaceMode = useWorkspaceMode()
return (
<div className="space-y-4">
<ChangePasswordCard />
<ChangeEmailCard />
<DeleteAccountCard />
<div>
<SettingsWindowRow
title={intl.formatMessage({ id: 'account.password.title' })}
action={intl.formatMessage({ id: 'account.change' })}
windowTitle={intl.formatMessage({ id: 'account.password.title' })}
>
<ChangePasswordForm />
</SettingsWindowRow>
<SettingsWindowRow
title={intl.formatMessage({ id: 'account.email.title' })}
action={intl.formatMessage({ id: 'account.change' })}
windowTitle={intl.formatMessage({ id: 'account.email.title' })}
>
<ChangeEmailForm />
</SettingsWindowRow>
{workspaceMode === 'individual' && (
<SettingsWindowRow
title={intl.formatMessage({ id: 'account.delete.title' })}
action={intl.formatMessage({ id: 'account.delete.action' })}
windowTitle={intl.formatMessage({ id: 'account.delete.title' })}
tone="danger"
>
<DeleteAccountForm />
</SettingsWindowRow>
)}
</div>
)
}

function ChangePasswordCard() {
function ChangePasswordForm() {
const intl = useIntl()
const mutation = useChangePassword()
const [currentPassword, setCurrentPassword] = useState('')
Expand All @@ -45,12 +72,8 @@ function ChangePasswordCard() {
}

return (
<Card>
<CardTitle>{intl.formatMessage({ id: 'account.password.title' })}</CardTitle>
<p className="mt-1 text-sm text-text-secondary">
{intl.formatMessage({ id: 'account.password.description' })}
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-3 max-w-sm">
<>
<form onSubmit={handleSubmit} className="space-y-3">
<Input
label={intl.formatMessage({ id: 'account.password.current' })}
type="password"
Expand Down Expand Up @@ -93,11 +116,11 @@ function ChangePasswordCard() {
: intl.formatMessage({ id: 'account.password.submit' })}
</Button>
</form>
</Card>
</>
)
}

function ChangeEmailCard() {
function ChangeEmailForm() {
const intl = useIntl()
const { user } = useAuth()
const mutation = useChangeEmail()
Expand All @@ -116,12 +139,8 @@ function ChangeEmailCard() {
}

return (
<Card>
<CardTitle>{intl.formatMessage({ id: 'account.email.title' })}</CardTitle>
<p className="mt-1 text-sm text-text-secondary">
{intl.formatMessage({ id: 'account.email.description' })}
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-3 max-w-sm">
<>
<form onSubmit={handleSubmit} className="space-y-3">
<Input
label={intl.formatMessage({ id: 'account.email.new' })}
type="email"
Expand Down Expand Up @@ -150,7 +169,7 @@ function ChangeEmailCard() {
: intl.formatMessage({ id: 'account.email.submit' })}
</Button>
</form>
</Card>
</>
)
}

Expand All @@ -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
Expand All @@ -182,12 +198,11 @@ function DeleteAccountCard() {
}

return (
<Card className="border-danger/30">
<CardTitle>{intl.formatMessage({ id: 'account.delete.title' })}</CardTitle>
<p className="mt-1 text-sm text-text-secondary">
<>
<p className="mb-3 text-sm text-text-secondary">
{intl.formatMessage({ id: 'account.delete.description' })}
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-3 max-w-sm">
<form onSubmit={handleSubmit} className="space-y-3">
<Input
label={intl.formatMessage({ id: 'account.password.current' })}
type="password"
Expand All @@ -206,6 +221,6 @@ function DeleteAccountCard() {
: intl.formatMessage({ id: 'account.delete.submit' })}
</Button>
</form>
</Card>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('BugReportEntry', () => {
it('shows only a row until the button is pressed, then the form', async () => {
renderEntry(<BugReportEntry />)
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()
})
})
Expand Down
60 changes: 6 additions & 54 deletions apps/skillnet-web/src/components/settings/BugReportEntry.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,34 @@
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<DOMRect | null>(null)
const button = useRef<HTMLButtonElement>(null)

return (
<>
<SettingRow title={title} description={description}>
<Button
ref={button}
variant="secondary"
size="sm"
className="shrink-0"
onClick={() => {
setOrigin(button.current?.getBoundingClientRect() ?? null)
setOpen(true)
}}
>
{action}
</Button>
</SettingRow>
<Modal open={open} onClose={() => setOpen(false)} origin={origin} size={size}>
<h2 className="text-lg font-semibold text-text mb-4">{windowTitle}</h2>
{children}
</Modal>
</>
)
}
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 (
<WindowRow
<SettingsWindowRow
title={intl.formatMessage({ id: 'bugReport.title' })}
description={intl.formatMessage({ id: 'bugReport.hint' })}
action={intl.formatMessage({ id: 'bugReport.title' })}
action={intl.formatMessage({ id: 'bugReport.action' })}
windowTitle={intl.formatMessage({ id: 'bugReport.title' })}
size="md"
>
<BugReportSection />
</WindowRow>
</SettingsWindowRow>
)
}

/** For admins: every report from the organization. */
export function BugReportsAdminEntry() {
const intl = useIntl()
return (
<WindowRow
<SettingsWindowRow
title={intl.formatMessage({ id: 'bugReport.admin.title' })}
description={intl.formatMessage({ id: 'bugReport.admin.hint' })}
action={intl.formatMessage({ id: 'bugReport.admin.open' })}
windowTitle={intl.formatMessage({ id: 'bugReport.admin.title' })}
size="lg"
>
<BugReportsAdmin />
</WindowRow>
</SettingsWindowRow>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -27,6 +27,8 @@ function renderWith(capabilities?: Partial<Capabilities>) {
}

describe('<CapabilityHealthBanner>', () => {
afterEach(() => window.localStorage.clear())

it('shows nothing when every capability is present', () => {
const { container } = renderWith(caps())
expect(container).toBeEmptyDOMElement()
Expand Down Expand Up @@ -77,4 +79,24 @@ describe('<CapabilityHealthBanner>', () => {
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()
})
})
Loading
Loading