Skip to content

Commit fe8a0ed

Browse files
authored
Merge pull request #2517 from YosemiteCrew/fix/settings-scope-review-fixes
2 parents 115b3da + 762f26e commit fe8a0ed

3 files changed

Lines changed: 168 additions & 14 deletions

File tree

apps/frontend/src/app/__tests__/pages/Settings/index.test.tsx

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,25 @@ jest.mock('next/dynamic', () => ({
7171
return <MockDeleteProfile {...props} />;
7272
}
7373

74+
// The clinic-wide controls previously fell through to `null`, which let the
75+
// whole point of the scope split regress without failing anything. They are
76+
// identifiable now so the composition tests below can assert placement.
77+
if (source.includes('Sections/AppointmentLockWindowPreference')) {
78+
return <div>Appointment Lock Window Preference</div>;
79+
}
80+
81+
if (source.includes('Sections/CrossClinicMessagingPreference')) {
82+
return <div>Cross Clinic Messaging Preference</div>;
83+
}
84+
85+
if (source.includes('Sections/YourOrganizations')) {
86+
return <div>Your Organizations</div>;
87+
}
88+
89+
if (source.includes('Sections/FederationSection')) {
90+
return <div>Federation Section</div>;
91+
}
92+
7493
return null;
7594
};
7695

@@ -119,14 +138,102 @@ jest.mock('@/app/features/settings/pages/Settings/Sections/CompanionTerminologyP
119138
default: () => <div>Companion Terminology</div>,
120139
}));
121140

