@kernhq/module-hr 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-hr",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -22,7 +22,7 @@ import { hrKeys, isoDate } from '../query.js'
22
22
  import PersonDocumentsSection from './PersonDocumentsSection.svelte'
23
23
  import PersonJobSection from './PersonJobSection.svelte'
24
24
  import PersonSensitiveSection from './PersonSensitiveSection.svelte'
25
- import { personnelVisibility } from './redaction.js'
25
+ import { personnelWithheld } from './redaction.js'
26
26
  import { explainRefusal } from './refusal.js'
27
27
 
28
28
  /**
@@ -195,11 +195,11 @@ const left = $derived(person?.status === 'terminated')
195
195
  * Whether the server withheld this person's personnel fields from this reader.
196
196
  *
197
197
  * The four it nulls arrive as nulls, so an empty phone row on this panel says "no phone number" —
198
- * a different and wrong fact. `personnelVisibility` says which of the two it is, or says it cannot
199
- * tell; only a certain `withheld` is marked, and the panel then explains itself once below the
200
- * list rather than once per field.
198
+ * a different and wrong fact. The record carries the answer (`personnelHidden`, set where the
199
+ * nulling happens), so this is a read rather than an inference; the panel then explains itself once
200
+ * below the list rather than once per field.
201
201
  */
202
- const withheld = $derived(person ? personnelVisibility(person) === 'withheld' : false)
202
+ const withheld = $derived(person ? personnelWithheld(person) : false)
203
203
  /**
204
204
  * And only where something on *this* panel is actually blank because of it. The two are the same
205
205
  * thing while the client and the server agree about the reader's keys; tying the sentence to what
@@ -1,51 +1,39 @@
1
- import { session } from '@kernhq/ui'
2
- import { canHr, canSeeFullRecords } from '../permissions.js'
3
-
4
1
  /**
5
2
  * Whether the personnel fields on a card were withheld, or are genuinely empty.
6
3
  *
7
4
  * `HrAccessService` nulls four of them — personal email, phone, hire date, termination date — for
8
5
  * anybody outside the reader's record scope, and nulls them **in place**: a redacted card parses as
9
- * an ordinary `Person`, so a blank phone number arrives at a screen meaning either "we are not
10
- * showing you this" or "this person never gave us one". Those are two different facts about a
11
- * colleague, and printing the second when the first is true is the defect this exists to stop.
12
- *
13
- * Nothing on the record says which it is, so this reconstructs what it can from the reader's own
14
- * keys — and answers `unknown` rather than guessing where it cannot:
6
+ * an ordinary `Person`, so a blank phone number would otherwise arrive at a screen meaning either
7
+ * "we are not showing you this" or "this person never gave us one". Those are two different facts
8
+ * about a colleague, and printing the second when the first is true is the defect this exists to
9
+ * stop.
15
10
  *
16
- * - `full` nothing was withheld from this reader. `hr.person.view_all` reads the workspace, and
17
- * `hr.person.manage` is unbounded for the same reason the server makes it so: the edit form is
18
- * populated from the record it saves back, so a writer handed a redacted card would blank the
19
- * fields they could not see. Everybody reads their own record besides, holding nothing at all.
20
- * - `withheld` — the reader holds none of the three widening keys, so every card but their own came
21
- * back with all four nulled. Certain, and the common case: `hr.person.view` is a member default
22
- * and most colleagues hold nothing above it.
23
- * - `unknown` — the reader holds `view_team` or `view_office`. **Which** people those cover is
24
- * resolved on the server from the org chart — headship of a unit or an office, direct reports, as
25
- * of today — and none of that reaches the client. A screen renders the field exactly as it
26
- * arrived rather than labelling a genuinely empty phone number "Hidden".
11
+ * **The record says which, and this only reads it.** `Person.personnelHidden` is set in `forViewer`,
12
+ * the single place that does the nulling so the answer comes from the code that made the decision
13
+ * rather than from a client re-deriving it.
27
14
  *
28
- * That third answer is the hole, and it is the server's to close: one boolean set where `forViewer`
29
- * already nulls the four would make every card say for itself, and every caller here collapses to
30
- * reading it. Until then a manager sees no marking on the people outside their team — the same
31
- * blank they see today, never a wrong sentence about it.
15
+ * That matters because the client cannot re-derive it. This helper used to infer the answer from
16
+ * the reader's own permission keys, which works for the two extremes somebody holding
17
+ * `hr.person.view_all` or `hr.person.manage` has nothing hidden, somebody holding none of the
18
+ * widening keys has everything but their own record hidden — and fails in the middle. A line
19
+ * manager or a country HR person holds `view_team` or `view_office`, and **which** people those
20
+ * cover is resolved on the server from the org chart: headship of a unit over an ltree subtree,
21
+ * headship of an office, `manager_person_id`, all as of today, across rows the directory never
22
+ * fetches. Inferring it would have meant a second implementation of that resolution, and "all four
23
+ * fields are null" is not evidence — a person really can have no personal email, no phone and no
24
+ * recorded hire date.
32
25
  */
