Show and manage the linked partner in Settings

Settings never called GET /workspace/{id}/members -- confirmed by network
capture, not inference. There was no member list, no loading state and no
swallowed error; the code was simply absent. A workspace with a partner
therefore looked identical to a solo one, and nothing stopped a second
partner being added.

Settings now lists both members, marks the logged-in user, and offers
rename on either row and remove on the partner. The add-form appears only
when no partner exists. Removal asks for confirmation inline rather than
via window.confirm, which this codebase does not use anywhere and which
blocks the browser tooling used to verify it.

Own-name rename is offered too: the backend documents this route as the
only place a name can be corrected, so hiding it would leave that
capability dead.

Also fixes apiFetch's error parsing, which handled only string and array
detail bodies. A structured {message_key, data} object fell through and
was assigned to error.message, rendering as [object Object] or leaking
the raw key -- latent until this session's backend work started
returning exactly that shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 18:54:43 +02:00
parent 68d982bb94
commit 8e14ac6576
5 changed files with 830 additions and 30 deletions
+191
View File
@@ -4,6 +4,7 @@ import { Settings } from './Settings'
import * as hooks from '@/hooks'
import { useWorkspace } from '@/context'
import * as api from '@/services/api'
import { ApiRequestError } from '@/services/errors'
import { LanguageProvider } from '@/i18n'
vi.mock('@/hooks')
@@ -82,10 +83,29 @@ describe('Settings', () => {
createdAt: '2026-01-01T00:00:00',
}
const selfMember = {
uuid: 'pd-1',
workspaceId: 'ws-1',
name: 'Test Parent',
salaryType: 'monthly' as const,
employmentStatus: 'employed' as const,
createdAt: '2026-01-01T00:00:00',
}
const partnerMember = {
uuid: 'pd-2',
workspaceId: 'ws-1',
name: 'Sam',
salaryType: 'monthly' as const,
employmentStatus: 'employed' as const,
createdAt: '2026-01-01T00:00:00',
}
let mockUpsertVacationDays: ReturnType<typeof vi.fn>
let mockUpdateChildDetails: ReturnType<typeof vi.fn>
let mockUpsertDayOverride: ReturnType<typeof vi.fn>
let mockRemoveDayOverride: ReturnType<typeof vi.fn>
let mockRefreshMembers: ReturnType<typeof vi.fn>
const mockCalendarWindow = {
startYear: CURRENT_YEAR,
@@ -101,8 +121,17 @@ describe('Settings', () => {
mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined)
mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined)
mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined)
mockRefreshMembers = vi.fn().mockResolvedValue(undefined)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any)
// Default: solo workspace (only the logged-in user) — the add-partner
// form should be visible. Tests that need a linked partner override this.
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({
data: [selfMember],
isLoading: false,
error: null,
refresh: mockRefreshMembers,
} as any)
vi.mocked(hooks.useVacationDays).mockReturnValue({
data: mockVacationDays,
isLoading: false,
@@ -131,6 +160,8 @@ describe('Settings', () => {
vi.mocked(api.updateWorkspace).mockResolvedValue({ ...mockWorkspaceContext.workspace } as any)
vi.mocked(api.updateParentSettings).mockResolvedValue({} as any)
vi.mocked(api.addPartner).mockResolvedValue({} as any)
vi.mocked(api.renameWorkspaceParent).mockResolvedValue({ ...partnerMember } as any)
vi.mocked(api.removeWorkspaceParent).mockResolvedValue(undefined)
})
it('renders loading spinner while workspace loads', () => {
@@ -274,6 +305,166 @@ describe('Settings', () => {
fireEvent.click(screen.getByTestId('add-partner-button'))
await waitFor(() => expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Partner Name'))
await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled())
})
it('shows an add-partner error without a raw message_key when the name is rejected', async () => {
vi.mocked(api.addPartner).mockRejectedValue(
new ApiRequestError(422, {
code: 'workspace.parents.invalidName',
message: 'workspace.parents.invalidName',
details: { messageKey: 'workspace.parents.invalidName', data: {} },
}),
)
renderSettings()
fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'X' } })
fireEvent.click(screen.getByTestId('add-partner-button'))
const message = await screen.findByTestId('save-message')
expect(message).toHaveTextContent('Partnerns namn är ogiltigt.')
})
describe('workspace members', () => {
beforeEach(() => {
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({
data: [selfMember, partnerMember],
isLoading: false,
error: null,
refresh: mockRefreshMembers,
} as any)
})
it('shows the linked partner and hides the add-partner form', () => {
renderSettings()
expect(screen.getByTestId('member-name-pd-2')).toHaveTextContent('Sam')
expect(screen.queryByTestId('add-partner-button')).not.toBeInTheDocument()
})
it('shows a "you" badge on the logged-in user\'s own row', () => {
renderSettings()
expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('Test Parent')
expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('(Du)')
})
it('does not offer a remove button on the logged-in user\'s own row', () => {
renderSettings()
expect(screen.queryByTestId('remove-button-pd-1')).not.toBeInTheDocument()
})
it('renames a member and refreshes the member list', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('rename-button-pd-2'))
fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Samuel' } })
fireEvent.click(screen.getByTestId('rename-save-pd-2'))
await waitFor(() =>
expect(api.renameWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2', 'Samuel'),
)
await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled())
})
it('allows renaming the logged-in user\'s own profile too', () => {
renderSettings()
fireEvent.click(screen.getByTestId('rename-button-pd-1'))
expect(screen.getByTestId('rename-input-pd-1')).toBeInTheDocument()
})
it('cancels a rename without calling the API', () => {
renderSettings()
fireEvent.click(screen.getByTestId('rename-button-pd-2'))
fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Something else' } })
fireEvent.click(screen.getByTestId('rename-cancel-pd-2'))
expect(screen.queryByTestId('rename-input-pd-2')).not.toBeInTheDocument()
expect(api.renameWorkspaceParent).not.toHaveBeenCalled()
})
it('requires an in-app confirmation before removing a member', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
expect(api.removeWorkspaceParent).not.toHaveBeenCalled()
expect(screen.getByTestId('remove-confirm-pd-2')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
await waitFor(() =>
expect(api.removeWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2'),
)
})
it('cancelling the remove confirmation does not call the API', () => {
renderSettings()
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
fireEvent.click(screen.getByTestId('remove-cancel-pd-2'))
expect(screen.queryByTestId('remove-confirm-pd-2')).not.toBeInTheDocument()
expect(api.removeWorkspaceParent).not.toHaveBeenCalled()
})
it('refreshes the member list after a successful removal', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled())
})
it('shows the has-login refusal without a raw message_key', async () => {
vi.mocked(api.removeWorkspaceParent).mockRejectedValue(
new ApiRequestError(422, {
code: 'workspace.parents.remove.hasLogin',
message: 'workspace.parents.remove.hasLogin',
details: { messageKey: 'workspace.parents.remove.hasLogin', data: {} },
}),
)
renderSettings()
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
const message = await screen.findByTestId('member-action-error-pd-2')
expect(message).toHaveTextContent('den här personen har ett eget konto och egen inloggning')
expect(message).not.toHaveTextContent('workspace.parents.remove.hasLogin')
})
it('names each blocker on a hasDependentData refusal, not a raw message_key', async () => {
vi.mocked(api.removeWorkspaceParent).mockRejectedValue(
new ApiRequestError(422, {
code: 'workspace.parents.remove.hasDependentData',
message: 'workspace.parents.remove.hasDependentData',
details: {
messageKey: 'workspace.parents.remove.hasDependentData',
data: { blockers: ['leave_wishes', 'planned_leave'] },
},
}),
)
renderSettings()
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
const message = await screen.findByTestId('member-action-error-pd-2')
expect(message).toHaveTextContent('önskemål om ledighet')
expect(message).toHaveTextContent('planerade lediga dagar')
expect(message).not.toHaveTextContent('hasDependentData')
})
it('shows the remove-self refusal without a raw message_key', async () => {
// Defence in depth: the UI never renders a remove button for the
// caller's own row, but the handler still routes a self-removal
// refusal from the backend through the same safe error mapping.
vi.mocked(api.removeWorkspaceParent).mockRejectedValue(
new ApiRequestError(422, {
code: 'workspace.parents.remove.self',
message: 'workspace.parents.remove.self',
details: { messageKey: 'workspace.parents.remove.self', data: {} },
}),
)
renderSettings()
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
const message = await screen.findByTestId('member-action-error-pd-2')
expect(message).not.toHaveTextContent('workspace.parents.remove.self')
})
})
it('toggles week numbers', () => {
+258 -23
View File
@@ -1,8 +1,16 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { useWorkspace } from '@/context'
import { useVacationDays, useShowWeekNumbers, useChildSettings, useDayOverrides, useCalendarDays } from '@/hooks'
import {
useVacationDays,
useShowWeekNumbers,
useChildSettings,
useDayOverrides,
useCalendarDays,
useWorkspaceMembers,
} from '@/hooks'
import * as api from '@/services/api'
import { useTranslation, type Locale } from '@/i18n'
import { isApiError } from '@/services/errors'
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
@@ -26,6 +34,56 @@ function isEmploymentStatus(value: string): value is EmploymentStatus {
return value === 'employed' || value === 'jobseeking' || value === 'none'
}
// Backend `message_key`s for the parent add/rename/remove endpoints (see
// workspace_service.py) mapped to real translation keys — same idiom as
// `PlanResults.tsx`'s `WARNING_BODY_KEYS` etc. A key not present here is
// intentionally never shown raw to the user (§ "Render the refusal cases
// properly") — callers fall back to a generic message instead.
const PARTNER_ERROR_KEYS: Record<string, TranslationKey> = {
'workspace.notFound': 'settings.partnerActionErrorNotFound',
'workspace.parents.notFound': 'settings.partnerActionErrorNotFound',
'workspace.parents.invalidName': 'settings.partnerNameInvalid',
'workspace.parents.remove.self': 'settings.removePartnerErrorSelf',
'workspace.parents.remove.hasLogin': 'settings.removePartnerErrorHasLogin',
}
// `hasDependentData`'s `data.blockers` is a subset of these four kinds —
// each gets its own i18n label so the refusal message can name what's
// actually blocking removal instead of a generic "can't remove".
const BLOCKER_KEYS: Record<string, TranslationKey> = {
leave_wishes: 'settings.removeBlockerLeaveWishes',
planned_leave: 'settings.removeBlockerPlannedLeave',
parental_leave: 'settings.removeBlockerParentalLeave',
vacation_days: 'settings.removeBlockerVacationDays',
}
function isBlockerList(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === 'string')
}
/** Turns a caught error from an add/rename/remove-partner call into a safe,
* translated message. Never surfaces a raw `message_key` — an unrecognised
* structured error falls back to `fallback`, same as a non-API error would. */
function describePartnerError(err: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string, fallback: string): string {
if (isApiError(err)) {
const details = err.error.details as { messageKey?: string; data?: Record<string, unknown> } | undefined
const messageKey = details?.messageKey
if (messageKey === 'workspace.parents.remove.hasDependentData') {
const blockers = isBlockerList(details?.data?.blockers) ? details.data.blockers : []
const blockerLabels = blockers
.map((b) => (BLOCKER_KEYS[b] ? t(BLOCKER_KEYS[b]) : b))
.join(', ')
return t('settings.removePartnerErrorHasDependentData', { blockers: blockerLabels })
}
if (messageKey && PARTNER_ERROR_KEYS[messageKey]) {
return t(PARTNER_ERROR_KEYS[messageKey])
}
// A structured error we don't recognise — never show the raw key.
if (messageKey) return fallback
}
return err instanceof Error ? err.message : fallback
}
/** Current year through (at minimum) the year the child turns 4. */
function computeVacationYearRange(birthDate: string | undefined, currentYear: number): number[] {
let endYear = currentYear
@@ -47,6 +105,11 @@ export function Settings(): ReactNode {
const { t, explicitLocale, setLocale } = useTranslation()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '')
const {
data: members,
isLoading: membersLoading,
refresh: refreshMembers,
} = useWorkspaceMembers(workspace?.id ?? null)
const {
data: childDetails,
isLoading: childDetailsLoading,
@@ -74,6 +137,10 @@ export function Settings(): ReactNode {
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [newPartnerName, setNewPartnerName] = useState('')
const [renamingUuid, setRenamingUuid] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [removeConfirmUuid, setRemoveConfirmUuid] = useState<string | null>(null)
const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null)
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
@@ -94,6 +161,7 @@ export function Settings(): ReactNode {
}, [workspace?.planningGoal, workspace?.planningMode])
const selectedChild = children.find((c) => c.id === selectedChildId)
const hasPartner = members.some((m) => m.uuid !== personalDataUuid)
const currentYear = new Date().getFullYear()
const vacationYears = useMemo(
@@ -237,11 +305,70 @@ export function Settings(): ReactNode {
setIsSaving(true)
setSaveMessage(null)
await api.addPartner(workspace.id, newPartnerName)
await refreshContext()
await refreshMembers()
setNewPartnerName('')
showMessage('success', t('settings.addPartnerSuccess'))
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.addPartnerError'))
showMessage('error', describePartnerError(err, t, t('settings.addPartnerError')))
} finally {
setIsSaving(false)
}
}
const handleStartRename = (member: { uuid: string; name: string }) => {
setMemberActionError(null)
setRemoveConfirmUuid(null)
setRenamingUuid(member.uuid)
setRenameValue(member.name)
}
const handleCancelRename = () => {
setRenamingUuid(null)
setRenameValue('')
}
const handleSaveRename = async (uuid: string) => {
if (!workspace?.id || !renameValue.trim()) {
setMemberActionError({ uuid, text: t('settings.partnerNameRequired') })
return
}
try {
setIsSaving(true)
setMemberActionError(null)
await api.renameWorkspaceParent(workspace.id, uuid, renameValue)
await refreshMembers()
if (uuid === personalDataUuid) {
await refreshContext()
}
setRenamingUuid(null)
setRenameValue('')
} catch (err) {
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.renamePartnerError')) })
} finally {
setIsSaving(false)
}
}
const handleRequestRemove = (uuid: string) => {
setMemberActionError(null)
setRenamingUuid(null)
setRemoveConfirmUuid(uuid)
}
const handleCancelRemove = () => {
setRemoveConfirmUuid(null)
}
const handleConfirmRemove = async (uuid: string) => {
if (!workspace?.id) return
try {
setIsSaving(true)
setMemberActionError(null)
await api.removeWorkspaceParent(workspace.id, uuid)
setRemoveConfirmUuid(null)
await refreshMembers()
} catch (err) {
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.removePartnerError')) })
} finally {
setIsSaving(false)
}
@@ -395,25 +522,133 @@ export function Settings(): ReactNode {
<section className="settings-page__section">
<h2>{t('settings.partnerSectionTitle')}</h2>
<p>{t('settings.partnerIntro')}</p>
<div className="settings-page__partner-row">
<input
type="text"
placeholder={t('settings.partnerNamePlaceholder')}
value={newPartnerName}
onChange={(e) => setNewPartnerName(e.target.value)}
disabled={isSaving}
className="settings-page__partner-input"
/>
<button
onClick={handleAddPartner}
className="settings-page__button"
disabled={isSaving}
data-testid="add-partner-button"
>
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
<p>{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}</p>
{membersLoading ? (
<p>{t('settings.membersLoading')}</p>
) : (
<ul className="settings-page__member-list" data-testid="member-list">
{members.map((member) => {
const isSelf = member.uuid === personalDataUuid
const isRenaming = renamingUuid === member.uuid
const isConfirmingRemove = removeConfirmUuid === member.uuid
return (
<li
key={member.uuid}
className="settings-page__member-row"
data-testid={`member-row-${member.uuid}`}
>
{isRenaming ? (
<>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
disabled={isSaving}
className="settings-page__partner-input"
data-testid={`rename-input-${member.uuid}`}
/>
<button
onClick={() => handleSaveRename(member.uuid)}
className="settings-page__button"
disabled={isSaving}
data-testid={`rename-save-${member.uuid}`}
>
{t('settings.saveRenameButton')}
</button>
<button
onClick={handleCancelRename}
className="settings-page__button settings-page__button--secondary"
disabled={isSaving}
data-testid={`rename-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</>
) : (
<>
<span data-testid={`member-name-${member.uuid}`}>
{member.name}
{isSelf && (
<span className="settings-page__you-badge"> {t('settings.youBadge')}</span>
)}
</span>
<button
onClick={() => handleStartRename(member)}
className="settings-page__button settings-page__button--secondary"
disabled={isSaving}
data-testid={`rename-button-${member.uuid}`}
>
{t('settings.renameButton')}
</button>
{!isSelf &&
(isConfirmingRemove ? (
<span className="settings-page__remove-confirm">
{t('settings.removeConfirmPrompt', { name: member.name })}
<button
onClick={() => handleConfirmRemove(member.uuid)}
className="settings-page__button settings-page__button--danger"
disabled={isSaving}
data-testid={`remove-confirm-${member.uuid}`}
>
{t('settings.removeConfirmButton')}
</button>
<button
onClick={handleCancelRemove}
className="settings-page__button settings-page__button--secondary"
disabled={isSaving}
data-testid={`remove-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</span>
) : (
<button
onClick={() => handleRequestRemove(member.uuid)}
className="settings-page__button settings-page__button--danger"
disabled={isSaving}
data-testid={`remove-button-${member.uuid}`}
>
{t('settings.removePartnerButton')}
</button>
))}
</>
)}
{memberActionError?.uuid === member.uuid && (
<p
role="alert"
className="settings-page__message settings-page__message--error"
data-testid={`member-action-error-${member.uuid}`}
>
{memberActionError.text}
</p>
)}
</li>
)
})}
</ul>
)}
{!hasPartner && (
<div className="settings-page__partner-row">
<input
type="text"
placeholder={t('settings.partnerNamePlaceholder')}
value={newPartnerName}
onChange={(e) => setNewPartnerName(e.target.value)}
disabled={isSaving}
className="settings-page__partner-input"
/>
<button
onClick={handleAddPartner}
className="settings-page__button"
disabled={isSaving}
data-testid="add-partner-button"
>
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
)}
</section>
</div>
</Layout>