141+
const mockHasPermission = jest.fn(() => true);
142+
jest.mock('@/app/hooks/usePermissions', () => ({
143+
__esModule: true,
144+
useHasPermission: () => mockHasPermission(),
145+
}));
146+
147+
/**
148+
* Returns the labelled scope band (`<section aria-labelledby>`) that contains
149+
* the given control, so a test can assert WHERE a control sits rather than just
150+
* that it rendered somewhere on the page.
151+
*/
152+
const bandContaining = (text: string): HTMLElement | null =>
153+
screen.getByText(text).closest('section[aria-labelledby^="settings-band-"]');
154+
122155
describe('Settings page', () => {
123-
it('renders the header with the subtitle and auto-save indicator', () => {
156+
beforeEach(() => {
157+
mockHasPermission.mockReturnValue(true);
158+
});
159+
160+
it('puts every per-user control in the Personal band', () => {
124161
render(<Settings />);
125162

126-
expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
163+
for (const control of [
164+
'Personal Card',
165+
'Timezone Preference',
166+
'Default Open Screen Preference',
167+
'Companion Terminology',
168+
'Your Organizations',
169+
'Delete Profile',
170+
// Device-scoped, but still the signed-in person's own surface.
171+
'Appearance Preference',
172+
]) {
173+
expect(bandContaining(control)).toHaveAttribute('aria-labelledby', 'settings-band-Personal');
174+
}
175+
});
176+
177+
it('puts every clinic-wide control in the Organisation band', () => {
178+
render(<Settings />);
179+
180+
for (const control of [
181+
'Appointment Lock Window Preference',
182+
'Cross Clinic Messaging Preference',
183+
'Federation Section',
184+
]) {
185+
expect(bandContaining(control)).toHaveAttribute(
186+
'aria-labelledby',
187+
'settings-band-Organisation'
188+
);
189+
}
190+
});
191+
192+
it('keeps the device theme out of the account-scoped group', () => {
193+
render(<Settings />);
194+
195+
// Appearance does not follow the account to another device, so it must not
196+
// sit under the group that promises "your account".
197+
const appearanceGroup = screen.getByText('Appearance Preference').closest('section');
198+
expect(appearanceGroup).toHaveTextContent('This device');
199+
expect(appearanceGroup).not.toHaveTextContent('Only you');
200+
});
201+
202+
// Scoped to the GROUP, not the band. The organisation band mixes two gates -
203+
// scheduling goes through updateOrg (teams:edit:any), federation through
204+
// integrations:edit:any - so a Supervisor holds one and not the other. A
205+
// band-level verdict would be wrong for exactly that role.
206+
it('marks the scheduling group read-only when the member cannot edit it', () => {
207+
mockHasPermission.mockReturnValue(false);
208+
render(<Settings />);
209+
210+
const group = screen.getByText('Appointment Lock Window Preference').closest('section');
211+
expect(group).toHaveTextContent(/Managed by a clinic administrator/);
212+
});
213+
214+
it('does not mark the scheduling group read-only for a member who can edit it', () => {
215+
mockHasPermission.mockReturnValue(true);
216+
render(<Settings />);
217+
218+
const group = screen.getByText('Appointment Lock Window Preference').closest('section');
219+
expect(group).not.toHaveTextContent(/Managed by a clinic administrator/);
220+
});
221+
222+
it('keeps the organisation band description permission-neutral', () => {
223+
mockHasPermission.mockReturnValue(false);
224+
render(<Settings />);
225+
226+
// The band must not claim a single verdict for controls behind two gates.
127227
expect(
128-
screen.getByText('Your preferences, and the clinic settings you administer')
228+
screen.getByText('Shared clinic settings. Changes here apply to every colleague.')
129229
).toBeInTheDocument();
230+
});
231+
232+
it('renders the header with the subtitle and auto-save indicator', () => {
233+
render(<Settings />);
234+
235+
expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
236+
expect(screen.getByText('Your preferences and clinic settings')).toBeInTheDocument();
130237
expect(screen.getByText('Changes save automatically')).toBeInTheDocument();
131238
});
132239

apps/frontend/src/app/features/settings/pages/Settings/Sections/PreferenceGroup.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ import React from 'react';
99
* undifferentiated cards - so an owner could change the whole clinic believing it
1010
* was their own preference. Groups declare their scope and say so on screen.
1111
*/
12-
export type PreferenceScope = 'personal' | 'organisation';
12+
export type PreferenceScope = 'personal' | 'device' | 'organisation';
1313

1414
const SCOPE_COPY: Record<PreferenceScope, { label: string; hint: string }> = {
1515
personal: { label: 'Only you', hint: 'These apply to your account on this clinic.' },
16+
// Distinct from `personal` on purpose. The theme is stored under an
17+
// un-namespaced `yc-theme` key in browser localStorage, so it does not follow
18+
// the account to another device and does not reset for the next person to use
19+
// the same browser. Calling that "your account" would be a promise the storage
20+
// does not keep.
21+
device: { label: 'This device', hint: 'Saved in this browser, not on your account.' },
1622
organisation: {
1723
label: 'Whole clinic',
1824
hint: 'These apply to everyone at this clinic, not just you.',
@@ -25,6 +31,16 @@ type PreferenceGroupProps = {
2531
className?: string;
2632
/** Who the group's controls affect. Renders a scope chip and a one-line hint. */
2733
scope?: PreferenceScope;
34+
/**
35+
* The reader can see these settings but not change them.
36+
*
37+
* Belongs on the GROUP, not the surrounding band: the organisation band mixes
38+
* controls behind different permissions - the scheduling ones are gated by
39+
* `teams:edit:any` and federation by `integrations:edit:any` - so a Supervisor
40+
* can edit one and not the other. A single band-level verdict would be wrong
41+
* for exactly that role.
42+
*/
43+
readOnly?: boolean;
2844
};
2945

3046
/**
@@ -35,7 +51,13 @@ type PreferenceGroupProps = {
3551
* When `scope` is given the title row also carries a chip naming who the settings
3652
* affect, plus a faint hint line beneath it.
3753
*/
38-
export const PreferenceGroup = ({ title, children, className, scope }: PreferenceGroupProps) => {
54+
export const PreferenceGroup = ({
55+
title,
56+
children,
57+
className,
58+
scope,
59+
readOnly = false,
60+
}: PreferenceGroupProps) => {
3961
const copy = scope ? SCOPE_COPY[scope] : null;
4062

4163
return (
@@ -49,7 +71,11 @@ export const PreferenceGroup = ({ title, children, className, scope }: Preferenc
4971
<h3 className="text-[14.5px] font-bold text-[var(--ink)]">{title}</h3>
5072
{copy && <ScopeChip scope={scope!} label={copy.label} />}
5173
</div>
52-
{copy && <p className="m-0! text-[11.5px] text-[var(--ink-faint)]">{copy.hint}</p>}
74+
{copy && (
75+
<p className="m-0! text-[11.5px] text-[var(--ink-faint)]">
76+
{readOnly ? `${copy.hint} Managed by a clinic administrator.` : copy.hint}
77+
</p>
78+
)}
5379
</div>
5480
{children}
5581
</section>
@@ -64,7 +90,10 @@ const ScopeChip = ({ scope, label }: { scope: PreferenceScope; label: string })
6490
<span
6591
className={`flex-none rounded-full border px-2 py-[2px] text-[10.5px] font-semibold tracking-[0.02em] whitespace-nowrap ${
6692
scope === 'organisation'
67-
? 'border-[var(--blue)] text-[var(--blue)]'
93+
? // --blue is a FILL token and carries no contrast duty; at 10.5px it fails
94+
// AA on the bone surfaces. --blue-text is the one that clears 4.5:1, so
95+
// the border keeps the fill and the label takes the text token.
96+
'border-[var(--blue)] text-[var(--blue-text)]'
6897
: 'border-[var(--hairline)] text-[var(--ink-faint)]'
6998
}`}
7099
>

apps/frontend/src/app/features/settings/pages/Settings/index.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import React, { useState } from 'react';
55
import dynamic from 'next/dynamic';
66
import { IoInformationCircleOutline } from 'react-icons/io5';
77

8+
import { useHasPermission } from '@/app/hooks/usePermissions';
9+
import { PERMISSIONS } from '@/app/lib/permissions';
810
import { PreferenceGroup } from '@/app/features/settings/pages/Settings/Sections/PreferenceGroup';
911
import '@/app/features/settings/styles/Settings.css';
1012

@@ -98,6 +100,10 @@ const SettingsBand = ({
98100
const Settings = () => {
99101
const [profileOpen, setProfileOpen] = useState(false);
100102
const [hoursOpen, setHoursOpen] = useState(false);
103+
// The two updateOrg controls below are gated by teams:edit:any, NOT by
104+
// integrations:edit:any - see organization.router.ts. Supervisor holds the
105+
// former and not the latter.
106+
const canEditClinicPreferences = useHasPermission(PERMISSIONS.TEAMS_EDIT_ANY);
101107

102108
return (
103109
<div className="yc-page-content">
@@ -115,9 +121,9 @@ const Settings = () => {
115121
className="flex-none"
116122
/>
117123
</h1>
118-
<span className="yc-settings-subtitle">
119-
Your preferences, and the clinic settings you administer
120-
</span>
124+
{/* Permission-neutral on purpose: most roles can view the organisation
125+
band without holding integrations:edit:any. */}
126+
<span className="yc-settings-subtitle">Your preferences and clinic settings</span>
121127
</div>
122128
<span className="yc-settings-autosave">
123129
<span className="yc-settings-autosave-dot" aria-hidden="true" />
@@ -140,13 +146,16 @@ const Settings = () => {
140146
onEditHours={() => setHoursOpen(true)}
141147
/>
142148

143-
{/* Every control here writes the per-user profile (patchUserProfile) or
144-
device-local theme storage — none of it is workspace-wide, which is
145-
what the old "Workspace preferences" title wrongly implied. */}
149+
{/* Every control here writes the per-user profile via patchUserProfile,
150+
so it follows the account to any device. Appearance does NOT — it is
151+
deliberately in its own group below. */}
146152
<PreferenceGroup title="Your preferences" scope="personal">
147153
<DefaultOpenScreenPreference />
148154
<TimezonePreference />
149155
<CompanionTerminologyPreference />
156+
</PreferenceGroup>
157+
158+
<PreferenceGroup title="This browser" scope="device">
150159
<AppearancePreference />
151160
</PreferenceGroup>
152161

@@ -155,13 +164,22 @@ const Settings = () => {
155164
</div>
156165
</SettingsBand>
157166

167+
{/* The band description stays permission-neutral because the band mixes
168+
two different gates: the scheduling controls go through updateOrg
169+
(teams:edit:any) and federation through integrations:edit:any. A
170+
Supervisor holds the first and not the second, so any single verdict
171+
here would be wrong for that role. Each group states its own. */}
158172
<SettingsBand
159173
title="Organisation"
160174
description="Shared clinic settings. Changes here apply to every colleague."
161175
>
162176
<div className="flex flex-col gap-3.5">
163177
{/* Both write the organisation record via updateOrg. */}
164-
<PreferenceGroup title="Scheduling & messaging" scope="organisation">
178+
<PreferenceGroup
179+
title="Scheduling & messaging"
180+
scope="organisation"
181+
readOnly={!canEditClinicPreferences}
182+
>
165183
<AppointmentLockWindowPreference />
166184
<CrossClinicMessagingPreference />
167185
</PreferenceGroup>

0 commit comments

Comments
 (0)