33
- export type PersonnelVisibility = 'full' | 'withheld' | 'unknown'
34
-
35
- /** Enough of a person to answer for — a directory row, a whole record, an office roster entry. */
36
26
  export interface PersonnelSubject {
37
- /** The Kern account behind the record, when there is one: how a reader recognises their own. */
38
- userId: string | null
27
+ /**
28
+ * True when the server withheld the personnel fields on this record.
29
+ *
30
+ * Optional so a caller can pass a row that predates the field — `dev:mock` fixtures, an older
31
+ * cached payload — and get `false` rather than a crash. Absent means "not withheld", which is the
32
+ * safe direction: a screen that fails to mark a hidden field shows a blank, while one that marks
33
+ * an empty field asserts something false about a colleague.
34
+ */
35
+ personnelHidden?: boolean
39
36
  }
40
37
 
41
- export function personnelVisibility(person: PersonnelSubject): PersonnelVisibility {
42
- // The two grants the server treats as unbounded, asked in the order it asks them.
43
- if (canHr('personViewAll') || canHr('personManage')) return 'full'
44
- // Identity, never a grant. Plenty of employees have no account, so a null on either side is
45
- // nobody's match rather than everybody's.
46
- const userId = session.user?.id
47
- if (userId && person.userId === userId) return 'full'
48
- // `view_all` is answered above, so what is left of that union is team-or-office scope: a set of
49
- // people only the server can name.
50
- return canSeeFullRecords() ? 'unknown' : 'withheld'
51
- }
38
+ /** Were this card's personnel fields withheld from the reader? */
39
+ export const personnelWithheld = (person: PersonnelSubject): boolean => person.personnelHidden === true
@@ -25,7 +25,7 @@ import { getHrApi } from '../api-instance.js'
25
25
  import DecisionDialog from '../components/DecisionDialog.svelte'
26
26
  import PersonFormDialog from '../components/PersonFormDialog.svelte'
27
27
  import PersonPanel from '../components/PersonPanel.svelte'
28
- import { personnelVisibility } from '../components/redaction.js'
28
+ import { personnelWithheld } from '../components/redaction.js'
29
29
  import { t } from '../i18n.js'
30
30
  import type { ApprovalRequest } from '../index.js'
31
31
  import { canHr, HR_CAPABILITIES } from '../permissions.js'
@@ -434,12 +434,15 @@ const started = (iso: string | null) =>
434
434
  * and it arrives here as a null like any other — so the em dash this column drew for it said
435
435
  * "never started", about everybody in the company, to every colleague without a widening key.
436
436
  *
437
- * `personnelVisibility` answers `unknown` for a reader whose scope only the server can resolve, and
438
- * an unknown row keeps the dash: a dash over an empty field is imprecise, and "Hidden" over one is
439
- * a lie. Both are marked once, under the table, rather than per row.
437
+ * The record says which it is — `personnelHidden`, set at the one place that does the nulling so
438
+ * a row is marked only when the server actually withheld it, and a genuinely empty hire date keeps
439
+ * its dash. Marked once, under the table, rather than per row.
440
+ *
441
+ * The `!person.hiredOn` half stays deliberately: the mark only ever goes over a value that is
442
+ * absent, so a stale or unrecognised payload can never paint "Hidden" across data on screen.
440
443
  */
441
- const startWithheld = (person: { userId: string | null; hiredOn: string | null }) =>
442
- !person.hiredOn && personnelVisibility(person) === 'withheld'
444
+ const startWithheld = (person: { hiredOn: string | null; personnelHidden?: boolean }) =>
445
+ !person.hiredOn && personnelWithheld(person)
443
446
 
444
447
  /** Whether anything on the page is actually marked — the sentence must not outlive the marks. */
445
448
  const anyWithheld = $derived(people.some(startWithheld))
@@ -22,19 +22,9 @@ export function canHr(permission: HrPermission): boolean {
22
22
  return session.can(HR_PERMISSIONS[permission])
23
23
  }
24
24
 
25
- /**
26
- * Whether the viewer reads the personnel record behind a directory card, for anybody but themselves.
27
- *
28
- * Not "can they open somebody else's page" everybody can, and should. `hr.person.view` is a
29
- * `member` default and the directory is meant to be read. What the three widening keys decide is
30
- * how much of each person comes back: personal email, phone, hire date and termination date are the
31
- * personnel record, and the server nulls all four for anybody outside the reader's scope.
32
- *
33
- * The three do not imply one another — a country HR manager must not silently become a global one —
34
- * so a screen asks the union once, here. It is only a hint: which *people* fall inside a team or an
35
- * office is resolved on the server from the org chart, so a screen may not conclude from `true`
36
- * that a particular person's record is readable. Render what came back; use this to decide whether
37
- * a "personal details" section is worth offering at all.
38
- */
39
- export const canSeeFullRecords = (): boolean =>
40
- canHr('personViewTeam') || canHr('personViewOffice') || canHr('personViewAll')
25
+ // No union helper over the three person-visibility keys. There has been one here twice —
26
+ // `canSeeOthers`, then `canSeeFullRecords` and both were dead the whole time, because the question
27
+ // a screen actually has is "was *this* record withheld", and the answer to that is on the record:
28
+ // `Person.personnelHidden`, set by the server at the one place that does the nulling. A client-side
29
+ // union can only say "the reader holds one of three keys somewhere", which is never enough to decide
30
+ // what to draw over one person's phone number. See `components/redaction.ts`.