@kernhq/module-hr 0.13.2 → 0.14.0
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/dist/contract/attendance.d.ts +35 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +25 -1
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +10 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +17 -16
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +23 -16
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/models.d.ts +1 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +11 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/router.d.ts +12 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +11 -3
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +0 -1
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +11 -5
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +13 -5
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +12 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +99 -16
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/access.d.ts +176 -0
- package/dist/server/services/access.d.ts.map +1 -0
- package/dist/server/services/access.js +260 -0
- package/dist/server/services/access.js.map +1 -0
- package/dist/server/services/people.d.ts +1 -0
- package/dist/server/services/people.d.ts.map +1 -1
- package/dist/server/services/people.js +3 -0
- package/dist/server/services/people.js.map +1 -1
- package/package.json +1 -1
- package/src/client/components/DayDetail.svelte +32 -14
- package/src/client/components/PersonPanel.svelte +66 -5
- package/src/client/components/redaction.ts +51 -0
- package/src/client/messages.ts +241 -22
- package/src/client/pages/DirectoryPage.svelte +45 -1
- package/src/client/permissions.ts +12 -4
- package/src/client/settings/AccrualSettings.svelte +596 -57
- package/src/client/settings/GeneralSettings.svelte +12 -9
- package/src/contract/attendance.ts +27 -1
- package/src/contract/capabilities.ts +10 -0
- package/src/contract/events.ts +23 -19
- package/src/contract/models.ts +11 -0
- package/src/contract/router.ts +11 -3
- package/src/contract/settings.ts +11 -5
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
EmptyState,
|
|
8
8
|
Field,
|
|
9
9
|
formatDate,
|
|
10
|
+
Icon,
|
|
10
11
|
Input,
|
|
11
12
|
navigation,
|
|
12
13
|
RightPanel,
|
|
@@ -21,6 +22,7 @@ import { hrKeys, isoDate } from '../query.js'
|
|
|
21
22
|
import PersonDocumentsSection from './PersonDocumentsSection.svelte'
|
|
22
23
|
import PersonJobSection from './PersonJobSection.svelte'
|
|
23
24
|
import PersonSensitiveSection from './PersonSensitiveSection.svelte'
|
|
25
|
+
import { personnelVisibility } from './redaction.js'
|
|
24
26
|
import { explainRefusal } from './refusal.js'
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -188,8 +190,30 @@ function officeTime(timezone: string): string {
|
|
|
188
190
|
|
|
189
191
|
const canManage = $derived(canHr('personManage'))
|
|
190
192
|
const left = $derived(person?.status === 'terminated')
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether the server withheld this person's personnel fields from this reader.
|
|
196
|
+
*
|
|
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.
|
|
201
|
+
*/
|
|
202
|
+
const withheld = $derived(person ? personnelVisibility(person) === 'withheld' : false)
|
|
203
|
+
/**
|
|
204
|
+
* And only where something on *this* panel is actually blank because of it. The two are the same
|
|
205
|
+
* thing while the client and the server agree about the reader's keys; tying the sentence to what
|
|
206
|
+
* is on screen means a disagreement leaves a stale sentence over nothing rather than over a value
|
|
207
|
+
* the reader can plainly see.
|
|
208
|
+
*/
|
|
209
|
+
const hiddenHere = $derived(withheld && (!person?.phone || !person?.hiredOn))
|
|
191
210
|
</script>
|
|
192
211
|
|
|
212
|
+
{#snippet hiddenValue()}
|
|
213
|
+
<!-- A value in its own right, not an absence: the row stays, and says what it is. -->
|
|
214
|
+
<span class="withheld"><Icon name="eye-off" size={12} strokeWidth={1.8} />{t('person_hidden')}</span>
|
|
215
|
+
{/snippet}
|
|
216
|
+
|
|
193
217
|
{#snippet footerActions()}
|
|
194
218
|
<div class="actions">
|
|
195
219
|
<!-- Disabled with the reason beside it, not hidden: the button was there a moment ago, and a
|
|
@@ -225,7 +249,7 @@ const left = $derived(person?.status === 'terminated')
|
|
|
225
249
|
</div>
|
|
226
250
|
</div>
|
|
227
251
|
|
|
228
|
-
<dl>
|
|
252
|
+
<dl class:tight={hiddenHere}>
|
|
229
253
|
{#if resolution?.primaryOfficeName}
|
|
230
254
|
<dt>{t('office')}</dt>
|
|
231
255
|
<dd>{resolution.primaryOfficeName}</dd>
|
|
@@ -241,13 +265,22 @@ const left = $derived(person?.status === 'terminated')
|
|
|
241
265
|
<dt>{t('employee_no')}</dt>
|
|
242
266
|
<dd>{person.employeeNo}</dd>
|
|
243
267
|
{/if}
|
|
244
|
-
|
|
268
|
+
<!--
|
|
269
|
+
Two of the four fields the server redacts. A withheld one keeps its row and is marked, so
|
|
270
|
+
that "we are not showing you this" cannot be read as "there is nothing here"; one that is
|
|
271
|
+
merely empty keeps the behaviour it had, and drops out of the list.
|
|
272
|
+
-->
|
|
273
|
+
{#if person.hiredOn || withheld}
|
|
245
274
|
<dt>{t('started')}</dt>
|
|
246
|
-
<dd>
|
|
275
|
+
<dd>
|
|
276
|
+
{#if person.hiredOn}{formatDate(`${person.hiredOn}T00:00:00`)}{:else}{@render hiddenValue()}{/if}
|
|
277
|
+
</dd>
|
|
247
278
|
{/if}
|
|
248
|
-
{#if person.phone}
|
|
279
|
+
{#if person.phone || withheld}
|
|
249
280
|
<dt>{t('phone')}</dt>
|
|
250
|
-
<dd>
|
|
281
|
+
<dd>
|
|
282
|
+
{#if person.phone}{person.phone}{:else}{@render hiddenValue()}{/if}
|
|
283
|
+
</dd>
|
|
251
284
|
{/if}
|
|
252
285
|
{#if resolution?.orgUnitPath}
|
|
253
286
|
<dt>{t('department')}</dt>
|
|
@@ -259,6 +292,14 @@ const left = $derived(person?.status === 'terminated')
|
|
|
259
292
|
{/if}
|
|
260
293
|
</dl>
|
|
261
294
|
|
|
295
|
+
<!--
|
|
296
|
+
Once for the panel, never once per field. The same sentence beside every marked row is a
|
|
297
|
+
lecture; the marks say *which* fields, and this says why, once, under all of them.
|
|
298
|
+
-->
|
|
299
|
+
{#if hiddenHere}
|
|
300
|
+
<p class="hint">{t('person_hidden_hint')}</p>
|
|
301
|
+
{/if}
|
|
302
|
+
|
|
262
303
|
<Badge tone={person.status === 'active' ? 'active' : person.status === 'on_leave' ? 'upcoming' : 'grey'}
|
|
263
304
|
>{person.status === 'active'
|
|
264
305
|
? t('status_active')
|
|
@@ -389,6 +430,11 @@ dl {
|
|
|
389
430
|
gap: 8px 16px;
|
|
390
431
|
margin: 0 0 16px;
|
|
391
432
|
}
|
|
433
|
+
/* The sentence below the list belongs to it, so the list gives up its own gap to keep them one
|
|
434
|
+
block rather than two things that happen to follow each other. */
|
|
435
|
+
dl.tight {
|
|
436
|
+
margin-block-end: 8px;
|
|
437
|
+
}
|
|
392
438
|
dt {
|
|
393
439
|
color: var(--kern-ink-500);
|
|
394
440
|
font-size: 12px;
|
|
@@ -396,6 +442,21 @@ dt {
|
|
|
396
442
|
dd {
|
|
397
443
|
margin: 0;
|
|
398
444
|
}
|
|
445
|
+
/* A colour and a smaller size, never opacity — a faded value is unreadable whatever token it
|
|
446
|
+
names, and this one has to be read to be believed. */
|
|
447
|
+
.withheld {
|
|
448
|
+
display: inline-flex;
|
|
449
|
+
align-items: center;
|
|
450
|
+
gap: 5px;
|
|
451
|
+
color: var(--kern-ink-500);
|
|
452
|
+
font-size: 12px;
|
|
453
|
+
}
|
|
454
|
+
.hint {
|
|
455
|
+
margin: 0 0 16px;
|
|
456
|
+
font-size: 12px;
|
|
457
|
+
line-height: 1.45;
|
|
458
|
+
color: var(--kern-ink-500);
|
|
459
|
+
}
|
|
399
460
|
.actions {
|
|
400
461
|
display: flex;
|
|
401
462
|
gap: 8px;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { session } from '@kernhq/ui'
|
|
2
|
+
import { canHr, canSeeFullRecords } from '../permissions.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the personnel fields on a card were withheld, or are genuinely empty.
|
|
6
|
+
*
|
|
7
|
+
* `HrAccessService` nulls four of them — personal email, phone, hire date, termination date — for
|
|
8
|
+
* 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:
|
|
15
|
+
*
|
|
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".
|
|
27
|
+
*
|
|
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.
|
|
32
|
+
*/
|
|
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
|
+
export interface PersonnelSubject {
|
|
37
|
+
/** The Kern account behind the record, when there is one: how a reader recognises their own. */
|
|
38
|
+
userId: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
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
|
+
}
|