From 8e14ac65762f303b60396bdcb2658d17f28dd5e8 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 14 Sep 2026 18:54:43 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- frontend/src/pages/Settings.test.tsx | 191 ++++++++++++++++++ frontend/src/pages/Settings.tsx | 281 +++++++++++++++++++++++--- frontend/src/services/api.test.ts | 287 +++++++++++++++++++++++++++ frontend/src/services/api.ts | 59 +++++- frontend/src/styles/components.css | 42 ++++ 5 files changed, 830 insertions(+), 30 deletions(-) diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index cfe2f65..8a94f64 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -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 let mockUpdateChildDetails: ReturnType let mockUpsertDayOverride: ReturnType let mockRemoveDayOverride: ReturnType + let mockRefreshMembers: ReturnType 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', () => { diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 8b0c164..ed4137e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 = { + '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 = { + 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, fallback: string): string { + if (isApiError(err)) { + const details = err.error.details as { messageKey?: string; data?: Record } | 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(null) + const [renameValue, setRenameValue] = useState('') + const [removeConfirmUuid, setRemoveConfirmUuid] = useState(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 {

{t('settings.partnerSectionTitle')}

-

{t('settings.partnerIntro')}

-
- setNewPartnerName(e.target.value)} - disabled={isSaving} - className="settings-page__partner-input" - /> - -
+

{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}

+ + {membersLoading ? ( +

{t('settings.membersLoading')}

+ ) : ( +
    + {members.map((member) => { + const isSelf = member.uuid === personalDataUuid + const isRenaming = renamingUuid === member.uuid + const isConfirmingRemove = removeConfirmUuid === member.uuid + return ( +
  • + {isRenaming ? ( + <> + setRenameValue(e.target.value)} + disabled={isSaving} + className="settings-page__partner-input" + data-testid={`rename-input-${member.uuid}`} + /> + + + + ) : ( + <> + + {member.name} + {isSelf && ( + {t('settings.youBadge')} + )} + + + {!isSelf && + (isConfirmingRemove ? ( + + {t('settings.removeConfirmPrompt', { name: member.name })} + + + + ) : ( + + ))} + + )} + {memberActionError?.uuid === member.uuid && ( +

    + {memberActionError.text} +

    + )} +
  • + ) + })} +
+ )} + + {!hasPartner && ( +
+ setNewPartnerName(e.target.value)} + disabled={isSaving} + className="settings-page__partner-input" + /> + +
+ )}
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 2ce4ed7..22e6b4b 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -829,6 +829,68 @@ describe('endpoint functions (representative coverage)', () => { expect(result.targetWeeks).toBe(8) }) + it('updateLeaveWish sends every provided field, snake_cased', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: 'lw1', + workspace_id: 'ws1', + child_id: 'c1', + name: 'Höst', + window_start_month: 9, + window_start_day: 1, + window_end_month: 10, + window_end_day: 1, + target_weeks: 4, + recurrence: 'once', + first_year: 2027, + last_year: 2027, + priority: 2, + parent_mode: 'specific', + parent_personal_data_uuid: 'pd1', + goal_override: 'max_time', + window_mode: 'fixed', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }), + }) + + await api.updateLeaveWish('lw1', { + childId: 'c1', + name: 'Höst', + windowStartMonth: 9, + windowStartDay: 1, + windowEndMonth: 10, + windowEndDay: 1, + targetWeeks: 4, + recurrence: 'once', + firstYear: 2027, + lastYear: 2027, + priority: 2, + parentMode: 'specific', + parentPersonalDataUuid: 'pd1', + goalOverride: 'max_time', + }) + + expect(lastRequestBody()).toEqual({ + child_id: 'c1', + name: 'Höst', + window_start_month: 9, + window_start_day: 1, + window_end_month: 10, + window_end_day: 1, + target_weeks: 4, + recurrence: 'once', + first_year: 2027, + last_year: 2027, + priority: 2, + parent_mode: 'specific', + parent_personal_data_uuid: 'pd1', + goal_override: 'max_time', + }) + }) + // PLANNING_ENGINE_SPEC.md §10.1/§10.5 — window_mode round-trips through the // API client like every other leave-wish field: camelCase on the wire out, // snake_case in the request body, and back to camelCase on the response. @@ -1052,6 +1114,49 @@ describe('endpoint functions (representative coverage)', () => { expect(result.age4Projections[0]!.fourthBirthday).toBe('2030-01-01') }) + it('proposePlan defaults a missing cost breakdown field to 0', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + days: [], + outcomes: [ + { + wish_id: 'lw1', + wish_name: 'Sommar', + year: 2026, + start_date: '2026-06-15', + end_date: '2026-07-26', + weeks_wished: 6, + weeks_funded: 6, + shortfall_days: 0, + strategy: 'parent_swap', + message_key: 'planOutcomes.windowExplanation', + data: {}, + // The backend can omit zero-valued keys entirely — the mapper + // must default each one rather than surfacing `undefined`. + cost_breakdown: {}, + remedies: [], + }, + ], + warnings: [], + pool_balances: [], + vacation_balances: [], + surplus_advice: [], + age4_projections: [], + }), + }) + + const result = await api.proposePlan('ws1') + + expect(result.outcomes[0]!.costBreakdown).toEqual({ + free: 0, + vacation: 0, + sjukpenning: 0, + lagsta: 0, + }) + }) + it('applyPlan maps rejections to messageKey/data (§3.5)', async () => { fetchMock.mockResolvedValue({ ok: true, @@ -1107,6 +1212,27 @@ describe('endpoint functions (representative coverage)', () => { expect(result[0]!.salaryType).toBe('monthly') }) + it('listWorkspaceMembers defaults a null created_at to an empty string', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => [ + { + uuid: 'pd1', + workspace_id: 'ws1', + name: 'Alex', + salary_type: 'monthly', + employment_status: 'employed', + created_at: null, + }, + ], + }) + + const result = await api.listWorkspaceMembers('ws1') + + expect(result[0]!.createdAt).toBe('') + }) + it('getTips fetches tips for a workspace', async () => { const tips = [{ id: 't1', severity: 'info', messageKey: 'k', data: {} }] fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tips }) @@ -1282,3 +1408,164 @@ describe('endpoint functions (representative coverage)', () => { }) }) }) + +describe('apiFetch — structured {message_key, data} errors', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + globalThis.fetch = fetchMock as any + }) + + afterEach(() => { + vi.clearAllMocks() + api.setAccessToken(null) + }) + + it('captures message_key/data in error.details instead of leaking the raw key into message', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ + detail: { message_key: 'workspace.parents.remove.hasDependentData', data: { blockers: ['leave_wishes'] } }, + }), + }) + + try { + await api.getTips('ws1') + expect.unreachable('expected getTips to throw') + } catch (err) { + expect(err).toBeInstanceOf(ApiRequestError) + const apiErr = err as ApiRequestError + expect(apiErr.status).toBe(422) + expect(apiErr.error.details).toEqual({ + messageKey: 'workspace.parents.remove.hasDependentData', + data: { blockers: ['leave_wishes'] }, + }) + } + }) + + it('defaults data to an empty object when the structured error omits it', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ detail: { message_key: 'workspace.notFound' } }), + }) + + await expect(api.getTips('ws1')).rejects.toMatchObject({ + status: 404, + error: { details: { messageKey: 'workspace.notFound', data: {} } }, + }) + }) + + it('still handles a plain string `detail` the old way (not misread as structured)', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ detail: 'Invalid payload' }), + }) + + await expect(api.getTips('ws1')).rejects.toMatchObject({ + status: 400, + error: { code: '400', message: 'Invalid payload' }, + }) + }) +}) + +describe('workspace parent management (rename/remove)', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + globalThis.fetch = fetchMock as any + }) + + afterEach(() => { + vi.clearAllMocks() + api.setAccessToken(null) + }) + + function lastRequestBody(): Record { + const calls = fetchMock.mock.calls as any[][] + const options = calls[calls.length - 1]![1] as RequestInit + return JSON.parse(options.body as string) as Record + } + + function lastRequest(): { url: string; method?: string | undefined } { + const calls = fetchMock.mock.calls as any[][] + const [url, options] = calls[calls.length - 1]! + return { url, method: (options as RequestInit | undefined)?.method } + } + + it('renameWorkspaceParent PATCHes the new display name and maps the response to camelCase', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + uuid: 'pd2', + workspace_id: 'ws1', + name: 'Samuel', + salary_type: 'monthly', + employment_status: 'employed', + created_at: '2026-01-01', + }), + }) + + const result = await api.renameWorkspaceParent('ws1', 'pd2', 'Samuel') + + expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'PATCH' }) + expect(lastRequestBody()).toEqual({ display_name: 'Samuel' }) + expect(result.name).toBe('Samuel') + expect(result.uuid).toBe('pd2') + }) + + it('renameWorkspaceParent surfaces an invalidName refusal as a structured error', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ detail: { message_key: 'workspace.parents.invalidName', data: {} } }), + }) + + await expect(api.renameWorkspaceParent('ws1', 'pd2', '')).rejects.toMatchObject({ + status: 422, + error: { details: { messageKey: 'workspace.parents.invalidName' } }, + }) + }) + + it('removeWorkspaceParent DELETEs and resolves to undefined on 204', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) }) + + const result = await api.removeWorkspaceParent('ws1', 'pd2') + + expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'DELETE' }) + expect(result).toBeUndefined() + }) + + it('removeWorkspaceParent surfaces a hasDependentData refusal with its blockers', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ + detail: { + message_key: 'workspace.parents.remove.hasDependentData', + data: { blockers: ['leave_wishes', 'vacation_days'] }, + }, + }), + }) + + await expect(api.removeWorkspaceParent('ws1', 'pd2')).rejects.toMatchObject({ + status: 422, + error: { + details: { + messageKey: 'workspace.parents.remove.hasDependentData', + data: { blockers: ['leave_wishes', 'vacation_days'] }, + }, + }, + }) + }) +}) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6ed916d..56eeae9 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -509,16 +509,35 @@ async function apiFetch( } try { const body = (await response.json()) as { - detail?: string | Array<{ msg?: string }> + detail?: string | Array<{ msg?: string }> | { message_key: string; data?: Record } code?: string message?: string } - const detail = Array.isArray(body.detail) - ? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ') - : body.detail - error = { - code: body.code ?? String(response.status), - message: detail ?? body.message ?? response.statusText, + if ( + body.detail && + typeof body.detail === 'object' && + !Array.isArray(body.detail) && + 'message_key' in body.detail + ) { + // Structured `{message_key, data}` error (see workspace.py's + // exception mapping) — kept out of `message` so callers never + // accidentally render the raw key; `details.messageKey`/`data` is + // the contract callers (e.g. Settings.tsx's `describePartnerError`) + // read instead. + const structured = body.detail + error = { + code: structured.message_key, + message: structured.message_key, + details: { messageKey: structured.message_key, data: structured.data ?? {} }, + } + } else { + const detail = Array.isArray(body.detail) + ? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ') + : body.detail + error = { + code: body.code ?? String(response.status), + message: detail ?? body.message ?? response.statusText, + } } } catch { // response body wasn't JSON — use status text @@ -873,6 +892,32 @@ export async function listWorkspaceMembers(workspaceId: string): Promise { + const raw = (await apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, { + method: 'PATCH', + body: JSON.stringify({ display_name: displayName }), + })) as RawPersonalData + return toPersonalData(raw) +} + +/** Soft-deletes a workspace member. Refused (422) for the caller's own + * profile, a member with their own login, or one with dependent data — + * see `describePartnerError` in `Settings.tsx` for how those surface. */ +export async function removeWorkspaceParent( + workspaceId: string, + personalDataUuid: string, +): Promise { + return apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, { + method: 'DELETE', + }) +} + // --------------------------------------------------------------------------- // Tips // --------------------------------------------------------------------------- diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index a4f69ec..fa4b2b6 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -341,6 +341,48 @@ button.ghost:hover:not(:disabled) { border-radius: var(--radius); } +.settings-page__member-list { + list-style: none; + margin: 0 0 var(--sp-4) 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.settings-page__member-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) 0; +} + +.settings-page__you-badge { + color: var(--c-text-muted); + font-size: var(--fs-sm); +} + +.settings-page__remove-confirm { + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--c-danger); + font-size: var(--fs-sm); +} + +.settings-page__button--secondary { + background-color: transparent; + color: var(--c-text); + border: 1px solid var(--c-border); +} + +.settings-page__button--danger { + background-color: var(--c-danger-weak); + color: var(--c-danger); + border: 1px solid var(--c-danger); +} + /* Vacation Days Table (multi-year) */ .vacation-days-table { width: 100%;