mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the managed-profile management surface
This commit is contained in:
commit
eb9b03a788
33 files changed
+4574
-14
No files matched your search
@@ -12743,6 +12743,43 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/managed-profiles/{id}/guardians:
|
||||
get:
|
||||
tags:
|
||||
- Account sharing
|
||||
summary: Who looks after a managed profile
|
||||
description: "The live Guardians of one managed profile — every unexpired, unrevoked MANAGE grant on it, PENDING and
|
||||
ACTIVE alike. Cookie transport only, by the same structural argument as the rest of this family: it resolves
|
||||
through the session and never falls through to the Bearer branch. A fresh second factor is NOT required, unlike
|
||||
every act on the same profile: this is a read, and it discloses nothing about a party the caller cannot already
|
||||
read on their own sharing panel. An ACTOR surface — the profile is named in the path and the caller acts as
|
||||
themselves, so it answers from the Guardian's own account rather than only while switched into the record.
|
||||
Anyone who is not an active Guardian of THIS profile gets the same 404 as an unknown id, including a READ, WRITE
|
||||
or MANAGE delegate of an ordinary record: the refusal is not an enumeration oracle. Count the ACTIVE rows to
|
||||
know whether removing a Guardian is possible — a PENDING invitation does not satisfy the last-Guardian floor."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: The profile's live Guardians, oldest invitation first.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ManagedProfileGuardianListEnvelope"
|
||||
"401": *a1
|
||||
"404":
|
||||
description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode:
|
||||
managed_profile.not_found`). The two are byte-identical."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/account/switch:
|
||||
post:
|
||||
tags:
|
||||
@@ -37258,6 +37295,57 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ManagedProfileGuardianListEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ManagedProfileGuardian"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ManagedProfileGuardian:
|
||||
type: object
|
||||
properties:
|
||||
grantId:
|
||||
type: string
|
||||
description: The grant row. This is the value `DELETE /api/managed-profiles/{id}/guardians/{grantId}` takes.
|
||||
account:
|
||||
$ref: "#/components/schemas/AccountGrantParty"
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- PENDING
|
||||
- ACTIVE
|
||||
description: "PENDING means the invitation has been sent and not yet accepted. A PENDING Guardian does NOT count toward
|
||||
the last-Guardian floor: the server counts accepted, unexpired MANAGE grants, so inviting somebody and then
|
||||
trying to leave is still refused until they accept. Say so before the act rather than letting it arrive as a
|
||||
409."
|
||||
invitedAt:
|
||||
type: string
|
||||
acceptedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- grantId
|
||||
- account
|
||||
- state
|
||||
- invitedAt
|
||||
- acceptedAt
|
||||
additionalProperties: false
|
||||
description: "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than
|
||||
listed with a state: the question is who looks after this record now."
|
||||
AccountSwitchEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -65,6 +65,28 @@ export const E2E_OWNER = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.37.0 — the account that creates and administers managed profiles.
|
||||
*
|
||||
* Its own account, and not one of the others, for one reason: every route in
|
||||
* the managed-profile family resolves `requireFreshMfa`, which refuses an
|
||||
* account with NO second factor enrolled (`auth.stepup.mfa_not_enrolled`). So
|
||||
* this account carries a confirmed TOTP secret, and an account with a confirmed
|
||||
* secret cannot be logged in by the password-only capture below. The setup
|
||||
* therefore logs it in FIRST and stamps the factor afterwards — see the
|
||||
* enrolment step at the end of `globalSetup`.
|
||||
*
|
||||
* Enrolling the factor on a shared account would have changed behaviour for
|
||||
* every spec that touches a step-up-gated surface, starting with the MANAGE
|
||||
* invitation on the sharing panel.
|
||||
*/
|
||||
export const E2E_GUARDIAN = {
|
||||
email: "e2e-guardian@healthlog.test",
|
||||
username: "e2e-guardian",
|
||||
password: "Rt5!Nm8xQ3wZ6bJp",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/** A separate delegate session for the eight scoped-record browser journeys. */
|
||||
export const E2E_SCOPE_DELEGATE = {
|
||||
email: "e2e-scope-delegate@healthlog.test",
|
||||
@@ -196,6 +218,16 @@ export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateFenceOffline.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The managed-profile journey's own jar. Its own session row, like every other
|
||||
* switching spec, so a run at CI's worker count cannot have two specs moving
|
||||
* the same session's record selector.
|
||||
*/
|
||||
export const GUARDIAN_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateGuardian.json",
|
||||
);
|
||||
|
||||
export const CROSS_TAB_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateCrossTab.json",
|
||||
@@ -297,6 +329,45 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// v1.37.0 — the managed-profile guardian. Seeded exactly like the other
|
||||
// fixture accounts; its second factor is stamped AFTER the login capture,
|
||||
// because the password-only capture cannot complete a TOTP login.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
totp_confirmed_at = NULL`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_GUARDIAN.username,
|
||||
E2E_GUARDIAN.email,
|
||||
await hashPassword(E2E_GUARDIAN.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// Whatever a previous run created. A managed profile is a real account row,
|
||||
// so a suite that left one behind would find the guardian looking after two
|
||||
// records on the next run and the roster assertions would read the wrong
|
||||
// one. Deleting the profile takes its grants with it.
|
||||
await pool.query(
|
||||
`DELETE FROM users
|
||||
WHERE managed_profile_at IS NOT NULL
|
||||
AND id IN (
|
||||
SELECT g.grantor_id FROM account_grants g
|
||||
JOIN users u ON u.id = g.grantee_id
|
||||
WHERE u.username = $1
|
||||
)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
|
||||
// The scoped-record journey uses a dedicated delegate so its preseeded
|
||||
// grants cannot affect the invitation lifecycle exercised by the sharing
|
||||
// journey above. Its own module preferences are explicitly off: a target
|
||||
@@ -645,6 +716,21 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH);
|
||||
|
||||
// v1.37.0 — the guardian's jar, and only then its second factor.
|
||||
//
|
||||
// Order is the whole of it. `requireFreshMfa` refuses an account with no
|
||||
// factor enrolled, so the managed-profile routes are unreachable without
|
||||
// this; and `captureAuthState` posts a password to `/api/auth/login`, which
|
||||
// for a TOTP account answers with a challenge rather than a session. Log in
|
||||
// first, enrol second. The freshness stamp on the session is refreshed by
|
||||
// the spec immediately before it acts — the window is five minutes and a
|
||||
// full suite run is longer than that.
|
||||
await capture(E2E_GUARDIAN, GUARDIAN_STORAGE_STATE_PATH);
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||||
[E2E_GUARDIAN.username, now],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* v1.37.0 — the step-up the managed-profile journey needs, and nothing else.
|
||||
*
|
||||
* ## Why a fixture exists at all
|
||||
*
|
||||
* Every route in the managed-profile family resolves
|
||||
* `requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)`. That gate has two halves:
|
||||
* the account must have a second factor enrolled, and the SESSION must carry a
|
||||
* `mfaVerifiedAt` newer than five minutes. `global-setup.ts` supplies the first
|
||||
* half once; the second cannot be supplied once, because a full suite run takes
|
||||
* longer than the window and the journey would be gated on how long the specs
|
||||
* ahead of it took.
|
||||
*
|
||||
* So the freshness stamp is refreshed immediately before the journey acts. It
|
||||
* is not a bypass and it does not weaken the route: the route still runs its
|
||||
* gate, the session still has to carry a stamp, and a session without one is
|
||||
* still refused. What it simulates is the person having just proved their
|
||||
* factor — which is the state the product requires and the only state in which
|
||||
* this surface is usable at all.
|
||||
*
|
||||
* Seeded through `pg`, the same transport `global-setup.ts` and
|
||||
* `vault-fixture.ts` use, because there is no product endpoint that says "I
|
||||
* proved a factor" without a real factor to prove.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
import { E2E_GUARDIAN } from "./global-setup";
|
||||
|
||||
function connect(): pg.Pool {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("[managed-profile-fixture] DATABASE_URL is not set");
|
||||
}
|
||||
return new pg.Pool({ connectionString: url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the guardian's step-up fresh, and clear anything an earlier run left.
|
||||
*
|
||||
* Idempotent, and safe to call from a `beforeEach`: both statements are
|
||||
* unconditional writes scoped to this one fixture account.
|
||||
*/
|
||||
export async function refreshGuardianStepUp(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
const stamped = await pool.query(
|
||||
`UPDATE sessions SET mfa_verified_at = NOW()
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
// A zero-row update means the jar this journey runs under points at a
|
||||
// session that is not there, and every act below would be refused for a
|
||||
// reason that has nothing to do with what it tests. Fail loudly here.
|
||||
if ((stamped.rowCount ?? 0) === 0) {
|
||||
throw new Error(
|
||||
`[managed-profile-fixture] no session for ${E2E_GUARDIAN.username}`,
|
||||
);
|
||||
}
|
||||
// And the factor itself, in case a spec ran before global setup stamped it.
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW())
|
||||
WHERE username = $1`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
// The guardian-invitation bucket, for the same reason `global-setup.ts`
|
||||
// clears the auth bucket between its own logins: the ceiling is ten
|
||||
// invitations an hour per caller (and ten creations), and this journey spends
|
||||
// one of each every time it
|
||||
// runs. On CI that never matters; locally, the eleventh run inside an hour
|
||||
// is answered by the product's own 429 rather than by what the test is
|
||||
// about, and the failure reads as a broken invitation form. Only this one
|
||||
// fixture account's bucket is touched.
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key IN (
|
||||
'managed-profile:guardian-invite:' ||
|
||||
(SELECT id FROM users WHERE username = $1),
|
||||
'managed-profile:create:' ||
|
||||
(SELECT id FROM users WHERE username = $1)
|
||||
)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every managed profile this account looks after.
|
||||
*
|
||||
* A managed profile is a real account row, so a journey that created one and
|
||||
* left it behind makes the next run's roster assertions read the wrong record.
|
||||
* Deleting the profile takes its grants with it.
|
||||
*/
|
||||
export async function clearGuardianProfiles(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`DELETE FROM users
|
||||
WHERE managed_profile_at IS NOT NULL
|
||||
AND id IN (
|
||||
SELECT g.grantor_id FROM account_grants g
|
||||
JOIN users u ON u.id = g.grantee_id
|
||||
WHERE u.username = $1
|
||||
)`,
|
||||
[E2E_GUARDIAN.username],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
export {
|
||||
E2E_USER,
|
||||
E2E_OWNER,
|
||||
E2E_GUARDIAN,
|
||||
E2E_SCOPE_DELEGATE,
|
||||
E2E_SCOPE_RECORDS,
|
||||
E2E_LEVEL_RECORDS,
|
||||
@@ -22,4 +23,5 @@ export {
|
||||
FENCE_STORAGE_STATE_PATH,
|
||||
FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
CROSS_TAB_STORAGE_STATE_PATH,
|
||||
GUARDIAN_STORAGE_STATE_PATH,
|
||||
} from "./global-setup";
|
||||
@@ -3,9 +3,15 @@ import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
E2E_LEVEL_RECORDS,
|
||||
E2E_SCOPE_DELEGATE,
|
||||
E2E_SCOPE_RECORDS,
|
||||
GUARDIAN_STORAGE_STATE_PATH,
|
||||
SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
import {
|
||||
clearGuardianProfiles,
|
||||
refreshGuardianStepUp,
|
||||
} from "./setup/managed-profile-fixture";
|
||||
|
||||
const DOMAIN_READS = {
|
||||
measurements: "/api/measurements",
|
||||
@@ -522,3 +528,160 @@ test.describe.serial("scoped sharing browser journeys", () => {
|
||||
await leaveRecord(page);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* [J3-managed-profile-lifecycle] — the half of the release goal that had no
|
||||
* browser at all until this plan.
|
||||
*
|
||||
* The routes have shipped with integration coverage since the profile family
|
||||
* landed, and nothing under `src/components` or `src/app` called any of them.
|
||||
* These two tests are the person's side of it: an adult with no managed profile
|
||||
* creates one, finds it in the switcher, enters it, leaves it, invites a second
|
||||
* Guardian and is told — before acting rather than by a refusal afterwards —
|
||||
* that the invitation does not count until it is accepted.
|
||||
*
|
||||
* Its own session jar and its own account, because this is the only account in
|
||||
* the suite with a second factor enrolled: every route here resolves
|
||||
* `requireFreshMfa`, which refuses an unenrolled account outright.
|
||||
*/
|
||||
test.describe.serial("managed profile browser journey", () => {
|
||||
test.use({ storageState: GUARDIAN_STORAGE_STATE_PATH });
|
||||
|
||||
const PROFILE_NAME = "E2E managed record";
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await clearGuardianProfiles();
|
||||
await refreshGuardianStepUp();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await clearGuardianProfiles();
|
||||
});
|
||||
|
||||
async function createProfile(page: Page): Promise<void> {
|
||||
await page.goto("/settings/access");
|
||||
const card = page.locator('[data-slot="managed-profile-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await card.locator('[data-slot="managed-profile-name"]').fill(PROFILE_NAME);
|
||||
await card.locator('[data-slot="managed-profile-create-submit"]').click();
|
||||
await expect(
|
||||
card.locator('[data-slot="managed-profile-created"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test("[J3-managed-profile-lifecycle] creates a record with no login and reaches it from the switcher", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createProfile(page);
|
||||
|
||||
// The row appears without a reload, which is the account-payload
|
||||
// invalidation doing its job rather than a lucky refetch.
|
||||
const row = page.locator('[data-slot="managed-profile-row"]');
|
||||
await expect(row).toHaveCount(1);
|
||||
const profileId = await row.getAttribute("data-managed-profile-id");
|
||||
expect(profileId).not.toBeNull();
|
||||
|
||||
// The switcher lists it as a managed record. Asserted on the stable
|
||||
// attribute rather than on the label, so the copy stays free to change.
|
||||
await openSwitcher(page);
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-id="${profileId}"]`,
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
await expect(entry).toHaveAttribute("data-record-kind", "managed");
|
||||
await entry.click();
|
||||
|
||||
// Inside it, the banner says which kind of record this is.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "managed");
|
||||
// The WORDING, not only the attribute, and this is the one place in the
|
||||
// suite where pinning copy is the right call rather than the lazy one: the
|
||||
// claim is that the banner NAMES it as a managed record, and the attribute
|
||||
// is not what a person reads. `recordKindLabel`'s ternary chooses between
|
||||
// two sentences that mean different things, and inverting it left every
|
||||
// attribute assertion green while telling a Guardian they were inside an
|
||||
// ordinary shared record. The pure function is pinned in
|
||||
// `src/components/layout/__tests__/shared-record-banner-label.test.tsx`;
|
||||
// this line proves the rendered banner really calls it.
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner-context"]'),
|
||||
).toContainText("Managed profile");
|
||||
|
||||
// And back out, with the record still on the list.
|
||||
//
|
||||
// Leaving ends in a full document reload, and a `goto` issued while that
|
||||
// reload is still in flight is aborted by Chromium — `net::ERR_ABORTED`,
|
||||
// reproducibly at CI's worker count and not at one. The banner being gone
|
||||
// is satisfied by the pre-reload DOM, so it cannot be the thing waited on.
|
||||
// Retrying the navigation is the honest fix: the claim is that the record
|
||||
// survives the round trip, not that the first navigation attempt wins a
|
||||
// race with the app's own.
|
||||
await leaveRecord(page);
|
||||
await expect(async () => {
|
||||
await page.goto("/settings/access");
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`,
|
||||
),
|
||||
).toHaveCount(1);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("[J3-managed-profile-lifecycle] states the last-guardian floor before an invitation is accepted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createProfile(page);
|
||||
|
||||
const guardians = page.locator('[data-slot="managed-profile-guardians"]');
|
||||
await expect(guardians).toBeVisible();
|
||||
|
||||
// One guardian, and it is the creator: no access here can be ended, and
|
||||
// the card says so rather than offering a control that would 404.
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-guardian"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-guardian-remove"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-sole-guardian"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Invite a second guardian.
|
||||
await guardians
|
||||
.locator('[data-slot="managed-profile-guardian-identifier"]')
|
||||
.fill(E2E_SCOPE_DELEGATE.username);
|
||||
await guardians
|
||||
.locator('[data-slot="managed-profile-guardian-submit"]')
|
||||
.click();
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-guardian-invited"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The roster shows them, and shows them as PENDING — which is the release
|
||||
// goal's sentence in the only form a browser can falsify: the invitation
|
||||
// reached the server, came back as a grant that confers nothing yet, and
|
||||
// the panel says which of the two states it is in. Delete the invitation
|
||||
// feature and this line goes red.
|
||||
await expect(
|
||||
guardians.locator(
|
||||
'[data-slot="managed-profile-guardian"][data-guardian-state="PENDING"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
|
||||
// And the floor is unchanged BECAUSE of that state: the sole-guardian
|
||||
// sentence is still on screen with a second person already invited.
|
||||
//
|
||||
// The `managed-profile-pending-note` line that used to sit here was
|
||||
// removed rather than kept. That node renders unconditionally inside the
|
||||
// invite form, so the assertion passed with the invitation feature deleted
|
||||
// — it proved the copy exists, which is a claim about a string and not
|
||||
// about anything the journey did. The note's own leg lives in
|
||||
// `managed-profile-affordances.test.tsx`, where deleting the node makes it
|
||||
// fail; here it would only have been a second look at the same string.
|
||||
await expect(
|
||||
guardians.locator('[data-slot="managed-profile-sole-guardian"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
+59
-1
@@ -9642,6 +9642,61 @@
|
||||
"kindManaged": "Verwaltetes Profil",
|
||||
"open": "Akte von {name} öffnen"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profile, die du betreust",
|
||||
"description": "Gesundheitsakten ohne eigenen Login.",
|
||||
"explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet — ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Der Name, an dem du diese Akte erkennst",
|
||||
"nameHint": "Bis zu 80 Zeichen. Wird überall dort angezeigt, wo diese Akte auftaucht.",
|
||||
"nameRequired": "Gib der Akte einen Namen.",
|
||||
"nameTooLong": "Dieser Name ist länger als 80 Zeichen.",
|
||||
"dobLabel": "Geburtsdatum (optional)",
|
||||
"dobHint": "Lass das Feld leer, wenn du keines hinterlegen willst. Aus einem Jahrgang wird nichts abgeleitet.",
|
||||
"localeLabel": "Sprache dieser Akte",
|
||||
"localeHint": "Die Sprache für Texte, die in diese Akte geschrieben werden. Deine eigenen Ansichten bleiben in deiner Sprache.",
|
||||
"timezoneHint": "Die Zeitzone, in der die Tage dieser Akte gezählt werden.",
|
||||
"timezoneInvalid": "Diese Zeitzone kennt der Browser nicht.",
|
||||
"create": "Profil anlegen",
|
||||
"creating": "Wird angelegt…",
|
||||
"created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.",
|
||||
"errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.",
|
||||
"errorFailed": "Das Profil konnte nicht angelegt werden.",
|
||||
"delete": "Profil löschen",
|
||||
"deleteTitle": "{name} löschen?",
|
||||
"deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.",
|
||||
"deleteGuardiansOne": "{count} Person betreut diese Akte derzeit und verliert sie.",
|
||||
"deleteGuardiansFew": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
"deleteGuardiansOther": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
"deleteFloorNote": "Das ist auch der Ausweg, wenn du die letzte betreuende Person bist: Du darfst die Akte beenden, aber nicht ohne Betreuung zurücklassen.",
|
||||
"deleteConfirm": "Profil löschen",
|
||||
"guardiansTitle": "Wer sich um {name} kümmert",
|
||||
"guardianYou": "(du)",
|
||||
"guardianSince": "Betreut diese Akte seit {date}",
|
||||
"guardianPending": "Eingeladen am {date}, noch nicht angenommen",
|
||||
"guardianRemove": "Zugriff beenden",
|
||||
"guardianRemoveTitle": "Zugriff von {name} beenden?",
|
||||
"guardianRemoveBody": "{name} kann {profile} dann nicht mehr öffnen. Was diese Person eingetragen hat, bleibt in der Akte.",
|
||||
"guardianWithdrawBody": "Die Einladung an {name} wird zurückgezogen. Die Person hat {profile} nie geöffnet, in der Akte ändert sich nichts.",
|
||||
"guardianRemoveConfirm": "Zugriff beenden",
|
||||
"soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert — hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.",
|
||||
"guardianInviteLabel": "Weitere betreuende Person einladen",
|
||||
"guardianInvite": "Einladung senden",
|
||||
"guardianInviting": "Wird gesendet…",
|
||||
"guardianInvited": "Einladung an {name} gesendet.",
|
||||
"guardianExpiresHint": "Optional. Der Zugriff endet am Ende dieses Tages in deiner Zeitzone.",
|
||||
"guardianPendingNote": "Eine eingeladene Person zählt erst, wenn sie annimmt. Bis dahin bist du weiterhin die letzte, und die Rückgabe deines eigenen Zugriffs wird weiterhin abgelehnt.",
|
||||
"guardianErrorDuplicate": "Dieses Konto hat bereits Zugriff auf diese Akte.",
|
||||
"guardianErrorManagedInvitee": "Ein verwaltetes Profil kann keine andere Akte betreuen. Lade ein Konto ein, das sich anmeldet.",
|
||||
"guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.",
|
||||
"guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.",
|
||||
"guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.",
|
||||
"errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.",
|
||||
"rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.",
|
||||
"errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "In der Akte von {name} gespeichert."
|
||||
},
|
||||
@@ -9786,7 +9841,10 @@
|
||||
"alreadyEnded": "Dieser Zugriff ist bereits beendet.",
|
||||
"notFound": "Diesen Zugriff gibt es nicht mehr.",
|
||||
"revokeFailed": "Der Zugriff konnte nicht beendet werden.",
|
||||
"renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden."
|
||||
"renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden.",
|
||||
"profileNotFound": "Dieses Profil gibt es nicht mehr — es wurde vielleicht schon gelöscht, oder dein Zugriff darauf ist beendet.",
|
||||
"deleteProfileFailed": "Das Profil konnte nicht gelöscht werden.",
|
||||
"removeGuardianFailed": "Dieser Zugriff konnte nicht beendet werden."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte."
|
||||
|
||||
+59
-1
@@ -9642,6 +9642,61 @@
|
||||
"kindManaged": "Managed profile",
|
||||
"open": "Open {name}'s record"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Records you look after",
|
||||
"description": "Health records with no login of their own.",
|
||||
"explainer": "A managed profile is a health record for somebody who does not sign in themselves — a child, or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "The name you will recognise this record by",
|
||||
"nameHint": "Up to 80 characters. Shown wherever this record appears.",
|
||||
"nameRequired": "Give the record a name.",
|
||||
"nameTooLong": "That name is longer than 80 characters.",
|
||||
"dobLabel": "Date of birth (optional)",
|
||||
"dobHint": "Leave it empty if you would rather not record one. Nothing is guessed from a year.",
|
||||
"localeLabel": "Language of this record",
|
||||
"localeHint": "The language used for text written into this record. Your own screens stay in your language.",
|
||||
"timezoneHint": "The zone this record's days are measured in.",
|
||||
"timezoneInvalid": "This browser does not know that timezone.",
|
||||
"create": "Create profile",
|
||||
"creating": "Creating…",
|
||||
"created": "{name} is set up. You can switch into that record from the account menu.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorOffline": "That did not reach the server. Nothing has changed — check your connection and try again.",
|
||||
"errorInvalid": "The server refused those details. Check the name, the date and the timezone.",
|
||||
"errorFailed": "The profile could not be created.",
|
||||
"delete": "Delete profile",
|
||||
"deleteTitle": "Delete {name}?",
|
||||
"deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.",
|
||||
"deleteGuardiansOne": "{count} person looks after this record today and will lose it.",
|
||||
"deleteGuardiansFew": "{count} people look after this record today and will lose it.",
|
||||
"deleteGuardiansOther": "{count} people look after this record today and will lose it.",
|
||||
"deleteFloorNote": "This is also the way out when you are the last guardian: you may end the record, but you may not leave it with nobody looking after it.",
|
||||
"deleteConfirm": "Delete profile",
|
||||
"guardiansTitle": "Who looks after {name}",
|
||||
"guardianYou": "(you)",
|
||||
"guardianSince": "Looking after this record since {date}",
|
||||
"guardianPending": "Invited {date}, not accepted yet",
|
||||
"guardianRemove": "End access",
|
||||
"guardianRemoveTitle": "End {name}'s access?",
|
||||
"guardianRemoveBody": "{name} stops being able to open {profile}. Anything they entered stays in the record.",
|
||||
"guardianWithdrawBody": "The invitation to {name} is withdrawn. They never opened {profile}, so nothing changes in the record.",
|
||||
"guardianRemoveConfirm": "End access",
|
||||
"soleGuardian": "You are the only person looking after {name}, so there is no access here you can end. Invite somebody and wait for them to accept, hand your own access back under “Records I can open”, or delete the profile.",
|
||||
"guardianInviteLabel": "Invite another guardian",
|
||||
"guardianInvite": "Send invitation",
|
||||
"guardianInviting": "Sending…",
|
||||
"guardianInvited": "Invitation sent to {name}.",
|
||||
"guardianExpiresHint": "Optional. Access ends at the end of that day in your timezone.",
|
||||
"guardianPendingNote": "An invited guardian counts only once they accept. Until then you are still the last one, and handing your own access back is still refused.",
|
||||
"guardianErrorDuplicate": "That account already has access to this record.",
|
||||
"guardianErrorManagedInvitee": "A managed profile cannot look after another record. Invite an account that signs in.",
|
||||
"guardianErrorNoAccount": "No account on this instance carries that name or e-mail.",
|
||||
"guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.",
|
||||
"guardianErrorFailed": "The invitation could not be sent.",
|
||||
"errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.",
|
||||
"rosterLoadError": "Could not load who looks after this record.",
|
||||
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Saved to {name}'s record."
|
||||
},
|
||||
@@ -9786,7 +9841,10 @@
|
||||
"alreadyEnded": "That access has already ended.",
|
||||
"notFound": "That access is no longer there.",
|
||||
"revokeFailed": "The access could not be ended.",
|
||||
"renounceFailed": "The access could not be handed back."
|
||||
"renounceFailed": "The access could not be handed back.",
|
||||
"profileNotFound": "That profile is not there any more — it may already have been deleted, or your access to it may have ended.",
|
||||
"deleteProfileFailed": "The profile could not be deleted.",
|
||||
"removeGuardianFailed": "That access could not be ended."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here."
|
||||
|
||||
+59
-1
@@ -9642,6 +9642,61 @@
|
||||
"kindManaged": "Perfil administrado",
|
||||
"open": "Abrir el historial de {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Perfiles de los que te ocupas",
|
||||
"description": "Historiales de salud sin inicio de sesión propio.",
|
||||
"explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión — una criatura, o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.",
|
||||
"nameLabel": "Nombre",
|
||||
"namePlaceholder": "El nombre con el que reconocerás este historial",
|
||||
"nameHint": "Hasta 80 caracteres. Se muestra allá donde aparezca este historial.",
|
||||
"nameRequired": "Dale un nombre al historial.",
|
||||
"nameTooLong": "Ese nombre tiene más de 80 caracteres.",
|
||||
"dobLabel": "Fecha de nacimiento (opcional)",
|
||||
"dobHint": "Déjala vacía si prefieres no registrarla. No se deduce nada a partir de un año.",
|
||||
"localeLabel": "Idioma de este historial",
|
||||
"localeHint": "El idioma de los textos que se escriben en este historial. Tus propias pantallas siguen en tu idioma.",
|
||||
"timezoneHint": "La zona en la que se miden los días de este historial.",
|
||||
"timezoneInvalid": "Este navegador no conoce esa zona horaria.",
|
||||
"create": "Crear perfil",
|
||||
"creating": "Creando…",
|
||||
"created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. No ha cambiado nada — revisa tu conexión y vuelve a intentarlo.",
|
||||
"errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.",
|
||||
"errorFailed": "No se pudo crear el perfil.",
|
||||
"delete": "Eliminar perfil",
|
||||
"deleteTitle": "¿Eliminar {name}?",
|
||||
"deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.",
|
||||
"deleteGuardiansOne": "{count} persona se ocupa hoy de este historial y lo perderá.",
|
||||
"deleteGuardiansFew": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
"deleteGuardiansOther": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
"deleteFloorNote": "También es la salida cuando eres la última persona responsable: puedes terminar el historial, pero no dejarlo sin nadie que se ocupe de él.",
|
||||
"deleteConfirm": "Eliminar perfil",
|
||||
"guardiansTitle": "Quién se ocupa de {name}",
|
||||
"guardianYou": "(tú)",
|
||||
"guardianSince": "Se ocupa de este historial desde el {date}",
|
||||
"guardianPending": "Invitada el {date}, aún sin aceptar",
|
||||
"guardianRemove": "Terminar acceso",
|
||||
"guardianRemoveTitle": "¿Terminar el acceso de {name}?",
|
||||
"guardianRemoveBody": "{name} dejará de poder abrir {profile}. Lo que haya registrado se queda en el historial.",
|
||||
"guardianWithdrawBody": "Se retira la invitación a {name}. Nunca abrió {profile}, así que en el historial no cambia nada.",
|
||||
"guardianRemoveConfirm": "Terminar acceso",
|
||||
"soleGuardian": "Eres la única persona que se ocupa de {name}, así que aquí no hay ningún acceso que puedas terminar. Invita a alguien y espera a que acepte, devuelve tu propio acceso en «Historiales que puedo abrir», o elimina el perfil.",
|
||||
"guardianInviteLabel": "Invitar a otra persona responsable",
|
||||
"guardianInvite": "Enviar invitación",
|
||||
"guardianInviting": "Enviando…",
|
||||
"guardianInvited": "Invitación enviada a {name}.",
|
||||
"guardianExpiresHint": "Opcional. El acceso termina al final de ese día en tu zona horaria.",
|
||||
"guardianPendingNote": "Una persona invitada solo cuenta cuando acepta. Hasta entonces sigues siendo la última, y devolver tu propio acceso se sigue rechazando.",
|
||||
"guardianErrorDuplicate": "Esa cuenta ya tiene acceso a este historial.",
|
||||
"guardianErrorManagedInvitee": "Un perfil administrado no puede ocuparse de otro historial. Invita a una cuenta que inicie sesión.",
|
||||
"guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.",
|
||||
"guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.",
|
||||
"guardianErrorFailed": "No se pudo enviar la invitación.",
|
||||
"errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.",
|
||||
"rosterLoadError": "No se pudo cargar quién se ocupa de este historial.",
|
||||
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Guardado en el historial de {name}."
|
||||
},
|
||||
@@ -9786,7 +9841,10 @@
|
||||
"alreadyEnded": "Ese acceso ya ha terminado.",
|
||||
"notFound": "Ese acceso ya no existe.",
|
||||
"revokeFailed": "No se pudo terminar el acceso.",
|
||||
"renounceFailed": "No se pudo devolver el acceso."
|
||||
"renounceFailed": "No se pudo devolver el acceso.",
|
||||
"profileNotFound": "Ese perfil ya no está — puede que se haya eliminado, o que tu acceso haya terminado.",
|
||||
"deleteProfileFailed": "No se pudo eliminar el perfil.",
|
||||
"removeGuardianFailed": "No se pudo terminar ese acceso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí."
|
||||
|
||||
+59
-1
@@ -9642,6 +9642,61 @@
|
||||
"kindManaged": "Profil géré",
|
||||
"open": "Ouvrir le dossier de {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profils dont vous vous occupez",
|
||||
"description": "Dossiers de santé sans identifiants propres.",
|
||||
"explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même — un enfant, ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.",
|
||||
"nameLabel": "Nom",
|
||||
"namePlaceholder": "Le nom sous lequel vous reconnaîtrez ce dossier",
|
||||
"nameHint": "Jusqu'à 80 caractères. Affiché partout où ce dossier apparaît.",
|
||||
"nameRequired": "Donnez un nom au dossier.",
|
||||
"nameTooLong": "Ce nom dépasse 80 caractères.",
|
||||
"dobLabel": "Date de naissance (facultatif)",
|
||||
"dobHint": "Laissez le champ vide si vous préférez ne pas en enregistrer. Rien n'est déduit d'une année.",
|
||||
"localeLabel": "Langue de ce dossier",
|
||||
"localeHint": "La langue des textes écrits dans ce dossier. Vos propres écrans restent dans votre langue.",
|
||||
"timezoneHint": "Le fuseau dans lequel les journées de ce dossier sont comptées.",
|
||||
"timezoneInvalid": "Ce navigateur ne connaît pas ce fuseau horaire.",
|
||||
"create": "Créer le profil",
|
||||
"creating": "Création…",
|
||||
"created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.",
|
||||
"errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.",
|
||||
"errorFailed": "Le profil n'a pas pu être créé.",
|
||||
"delete": "Supprimer le profil",
|
||||
"deleteTitle": "Supprimer {name} ?",
|
||||
"deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.",
|
||||
"deleteGuardiansOne": "{count} personne s'occupe aujourd'hui de ce dossier et le perdra.",
|
||||
"deleteGuardiansFew": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
"deleteGuardiansOther": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
"deleteFloorNote": "C'est aussi l'issue lorsque vous êtes la dernière personne responsable : vous pouvez mettre fin au dossier, mais pas le laisser sans personne.",
|
||||
"deleteConfirm": "Supprimer le profil",
|
||||
"guardiansTitle": "Qui s'occupe de {name}",
|
||||
"guardianYou": "(vous)",
|
||||
"guardianSince": "S'occupe de ce dossier depuis le {date}",
|
||||
"guardianPending": "Invitée le {date}, pas encore acceptée",
|
||||
"guardianRemove": "Mettre fin à l'accès",
|
||||
"guardianRemoveTitle": "Mettre fin à l'accès de {name} ?",
|
||||
"guardianRemoveBody": "{name} ne pourra plus ouvrir {profile}. Ce qu'elle a saisi reste dans le dossier.",
|
||||
"guardianWithdrawBody": "L'invitation adressée à {name} est retirée. Cette personne n'a jamais ouvert {profile}, rien ne change dans le dossier.",
|
||||
"guardianRemoveConfirm": "Mettre fin à l'accès",
|
||||
"soleGuardian": "Vous êtes la seule personne à vous occuper de {name} : il n'y a donc ici aucun accès auquel mettre fin. Invitez quelqu'un et attendez son acceptation, rendez votre propre accès sous « Dossiers que je peux ouvrir », ou supprimez le profil.",
|
||||
"guardianInviteLabel": "Inviter une autre personne responsable",
|
||||
"guardianInvite": "Envoyer l'invitation",
|
||||
"guardianInviting": "Envoi…",
|
||||
"guardianInvited": "Invitation envoyée à {name}.",
|
||||
"guardianExpiresHint": "Facultatif. L'accès prend fin à la fin de cette journée dans votre fuseau horaire.",
|
||||
"guardianPendingNote": "Une personne invitée ne compte qu'une fois qu'elle a accepté. D'ici là vous êtes toujours la dernière, et rendre votre propre accès reste refusé.",
|
||||
"guardianErrorDuplicate": "Ce compte a déjà accès à ce dossier.",
|
||||
"guardianErrorManagedInvitee": "Un profil géré ne peut pas s'occuper d'un autre dossier. Invitez un compte qui se connecte.",
|
||||
"guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.",
|
||||
"guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.",
|
||||
"guardianErrorFailed": "L'invitation n'a pas pu être envoyée.",
|
||||
"errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.",
|
||||
"rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.",
|
||||
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Enregistré dans le dossier de {name}."
|
||||
},
|
||||
@@ -9786,7 +9841,10 @@
|
||||
"alreadyEnded": "Cet accès a déjà pris fin.",
|
||||
"notFound": "Cet accès n'existe plus.",
|
||||
"revokeFailed": "L'accès n'a pas pu être terminé.",
|
||||
"renounceFailed": "L'accès n'a pas pu être rendu."
|
||||
"renounceFailed": "L'accès n'a pas pu être rendu.",
|
||||
"profileNotFound": "Ce profil n'existe plus — il a peut-être été supprimé, ou votre accès a pris fin.",
|
||||
"deleteProfileFailed": "Le profil n'a pas pu être supprimé.",
|
||||
"removeGuardianFailed": "Cet accès n'a pas pu prendre fin."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici."
|
||||
|
||||
+59
-1
@@ -9642,6 +9642,61 @@
|
||||
"kindManaged": "Profilo gestito",
|
||||
"open": "Apri la cartella di {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profili di cui ti occupi",
|
||||
"description": "Cartelle sanitarie senza credenziali proprie.",
|
||||
"explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé — un bambino, o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.",
|
||||
"nameLabel": "Nome",
|
||||
"namePlaceholder": "Il nome con cui riconoscerai questa cartella",
|
||||
"nameHint": "Fino a 80 caratteri. Compare ovunque appaia questa cartella.",
|
||||
"nameRequired": "Dai un nome alla cartella.",
|
||||
"nameTooLong": "Questo nome supera gli 80 caratteri.",
|
||||
"dobLabel": "Data di nascita (facoltativa)",
|
||||
"dobHint": "Lascia il campo vuoto se preferisci non registrarla. Da un anno di nascita non si deduce nulla.",
|
||||
"localeLabel": "Lingua di questa cartella",
|
||||
"localeHint": "La lingua dei testi scritti in questa cartella. Le tue schermate restano nella tua lingua.",
|
||||
"timezoneHint": "Il fuso in cui si contano le giornate di questa cartella.",
|
||||
"timezoneInvalid": "Questo browser non conosce quel fuso orario.",
|
||||
"create": "Crea profilo",
|
||||
"creating": "Creazione in corso…",
|
||||
"created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorOffline": "Non è arrivato al server. Non è cambiato nulla — controlla la connessione e riprova.",
|
||||
"errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.",
|
||||
"errorFailed": "Non è stato possibile creare il profilo.",
|
||||
"delete": "Elimina profilo",
|
||||
"deleteTitle": "Eliminare {name}?",
|
||||
"deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.",
|
||||
"deleteGuardiansOne": "{count} persona si occupa oggi di questa cartella e la perderà.",
|
||||
"deleteGuardiansFew": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
"deleteGuardiansOther": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
"deleteFloorNote": "È anche la via d'uscita quando sei l'ultima persona responsabile: puoi chiudere la cartella, ma non lasciarla senza nessuno.",
|
||||
"deleteConfirm": "Elimina profilo",
|
||||
"guardiansTitle": "Chi si occupa di {name}",
|
||||
"guardianYou": "(tu)",
|
||||
"guardianSince": "Si occupa di questa cartella dal {date}",
|
||||
"guardianPending": "Invitata il {date}, non ancora accettata",
|
||||
"guardianRemove": "Termina accesso",
|
||||
"guardianRemoveTitle": "Terminare l'accesso di {name}?",
|
||||
"guardianRemoveBody": "{name} non potrà più aprire {profile}. Quello che ha inserito resta nella cartella.",
|
||||
"guardianWithdrawBody": "L'invito a {name} viene ritirato. Non ha mai aperto {profile}, quindi nella cartella non cambia nulla.",
|
||||
"guardianRemoveConfirm": "Termina accesso",
|
||||
"soleGuardian": "Sei l'unica persona che si occupa di {name}, quindi qui non c'è nessun accesso da terminare. Invita qualcuno e aspetta che accetti, restituisci il tuo accesso in «Cartelle che posso aprire», oppure elimina il profilo.",
|
||||
"guardianInviteLabel": "Invita un'altra persona responsabile",
|
||||
"guardianInvite": "Invia invito",
|
||||
"guardianInviting": "Invio in corso…",
|
||||
"guardianInvited": "Invito inviato a {name}.",
|
||||
"guardianExpiresHint": "Facoltativo. L'accesso termina alla fine di quel giorno nel tuo fuso orario.",
|
||||
"guardianPendingNote": "Una persona invitata conta solo quando accetta. Fino ad allora sei ancora l'ultima, e restituire il tuo accesso viene comunque rifiutato.",
|
||||
"guardianErrorDuplicate": "Quell'account ha già accesso a questa cartella.",
|
||||
"guardianErrorManagedInvitee": "Un profilo gestito non può occuparsi di un'altra cartella. Invita un account che accede da sé.",
|
||||
"guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.",
|
||||
"guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.",
|
||||
"guardianErrorFailed": "Non è stato possibile inviare l'invito.",
|
||||
"errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.",
|
||||
"rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.",
|
||||
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Salvato nella cartella di {name}."
|
||||
},
|
||||
@@ -9786,7 +9841,10 @@
|
||||
"alreadyEnded": "Questo accesso è già terminato.",
|
||||
"notFound": "Questo accesso non esiste più.",
|
||||
"revokeFailed": "Non è stato possibile terminare l'accesso.",
|
||||
"renounceFailed": "Non è stato possibile restituire l'accesso."
|
||||
"renounceFailed": "Non è stato possibile restituire l'accesso.",
|
||||
"profileNotFound": "Quel profilo non c'è più — potrebbe essere già stato eliminato, o il tuo accesso è terminato.",
|
||||
"deleteProfileFailed": "Non è stato possibile eliminare il profilo.",
|
||||
"removeGuardianFailed": "Non è stato possibile terminare quell'accesso."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui."
|
||||
|
||||
+59
-1
@@ -9642,6 +9642,61 @@
|
||||
"kindManaged": "Zarządzany profil",
|
||||
"open": "Otwórz kartę użytkownika {name}"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profile, którymi się opiekujesz",
|
||||
"description": "Karty zdrowia bez własnego logowania.",
|
||||
"explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje — dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.",
|
||||
"nameLabel": "Nazwa",
|
||||
"namePlaceholder": "Nazwa, po której rozpoznasz tę kartę",
|
||||
"nameHint": "Do 80 znaków. Wyświetla się wszędzie tam, gdzie pojawia się ta karta.",
|
||||
"nameRequired": "Nadaj karcie nazwę.",
|
||||
"nameTooLong": "Ta nazwa ma więcej niż 80 znaków.",
|
||||
"dobLabel": "Data urodzenia (opcjonalnie)",
|
||||
"dobHint": "Zostaw puste, jeśli wolisz jej nie zapisywać. Z samego rocznika nic nie jest wyliczane.",
|
||||
"localeLabel": "Język tej karty",
|
||||
"localeHint": "Język tekstów zapisywanych w tej karcie. Twoje własne widoki pozostają w Twoim języku.",
|
||||
"timezoneHint": "Strefa, w której liczone są dni tej karty.",
|
||||
"timezoneInvalid": "Ta przeglądarka nie zna takiej strefy czasowej.",
|
||||
"create": "Utwórz profil",
|
||||
"creating": "Tworzenie…",
|
||||
"created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.",
|
||||
"errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.",
|
||||
"errorFailed": "Nie udało się utworzyć profilu.",
|
||||
"delete": "Usuń profil",
|
||||
"deleteTitle": "Usunąć {name}?",
|
||||
"deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.",
|
||||
"deleteGuardiansOne": "{count} osoba opiekuje się dziś tą kartą i ją straci.",
|
||||
"deleteGuardiansFew": "{count} osoby opiekują się dziś tą kartą i ją stracą.",
|
||||
"deleteGuardiansOther": "{count} osób opiekuje się dziś tą kartą i ją straci.",
|
||||
"deleteFloorNote": "To także wyjście, gdy jesteś ostatnią osobą opiekującą się profilem: możesz zamknąć kartę, ale nie zostawić jej bez opieki.",
|
||||
"deleteConfirm": "Usuń profil",
|
||||
"guardiansTitle": "Kto opiekuje się kartą {name}",
|
||||
"guardianYou": "(Ty)",
|
||||
"guardianSince": "Opiekuje się tą kartą od {date}",
|
||||
"guardianPending": "Zaproszenie z {date}, jeszcze nieprzyjęte",
|
||||
"guardianRemove": "Zakończ dostęp",
|
||||
"guardianRemoveTitle": "Zakończyć dostęp osoby {name}?",
|
||||
"guardianRemoveBody": "{name} nie będzie już mogła otworzyć karty {profile}. To, co zapisała, zostaje w karcie.",
|
||||
"guardianWithdrawBody": "Zaproszenie dla osoby {name} zostaje wycofane. Nigdy nie otworzyła karty {profile}, więc w karcie nic się nie zmienia.",
|
||||
"guardianRemoveConfirm": "Zakończ dostęp",
|
||||
"soleGuardian": "Jesteś jedyną osobą, która opiekuje się kartą {name}, więc nie ma tu dostępu, który mógłbyś zakończyć. Zaproś kogoś i poczekaj na przyjęcie, oddaj własny dostęp w sekcji „Karty, które mogę otworzyć” albo usuń profil.",
|
||||
"guardianInviteLabel": "Zaproś kolejną osobę opiekującą się profilem",
|
||||
"guardianInvite": "Wyślij zaproszenie",
|
||||
"guardianInviting": "Wysyłanie…",
|
||||
"guardianInvited": "Zaproszenie wysłane do osoby {name}.",
|
||||
"guardianExpiresHint": "Opcjonalnie. Dostęp kończy się z końcem tego dnia w Twojej strefie czasowej.",
|
||||
"guardianPendingNote": "Zaproszona osoba liczy się dopiero po przyjęciu zaproszenia. Do tego czasu wciąż jesteś ostatnia, a oddanie własnego dostępu nadal będzie odrzucane.",
|
||||
"guardianErrorDuplicate": "To konto ma już dostęp do tej karty.",
|
||||
"guardianErrorManagedInvitee": "Zarządzany profil nie może opiekować się inną kartą. Zaproś konto, które samo się loguje.",
|
||||
"guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.",
|
||||
"guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"guardianErrorFailed": "Nie udało się wysłać zaproszenia.",
|
||||
"errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.",
|
||||
"rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.",
|
||||
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}."
|
||||
},
|
||||
@@ -9786,7 +9841,10 @@
|
||||
"alreadyEnded": "Ten dostęp już się zakończył.",
|
||||
"notFound": "Tego dostępu już nie ma.",
|
||||
"revokeFailed": "Nie udało się zakończyć dostępu.",
|
||||
"renounceFailed": "Nie udało się zwrócić dostępu."
|
||||
"renounceFailed": "Nie udało się zwrócić dostępu.",
|
||||
"profileNotFound": "Tego profilu już nie ma — mógł zostać usunięty albo Twój dostęp do niego się zakończył.",
|
||||
"deleteProfileFailed": "Nie udało się usunąć profilu.",
|
||||
"removeGuardianFailed": "Nie udało się zakończyć tego dostępu."
|
||||
},
|
||||
"anamnesis": {
|
||||
"relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany."
|
||||
|
||||
@@ -241,6 +241,23 @@
|
||||
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Operational windows and cookie flags",
|
||||
"description": "Values the product states back to a user or that decide whether a session cookie is accepted. Optional: each has a documented default. They are listed so `pnpm check-env` reports them, and so the compose whitelist and the example files can be held in step with this manifest.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "AUDIT_LOG_RETENTION_DAYS",
|
||||
"purpose": "How long audit rows survive, in days (365 by default). Published, not merely enforced: the shared-access activity feed and the dialog that ends somebody's access both state the number back to the user.",
|
||||
"example": "365"
|
||||
},
|
||||
{
|
||||
"name": "SESSION_COOKIE_SECURE",
|
||||
"purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.",
|
||||
"example": "false"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -110,6 +110,18 @@ function routeFiles(dir: string): string[] {
|
||||
interface ModuleFacts {
|
||||
/** Does the module reach the record resolver at ANY level. */
|
||||
delegatesRecord: boolean;
|
||||
/**
|
||||
* Every level the module passes to `requireRecordAuth`, in source order.
|
||||
*
|
||||
* Recorded so the inclusion rule's LEVEL axis has something that can
|
||||
* falsify it. `delegatesRecord` used to be set only for `"manage"`, and the
|
||||
* legs below could not tell the corrected rule from the old one: every
|
||||
* assertion about coverage passed either way, because a manage-only sweep
|
||||
* still finds every manage action. The leg that separates them asks whether
|
||||
* the walked set STRICTLY EXCEEDS the manage-only one, and it needs the
|
||||
* levels to ask.
|
||||
*/
|
||||
recordLevels: string[];
|
||||
/**
|
||||
* Every action the module hands to `auditLog("…")`, awaited or not.
|
||||
*
|
||||
@@ -190,12 +202,17 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts {
|
||||
};
|
||||
|
||||
let delegatesRecord = false;
|
||||
const recordLevels: string[] = [];
|
||||
const auditActions: string[] = [];
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = calleeName(node.expression);
|
||||
if (name === RECORD_RESOLVER) delegatesRecord = true;
|
||||
if (name === RECORD_RESOLVER) {
|
||||
delegatesRecord = true;
|
||||
const level = firstStringArg(node);
|
||||
if (level !== null) recordLevels.push(level);
|
||||
}
|
||||
if (name === "auditLog") {
|
||||
const action = firstStringArg(node);
|
||||
if (action !== null) auditActions.push(action);
|
||||
@@ -205,7 +222,7 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts {
|
||||
};
|
||||
parsed.forEachChild(visit);
|
||||
|
||||
return { delegatesRecord, auditActions, imports: importPaths };
|
||||
return { delegatesRecord, recordLevels, auditActions, imports: importPaths };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,6 +458,30 @@ describe("every delegated action has a sentence of its own", () => {
|
||||
expect(actions.length).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
it("walks strictly more modules than a manage-only sweep would", () => {
|
||||
// The level axis of the inclusion rule, with something that can falsify
|
||||
// it. Every other leg in this file passes under BOTH rules — a manage-only
|
||||
// sweep still finds every manage action, so coverage cannot tell the two
|
||||
// apart. This one can: the corrected rule is "any module that reaches
|
||||
// `requireRecordAuth` at any level", so the walked set must strictly
|
||||
// exceed the subset that names `manage` anywhere.
|
||||
const manageOnly = delegatedModules.filter((file) =>
|
||||
read(file).recordLevels.includes("manage"),
|
||||
);
|
||||
expect(manageOnly.length).toBeGreaterThan(0);
|
||||
expect(delegatedModules.length).toBeGreaterThan(manageOnly.length);
|
||||
|
||||
// A named anchor, so the claim does not rest on a count that could drift
|
||||
// to equality without anybody noticing. The custom-metrics list resolves
|
||||
// `requireRecordAuth("read", …)` and nothing else, so a manage-only filter
|
||||
// drops it — along with the refusal breadcrumbs it and its neighbours file.
|
||||
const anchor = join(API, "custom-metrics/route.ts");
|
||||
expect(read(anchor).recordLevels).toEqual(["read"]);
|
||||
expect(read(anchor).recordLevels).not.toContain("manage");
|
||||
expect(delegatedModules).toContain(anchor);
|
||||
expect(manageOnly).not.toContain(anchor);
|
||||
});
|
||||
|
||||
it("reaches the actions a route files through what it imports", () => {
|
||||
// Both halves of the fix, pinned against the two that were invisible.
|
||||
expect(actions).toContain("medication.intake.import.kickoff.denied");
|
||||
|
||||
@@ -34,6 +34,13 @@ import { describe, expect, it } from "vitest";
|
||||
* `scripts/check-env.ts` owns that question and CI runs it against
|
||||
* `.env.production.example`. This is the narrower one that check-env cannot
|
||||
* answer, because it reads a file rather than the compose whitelist.
|
||||
*
|
||||
* v1.37.0 — a fourth place joined the three. Both of these variables were
|
||||
* documented in the two example files and whitelisted in compose, and NEITHER
|
||||
* was in `scripts/env-manifest.json` — so `pnpm check-env` had never heard of
|
||||
* them, and an operator running the pre-deploy check against their own
|
||||
* environment was told nothing about the two settings this guard exists for.
|
||||
* Three places held them in step and the fourth, the one CI runs, did not.
|
||||
*/
|
||||
const ROOT = process.cwd();
|
||||
const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
|
||||
@@ -42,6 +49,19 @@ const PROD_EXAMPLE = readFileSync(
|
||||
join(ROOT, ".env.production.example"),
|
||||
"utf8",
|
||||
);
|
||||
const MANIFEST = readFileSync(join(ROOT, "scripts/env-manifest.json"), "utf8");
|
||||
|
||||
/** Every variable name the manifest declares, in any group. */
|
||||
const MANIFEST_NAMES: string[] = (
|
||||
JSON.parse(MANIFEST) as {
|
||||
groups: Array<{ variables: Array<{ name: string; anyOf?: string[] }> }>;
|
||||
}
|
||||
).groups.flatMap((group) =>
|
||||
group.variables.flatMap((variable) => [
|
||||
variable.name,
|
||||
...(variable.anyOf ?? []),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Variables whose value the product states back to a user.
|
||||
@@ -72,6 +92,11 @@ describe("a variable the product publishes reaches the container", () => {
|
||||
expect(COMPOSE).toContain("environment:");
|
||||
expect(ENV_EXAMPLE.length).toBeGreaterThan(1000);
|
||||
expect(PROD_EXAMPLE.length).toBeGreaterThan(1000);
|
||||
// The manifest really parsed, and really carries names. An empty list
|
||||
// would make the manifest leg below a quiet pass for every entry.
|
||||
expect(MANIFEST_NAMES.length).toBeGreaterThan(20);
|
||||
expect(MANIFEST_NAMES).toContain("DATABASE_URL");
|
||||
expect(MANIFEST_NAMES).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE");
|
||||
// And a name nothing declares is genuinely absent from all three, so the
|
||||
// matchers below are not passing on a substring that is everywhere.
|
||||
for (const file of [COMPOSE, ENV_EXAMPLE, PROD_EXAMPLE]) {
|
||||
@@ -96,6 +121,13 @@ describe("a variable the product publishes reaches the container", () => {
|
||||
it("is documented in the production example", () => {
|
||||
expect(PROD_EXAMPLE).toContain(name);
|
||||
});
|
||||
|
||||
it("is declared in the manifest, so `pnpm check-env` reports it", () => {
|
||||
// The fourth place, and the one CI actually runs. A variable an
|
||||
// operator is told to set, and which the pre-deploy check has never
|
||||
// heard of, is documented everywhere except where it would be checked.
|
||||
expect(MANIFEST_NAMES).toContain(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -227,4 +227,47 @@ describe("the eight release journeys are enumerated", () => {
|
||||
expect(journey.claim.length, journey.id).toBeGreaterThan(40);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the ids that are wired to a test title, and only those.
|
||||
*
|
||||
* The enumeration above says eight journeys exist. It says nothing about
|
||||
* whether any of them RAN, which is the same gap between "the suite is
|
||||
* green" and "the suite covers this" that the rest of this file exists to
|
||||
* close. Wiring all eight is the integration plan's item; this list is the
|
||||
* subset already claimed by a spec, and it grows as they land.
|
||||
*
|
||||
* An id on this list whose spec loses the marker fails here rather than
|
||||
* disappearing quietly from a run nobody counted.
|
||||
*/
|
||||
const WIRED_JOURNEYS: ReadonlyArray<{ id: string; spec: string }> = [
|
||||
{
|
||||
id: "J3-managed-profile-lifecycle",
|
||||
spec: "e2e/v137-sharing-managed-profiles.spec.ts",
|
||||
},
|
||||
];
|
||||
|
||||
it("claims only ids the enumeration actually declares", () => {
|
||||
// A wiring entry naming a journey nobody enumerated would be a claim about
|
||||
// nothing, and would make the leg below pass against a marker that means
|
||||
// no more than the string it is.
|
||||
expect(WIRED_JOURNEYS.length).toBeGreaterThan(0);
|
||||
const declared = new Set(V137_E2E_JOURNEYS.map((j) => j.id));
|
||||
for (const { id } of WIRED_JOURNEYS) {
|
||||
expect(declared, id).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(WIRED_JOURNEYS)("$id is carried by a test title", ({ id, spec }) => {
|
||||
const source = readFileSync(join(ROOT, spec), "utf8");
|
||||
// Non-zero proof: the spec really loaded and really has tests, so a path
|
||||
// that stopped existing fails here rather than matching nothing quietly.
|
||||
expect(source.length).toBeGreaterThan(1000);
|
||||
const titles = [...testBodies(source).keys()];
|
||||
expect(titles.length).toBeGreaterThan(1);
|
||||
expect(
|
||||
titles.filter((title) => title.includes(`[${id}]`)).length,
|
||||
`no test title in ${spec} carries [${id}]`,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -207,6 +207,66 @@ describe("what a refused sharing action says", () => {
|
||||
key: "recordSharing.actionError.notFound",
|
||||
why: "the row is gone",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(404, "managed_profile.not_found"),
|
||||
key: "recordSharing.actionError.profileNotFound",
|
||||
why: "the only refusal the delete route classifies, and it covers three situations the server will not tell apart",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(404, "managed_profile.not_found"),
|
||||
key: "recordSharing.actionError.profileNotFound",
|
||||
why: "the same refusal reached from the guardian-removal route",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(409, "managed_profile.guardian.required"),
|
||||
key: "recordSharing.actionError.lastGuardian",
|
||||
why: "the floor, still enforced by the server after the card has hidden the control",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(500),
|
||||
key: "recordSharing.actionError.deleteProfileFailed",
|
||||
why: "anything the delete route does not classify becomes the generic envelope and must not borrow a diagnosis",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(401, "auth.stepup.required"),
|
||||
key: "recordSharing.managed.errorStepUp",
|
||||
why: "the step-up window lapsed while the confirm dialog was open — the likeliest refusal of the two acts that carry a gate",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: apiError(401, "auth.stepup.required"),
|
||||
key: "recordSharing.managed.errorStepUp",
|
||||
why: "the same gate on the other confirmed act",
|
||||
},
|
||||
{
|
||||
action: "deleteProfile",
|
||||
err: apiError(401, "auth.stepup.mfa_not_enrolled"),
|
||||
key: "recordSharing.managed.errorMfaRequired",
|
||||
why: "no second factor at all: 'confirm your second factor' is a dead end, and enrolling is the only way through",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: apiError(401),
|
||||
key: "recordSharing.actionError.acceptFailed",
|
||||
why: "a 401 on a route with NO step-up gate is a lapsed session, and must not tell somebody to confirm a factor",
|
||||
},
|
||||
{
|
||||
action: "renounce",
|
||||
err: apiError(401),
|
||||
key: "recordSharing.actionError.renounceFailed",
|
||||
why: "same, from the delegate's side",
|
||||
},
|
||||
{
|
||||
action: "removeGuardian",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
key: "recordSharing.actionError.offline",
|
||||
why: "never reached the server, so the guardian still has access and the row is unchanged",
|
||||
},
|
||||
{
|
||||
action: "accept",
|
||||
err: new TypeError("Failed to fetch"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod/v4";
|
||||
import {
|
||||
apiHandler,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
@@ -14,12 +15,61 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { listManagedProfileGuardians } from "@/lib/managed-profiles/guardian-list";
|
||||
import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Who looks after this profile.
|
||||
*
|
||||
* The read the panel needs before it can offer to remove anybody, and the one
|
||||
* the last-Guardian floor is counted from before the click rather than
|
||||
* discovered as a 409 after it.
|
||||
*
|
||||
* Cookie-only, like every endpoint in this family, and for the same structural
|
||||
* reason: `requireCookieAuth` reads only the session cookie and never falls
|
||||
* through to the Bearer branch. (Named that way rather than by the session
|
||||
* helper it calls, because `session-surface-guard.test.ts` matches on the
|
||||
* helper's NAME in file text and a comment naming it enrols this route in a
|
||||
* list of files that resolve a session themselves. This one does not.)
|
||||
* Fresh MFA is deliberately NOT required — this
|
||||
* is a read, and it discloses nothing the caller cannot already read on their
|
||||
* own sharing panel about a party to a grant they hold. The gate belongs to the
|
||||
* acts, not to looking.
|
||||
*
|
||||
* An actor surface: the profile is named by the URL, and the caller acts as
|
||||
* themselves. `requireRecordAuth` / `requireGuardianAuth` would make the roster
|
||||
* answerable only while switched INTO the profile, which is backwards for a
|
||||
* panel whose whole purpose is to work from the Guardian's own account.
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const guardians = await listManagedProfileGuardians({
|
||||
profileId: id,
|
||||
callerId: user.id,
|
||||
});
|
||||
// One refusal for "no such account", "not a managed profile" and "you are
|
||||
// not a Guardian of it", so the route is not an enumeration oracle.
|
||||
if (guardians === null) {
|
||||
return apiError("Managed profile not found", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.guardian.list" },
|
||||
meta: { profile_id: id, guardian_count: guardians.length },
|
||||
});
|
||||
return apiSuccess(guardians);
|
||||
},
|
||||
);
|
||||
|
||||
const INVITE_LIMIT = 10;
|
||||
const INVITE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -6,12 +6,30 @@ import {
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
requireFreshMfa,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* The same ceiling its guardian sibling carries, and for the same reason.
|
||||
*
|
||||
* `POST /api/managed-profiles/{id}/guardians` has been rate-limited at ten an
|
||||
* hour since it shipped; this route creates a real account row on every call
|
||||
* and had none. A person cannot mistype their way into a hundred accounts, but
|
||||
* a script can, and the two endpoints of one family answering differently to
|
||||
* the same abuse is the kind of asymmetry nobody notices until it is used.
|
||||
*/
|
||||
const CREATE_LIMIT = 10;
|
||||
const CREATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
const managedProfileSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
@@ -32,6 +50,16 @@ const managedProfileSchema = z
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
// Before the body is read, exactly as the guardian route does it: the cheap
|
||||
// refusal comes first so a flood costs a bucket lookup rather than a parse.
|
||||
const rateLimit = await checkRateLimit(
|
||||
`managed-profile:create:${user.id}`,
|
||||
CREATE_LIMIT,
|
||||
CREATE_WINDOW_MS,
|
||||
);
|
||||
if (!rateLimit.allowed) {
|
||||
return apiError("Too many profiles created, try again later", 429);
|
||||
}
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The banner names which KIND of record this is, and the two names differ.
|
||||
*
|
||||
* ## Why this file exists
|
||||
*
|
||||
* `recordKindLabel` is a two-arm ternary, and until this file nothing could
|
||||
* fail it. The browser journey asserted `data-record-kind="managed"` — an
|
||||
* attribute the function does not read — so inverting the arms left the whole
|
||||
* suite green while the banner told a Guardian standing inside a managed
|
||||
* profile that they were in an ordinary shared record. That is not a cosmetic
|
||||
* difference: the two records have different rules, and the banner is the one
|
||||
* line of chrome whose job is to say which one you are in.
|
||||
*
|
||||
* The mapping is asserted through the real EN bundle rather than against key
|
||||
* names, because a key that resolves to the wrong sentence passes a key-level
|
||||
* check. Both arms are named, and the two are asserted to be DIFFERENT — a
|
||||
* function that returned one sentence for both would satisfy every "contains"
|
||||
* assertion anybody would think to write.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { recordKindLabel } from "../shared-record-banner";
|
||||
|
||||
/** The real bundle, so the assertion is about sentences and not about keys. */
|
||||
const EN = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "messages/en.json"), "utf8"),
|
||||
) as {
|
||||
recordSharing: { lookingAfter: { kindManaged: string; kindShared: string } };
|
||||
};
|
||||
|
||||
/** The i18n `t` as the banner receives it, resolved against the real bundle. */
|
||||
const t = (key: string): string => {
|
||||
const leaf = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, segment) =>
|
||||
typeof node === "object" && node !== null
|
||||
? (node as Record<string, unknown>)[segment]
|
||||
: undefined,
|
||||
EN,
|
||||
);
|
||||
if (typeof leaf !== "string") throw new Error(`unresolved key: ${key}`);
|
||||
return leaf;
|
||||
};
|
||||
|
||||
describe("what the shared-record banner calls the record", () => {
|
||||
it("has a translator that really resolves", () => {
|
||||
// Positive control. A `t` that returned its own key would make both legs
|
||||
// below pass on the key names while proving nothing about the sentences.
|
||||
expect(t("recordSharing.lookingAfter.kindManaged")).toBe(
|
||||
EN.recordSharing.lookingAfter.kindManaged,
|
||||
);
|
||||
expect(EN.recordSharing.lookingAfter.kindManaged.length).toBeGreaterThan(3);
|
||||
expect(EN.recordSharing.lookingAfter.kindShared.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it("names a managed profile as one", () => {
|
||||
expect(recordKindLabel("managed", t)).toBe(
|
||||
EN.recordSharing.lookingAfter.kindManaged,
|
||||
);
|
||||
});
|
||||
|
||||
it("names an ordinary shared record as one", () => {
|
||||
expect(recordKindLabel("shared", t)).toBe(
|
||||
EN.recordSharing.lookingAfter.kindShared,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not give the two the same sentence", () => {
|
||||
// The failure mode a pair of "contains" assertions cannot see.
|
||||
expect(recordKindLabel("managed", t)).not.toBe(
|
||||
recordKindLabel("shared", t),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -118,7 +118,17 @@ function accessLabel(
|
||||
: t("recordSharing.banner.readOnly");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
/**
|
||||
* Which kind of record this is, in the banner's own words.
|
||||
*
|
||||
* Exported and pure because the ternary is the whole claim and nothing could
|
||||
* fail it: the browser journey asserted `data-record-kind="managed"`, which is
|
||||
* the attribute this function does not read, so inverting the two arms left
|
||||
* every test in the suite green while telling a Guardian they were inside an
|
||||
* ordinary shared record. Two sentences that mean different things are worth
|
||||
* one test.
|
||||
*/
|
||||
export function recordKindLabel(
|
||||
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { GrantInviteCard } from "@/components/settings/access/grant-invite-card";
|
||||
import { GrantsGivenCard } from "@/components/settings/access/grants-given-card";
|
||||
import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card";
|
||||
import { ManagedProfileCard } from "@/components/settings/access/managed-profile-card";
|
||||
import { RecordActivityCard } from "@/components/settings/access/record-activity-card";
|
||||
|
||||
/**
|
||||
@@ -15,9 +16,18 @@ import { RecordActivityCard } from "@/components/settings/access/record-activity
|
||||
* somebody reads them:
|
||||
*
|
||||
* 1. Invite — the act.
|
||||
* 2. Who has access — the standing state, and the way to end it.
|
||||
* 3. What I can open — the same, from the other side.
|
||||
* 4. When it was opened — what actually happened.
|
||||
* 2. Records you look after — the other act, and the one that CREATES a
|
||||
* record rather than opening one that already exists.
|
||||
* 3. Who has access — the standing state, and the way to end it.
|
||||
* 4. What I can open — the same, from the other side.
|
||||
* 5. When it was opened — what actually happened.
|
||||
*
|
||||
* v1.37.0 put the managed-profile card second rather than in a settings area
|
||||
* of its own. A managed profile is a record with no login, reachable only
|
||||
* through the people who look after it — which is the same question this
|
||||
* section already answers twice, from a third side. Splitting it out would
|
||||
* have made "who may open which record" two half-answers on two pages, which
|
||||
* is the settings-IA rule below in miniature.
|
||||
*
|
||||
* Distinct from Settings → "Health record" → sharing links, which mints a
|
||||
* time-boxed read-only link to a REPORT for a clinician. This section is about
|
||||
@@ -35,6 +45,7 @@ export function AccessSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GrantInviteCard />
|
||||
<ManagedProfileCard />
|
||||
<GrantsGivenCard />
|
||||
<GrantsReceivedCard />
|
||||
<RecordActivityCard />
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
/**
|
||||
* The managed-profile surface, from a static render.
|
||||
*
|
||||
* ## What an SSR render can prove here, and what it cannot
|
||||
*
|
||||
* It can prove that a control exists, that the sentence which qualifies it
|
||||
* sits beside it in document order, that a list renders one row per profile,
|
||||
* and — because the section is composed rather than routed — that the whole
|
||||
* card is present for an owner and absent for a delegate. It CANNOT prove that
|
||||
* what the form posts reaches the server: there is no click here, so the
|
||||
* submit path never runs and every assertion below stays green with the form
|
||||
* wired to a constant. That half belongs to `tests/integration/
|
||||
* managed-profile-surface.test.ts` (the real routes) and to the browser
|
||||
* journey in `e2e/v137-sharing-managed-profiles.spec.ts`.
|
||||
*
|
||||
* The unit environment is `node`: no jsdom, no testing-library. The pure
|
||||
* exports (`displayNameIssue`, `createManagedProfileErrorKey`,
|
||||
* `managedProfilesOf`) are where the decisions live, and they are pinned
|
||||
* directly for that reason.
|
||||
*
|
||||
* ## The reachability legs are legs, not reasoning
|
||||
*
|
||||
* `/settings/access` is an ACTOR surface — grant management is the one thing a
|
||||
* delegate must have no reach into — and `access` classifies `personal`, so
|
||||
* the prediction is that a Guardian inside a profile and an adult MANAGE
|
||||
* delegate both get the refusal panel instead. That is asserted rather than
|
||||
* argued: `auth-shell.tsx` and `settings-shell.tsx` decide reachability above
|
||||
* every component in this file, and Plan 13 shipped a destination that was
|
||||
* admitted and unreachable because the prediction was not checked.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
import type { GrantList } from "@/lib/queries/use-account-grants";
|
||||
import type { ManagedProfileGuardian } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
/** The account payload, as the cards under test read it. */
|
||||
const authRef: {
|
||||
value: {
|
||||
accounts: AccountAccessEntry[];
|
||||
active: AccountAccessEntry | null;
|
||||
};
|
||||
} = { value: { accounts: [], active: null } };
|
||||
|
||||
vi.mock("@/hooks/use-auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/hooks/use-auth")>();
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "guardian",
|
||||
username: "guardian",
|
||||
timezone: "Europe/Berlin",
|
||||
accountAccess: {
|
||||
accounts: authRef.value.accounts,
|
||||
active: authRef.value.active,
|
||||
canSwitch: authRef.value.accounts.length > 0,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
isAuthUnknown: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-account-grants")>();
|
||||
return {
|
||||
...actual,
|
||||
useAccountGrants: () => ({
|
||||
data: grantList(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useRecordActivity: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useInviteGrant: () => idle(),
|
||||
useAcceptGrant: () => idle(),
|
||||
useRevokeGrant: () => idle(),
|
||||
useRenounceGrant: () => idle(),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The roster read, as each profile row sees it.
|
||||
*
|
||||
* Three states, not two. `"loading"` and `"error"` used to share `undefined`,
|
||||
* which is precisely the conflation the error branch exists to end: a failed
|
||||
* read that renders as a slow one is a person waiting for something that is
|
||||
* never coming.
|
||||
*/
|
||||
const rosterRef: {
|
||||
value: ManagedProfileGuardian[] | "loading" | "error";
|
||||
} = { value: [] };
|
||||
/** The deletion mutation, idle unless a case says otherwise. */
|
||||
const deleteRef = { value: idle() };
|
||||
/** The guardian invitation, idle unless a case says otherwise. */
|
||||
const inviteRef: { value: FakeMutation } = { value: idle() };
|
||||
/** The guardian removal, whose `variables` name a profile and a grant. */
|
||||
const removeGuardianRef: {
|
||||
value: Omit<FakeMutation, "variables"> & {
|
||||
variables: { grantId: string } | undefined;
|
||||
};
|
||||
} = { value: { ...idle(), variables: undefined } };
|
||||
|
||||
vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-managed-profiles")>();
|
||||
return {
|
||||
...actual,
|
||||
useCreateManagedProfile: () => idle(),
|
||||
useDeleteManagedProfile: () => deleteRef.value,
|
||||
useInviteManagedProfileGuardian: () => inviteRef.value,
|
||||
useRemoveManagedProfileGuardian: () => removeGuardianRef.value,
|
||||
useManagedProfileGuardians: () => ({
|
||||
data: Array.isArray(rosterRef.value) ? rosterRef.value : undefined,
|
||||
isLoading: rosterRef.value === "loading",
|
||||
isError: rosterRef.value === "error",
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
interface FakeMutation {
|
||||
mutate: ReturnType<typeof vi.fn>;
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
variables: string | undefined;
|
||||
}
|
||||
|
||||
function idle(): FakeMutation {
|
||||
return {
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function failed(error: unknown, variables: string): FakeMutation {
|
||||
return { ...idle(), isError: true, error, variables };
|
||||
}
|
||||
|
||||
function guardian(
|
||||
partial: Partial<ManagedProfileGuardian> = {},
|
||||
): ManagedProfileGuardian {
|
||||
return {
|
||||
grantId: "g1",
|
||||
account: { id: "acct-1", username: "guardian", displayName: "Alex" },
|
||||
state: "ACTIVE",
|
||||
invitedAt: "2026-01-02T10:00:00.000Z",
|
||||
acceptedAt: "2026-01-02T11:00:00.000Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
import { AccessSection } from "@/components/settings/access-section";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
import {
|
||||
createManagedProfileErrorKey,
|
||||
displayNameIssue,
|
||||
FieldGuidance,
|
||||
} from "../managed-profile-create-form";
|
||||
import {
|
||||
activeGuardianCount,
|
||||
deleteProfileBody,
|
||||
managedProfilesOf,
|
||||
} from "../managed-profile-card";
|
||||
import { guardianInviteErrorKey } from "../managed-profile-guardians";
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The section as the settings page actually mounts it: behind the gate. */
|
||||
function renderGatedSection(): string {
|
||||
return render(
|
||||
<RecordSettingsSectionGate section="access">
|
||||
<AccessSection />
|
||||
</RecordSettingsSectionGate>,
|
||||
);
|
||||
}
|
||||
|
||||
function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
|
||||
return {
|
||||
accountId: "p1",
|
||||
username: "managed-abc",
|
||||
displayName: "Managed record",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authRef.value = { accounts: [], active: null };
|
||||
// The caller is always an active Guardian of the profile: it is the only way
|
||||
// to have the roster at all.
|
||||
rosterRef.value = [guardian({ account: SELF })];
|
||||
deleteRef.value = idle();
|
||||
inviteRef.value = idle();
|
||||
removeGuardianRef.value = { ...idle(), variables: undefined };
|
||||
});
|
||||
|
||||
/** The signed-in account, as the roster names it. */
|
||||
const SELF = { id: "guardian", username: "guardian", displayName: "You" };
|
||||
|
||||
describe("the managed-profile card, in the shared-access section", () => {
|
||||
it("renders inside the section, after the invitation card", () => {
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-card"');
|
||||
// Reading order: the invitation is the first act, this is the second, and
|
||||
// the standing state of both follows. A card that drifted below the
|
||||
// activity feed would still contain every string this file asserts.
|
||||
expect(html.indexOf('data-slot="grant-invite-card"')).toBeLessThan(
|
||||
html.indexOf('data-slot="managed-profile-card"'),
|
||||
);
|
||||
expect(html.indexOf('data-slot="managed-profile-card"')).toBeLessThan(
|
||||
html.indexOf('data-slot="grants-given-card"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("says the record has no login and that the creator looks after it", () => {
|
||||
const html = renderGatedSection();
|
||||
// The two facts somebody is consenting to. A rewrite that keeps the form
|
||||
// and drops either sentence should fail here.
|
||||
expect(html).toContain("It has no login and no e-mail address");
|
||||
expect(html).toContain("you become its first guardian");
|
||||
});
|
||||
|
||||
it("collects the four fields the route accepts and nothing else", () => {
|
||||
const html = renderGatedSection();
|
||||
for (const slot of [
|
||||
"managed-profile-name",
|
||||
"managed-profile-dob",
|
||||
"managed-profile-locale",
|
||||
"managed-profile-create-submit",
|
||||
]) {
|
||||
expect(html, slot).toContain(`data-slot="${slot}"`);
|
||||
}
|
||||
// The timezone control is the shared picker, which labels itself.
|
||||
expect(html).toContain('id="managed-profile-timezone"');
|
||||
// Six languages offered, which is the route's enum exactly.
|
||||
const options =
|
||||
/<select[^>]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/
|
||||
.exec(html)?.[0]
|
||||
.match(/<option/g) ?? [];
|
||||
expect(options).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("lists the profiles this account looks after, and only those", () => {
|
||||
authRef.value = {
|
||||
accounts: [
|
||||
entry(),
|
||||
entry({
|
||||
accountId: "a1",
|
||||
username: "housemate",
|
||||
displayName: "Jo",
|
||||
recordKind: "shared",
|
||||
}),
|
||||
],
|
||||
active: null,
|
||||
};
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-managed-profile-id="p1"');
|
||||
// An adult who shared their own record is not a profile anybody looks
|
||||
// after, and offering a delete control for one would be the same mistake
|
||||
// one level up.
|
||||
expect(html).not.toContain('data-managed-profile-id="a1"');
|
||||
expect(html.match(/data-slot="managed-profile-row"/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("who can reach the section at all", () => {
|
||||
it("renders for an unswitched owner", () => {
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-card"');
|
||||
expect(html).not.toContain("shared-record-settings-unavailable-title");
|
||||
});
|
||||
|
||||
it("does not render for a Guardian inside the profile they look after", () => {
|
||||
// Grant management is the one surface a delegate must have no reach into,
|
||||
// and a Guardian acting AS the profile is a delegate for this purpose.
|
||||
authRef.value = { accounts: [entry()], active: entry() };
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("shared-record-settings-unavailable-title");
|
||||
expect(html).not.toContain('data-slot="managed-profile-card"');
|
||||
expect(html).not.toContain('data-slot="grant-invite-card"');
|
||||
});
|
||||
|
||||
it("does not render for an adult delegate holding MANAGE", () => {
|
||||
const adult = entry({
|
||||
accountId: "a1",
|
||||
username: "housemate",
|
||||
displayName: "Jo",
|
||||
recordKind: "shared",
|
||||
});
|
||||
authRef.value = { accounts: [adult], active: adult };
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("shared-record-settings-unavailable-title");
|
||||
expect(html).not.toContain('data-slot="managed-profile-card"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("how a field says what is wrong with it", () => {
|
||||
/**
|
||||
* The form's invalid states are not reachable from a static render — there
|
||||
* is no click, so nothing is ever invalid — which is why the two nodes were
|
||||
* pulled out into a pure component. These legs are the whole of the claim
|
||||
* the HIGH finding was about: a refusal is content and it is announced.
|
||||
*/
|
||||
it("renders the hint as muted meta and nothing else when the field is fine", () => {
|
||||
const html = render(
|
||||
<FieldGuidance
|
||||
slot="f"
|
||||
hintId="f-hint"
|
||||
hint="Up to 80 characters."
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="f-hint"');
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
expect(html).not.toContain('data-slot="f-error"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("announces the refusal, in content contrast, on its own node", () => {
|
||||
const html = render(
|
||||
<FieldGuidance
|
||||
slot="f"
|
||||
hintId="f-hint"
|
||||
hint="Up to 80 characters."
|
||||
error="That name is longer than 80 characters."
|
||||
/>,
|
||||
);
|
||||
const alert = /<p[^>]*data-slot="f-error"[^>]*>/.exec(html)?.[0];
|
||||
expect(alert).toBeDefined();
|
||||
// Announced, because it MOUNTS with the role rather than a class being
|
||||
// swapped onto a node that was already there.
|
||||
expect(alert).toContain('role="alert"');
|
||||
// Content, not meta. UI-STANDARDS §3, and the same reasoning
|
||||
// `grant-action-error.tsx` spells out: a refusal rendered as muted is a
|
||||
// refusal nobody reads.
|
||||
expect(alert).toContain("text-destructive");
|
||||
expect(alert).not.toContain("text-muted-foreground");
|
||||
// And the hint survives beside it rather than being overwritten, so the
|
||||
// bound the person is being held to is still on screen.
|
||||
expect(html).toContain('data-slot="f-hint"');
|
||||
expect(html).toContain("Up to 80 characters.");
|
||||
});
|
||||
|
||||
it("renders the error alone for a control that brings its own hint", () => {
|
||||
// The timezone picker labels itself; the form owes only the refusal, and
|
||||
// that refusal is the only explanation for a submit button that will not
|
||||
// press.
|
||||
const html = render(
|
||||
<FieldGuidance slot="tz" error="This browser does not know that zone." />,
|
||||
);
|
||||
expect(html).toContain('data-slot="tz-error"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain('data-slot="tz-hint"');
|
||||
});
|
||||
|
||||
it("keeps the standing hint muted on the rendered form", () => {
|
||||
// The other half, on the real component: the default state is guidance,
|
||||
// and guidance IS meta.
|
||||
authRef.value = { accounts: [], active: null };
|
||||
const html = renderGatedSection();
|
||||
const hint = /<p[^>]*data-slot="managed-profile-name-hint"[^>]*>/.exec(
|
||||
html,
|
||||
)?.[0];
|
||||
expect(hint).toBeDefined();
|
||||
expect(hint).toContain("text-muted-foreground");
|
||||
expect(html).not.toContain('data-slot="managed-profile-name-error"');
|
||||
expect(html).not.toContain('data-slot="managed-profile-timezone-error"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the form decides before it sends", () => {
|
||||
it("treats a whitespace-only name as empty, because the route does", () => {
|
||||
// The route trims and then requires 1..80. A client that did not trim
|
||||
// would offer to send a name the server reads as absent.
|
||||
expect(displayNameIssue(" ")).toBe("recordSharing.managed.nameRequired");
|
||||
expect(displayNameIssue("")).toBe("recordSharing.managed.nameRequired");
|
||||
expect(displayNameIssue(" Managed record ")).toBeNull();
|
||||
});
|
||||
|
||||
it("holds the route's own 80-character bound", () => {
|
||||
expect(displayNameIssue("x".repeat(80))).toBeNull();
|
||||
expect(displayNameIssue("x".repeat(81))).toBe(
|
||||
"recordSharing.managed.nameTooLong",
|
||||
);
|
||||
// And trailing space does not push a valid name over the edge.
|
||||
expect(displayNameIssue(`${"x".repeat(80)} `)).toBeNull();
|
||||
});
|
||||
|
||||
it("routes the step-up gate to its own sentence, not to a failure", () => {
|
||||
// Every route in this family resolves `requireFreshMfa`, so for anybody
|
||||
// with a second factor this is the FIRST answer, not an error. Told as a
|
||||
// failure it sends somebody looking for a problem with what they typed.
|
||||
expect(
|
||||
createManagedProfileErrorKey(
|
||||
new ApiError("refused", 401, { errorCode: "auth.stepup.required" }),
|
||||
),
|
||||
).toBe("recordSharing.managed.errorStepUp");
|
||||
expect(createManagedProfileErrorKey(new ApiError("refused", 422))).toBe(
|
||||
"recordSharing.managed.errorInvalid",
|
||||
);
|
||||
expect(createManagedProfileErrorKey(new ApiError("refused", 500))).toBe(
|
||||
"recordSharing.managed.errorFailed",
|
||||
);
|
||||
// Not an `ApiError` means no response came back at all.
|
||||
expect(createManagedProfileErrorKey(new TypeError("Failed to fetch"))).toBe(
|
||||
"recordSharing.managed.errorOffline",
|
||||
);
|
||||
// The four are distinct, so a resolver returning one key for everything
|
||||
// fails here rather than passing each row above by accident.
|
||||
expect(
|
||||
new Set(
|
||||
[401, 422, 500].map((s) =>
|
||||
createManagedProfileErrorKey(new ApiError("refused", s)),
|
||||
),
|
||||
).size,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps a refused creation's fields, and clears them only on success", () => {
|
||||
// A static render cannot press the button, so the claim is made about the
|
||||
// code path: every reset lives inside `onSuccess`. A reset that migrated
|
||||
// into `onError` would wipe a typed name on a failure the person is being
|
||||
// asked to retry.
|
||||
const source = readForm();
|
||||
const start = source.indexOf("onSuccess:");
|
||||
const split = source.indexOf("onError:", start);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(split).toBeGreaterThan(start);
|
||||
const onSuccess = source.slice(start, split);
|
||||
const onError = source.slice(split);
|
||||
expect(onSuccess).toContain("setCreated");
|
||||
expect(onError).toContain("createManagedProfileErrorKey");
|
||||
for (const reset of [
|
||||
'setDisplayName("")',
|
||||
'setDateOfBirth("")',
|
||||
"setLocale(actorLocale)",
|
||||
"setTimezone(browserTimezone())",
|
||||
]) {
|
||||
expect(onSuccess, reset).toContain(reset);
|
||||
expect(onError, reset).not.toContain(reset);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ending a managed profile", () => {
|
||||
it("offers a confirmed delete on the profile's own row", () => {
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-delete"');
|
||||
// The mobile tap floor UI-STANDARDS §11 asks of an inline action.
|
||||
expect(html).toMatch(
|
||||
/data-slot="managed-profile-delete"[^>]*class="[^"]*min-h-11/,
|
||||
);
|
||||
});
|
||||
|
||||
it("withholds the control until the roster has answered", () => {
|
||||
// The confirm copy states how many people lose the record, and that number
|
||||
// comes from the roster. A control that existed before the read would put
|
||||
// the sentence's presence at the mercy of which request landed first —
|
||||
// the defect the retention disclosure already paid for once.
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
rosterRef.value = "loading";
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-managed-profile-id="p1"');
|
||||
expect(html).not.toContain('data-slot="managed-profile-delete"');
|
||||
});
|
||||
|
||||
it("announces a refused deletion on the row it was refused for", () => {
|
||||
authRef.value = {
|
||||
accounts: [entry(), entry({ accountId: "p2", displayName: "Second" })],
|
||||
active: null,
|
||||
};
|
||||
deleteRef.value = failed(
|
||||
new ApiError("refused", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
}),
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
|
||||
expect(html).toContain('data-slot="grant-action-error"');
|
||||
expect(html).toContain('data-grant-error-for="p1"');
|
||||
// Not painted onto the profile that did not fail.
|
||||
expect(html).not.toContain('data-grant-error-for="p2"');
|
||||
expect(html.match(/data-slot="grant-action-error"/g)).toHaveLength(1);
|
||||
// Specific, not the generic "could not be deleted".
|
||||
expect(html).toContain("That profile is not there any more");
|
||||
// Retryable, and the row keeps its control.
|
||||
expect(html).toContain('data-slot="grant-action-retry"');
|
||||
expect(html).toContain('data-slot="managed-profile-delete"');
|
||||
});
|
||||
|
||||
it("shows nothing at all while the deletion is idle", () => {
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
expect(renderGatedSection()).not.toContain(
|
||||
'data-slot="grant-action-error"',
|
||||
);
|
||||
});
|
||||
|
||||
it("names the step-up gate rather than reporting a failure", () => {
|
||||
// The likeliest refusal of all, and the one that read as a defect. Both
|
||||
// destructive acts sit behind a `ConfirmButton`, so the request that
|
||||
// reaches the server is the second click — minutes after the panel was
|
||||
// opened, and easily outside the five-minute window.
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
deleteRef.value = failed(
|
||||
new ApiError("refused", 401, { errorCode: "auth.stepup.required" }),
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("Confirm your second factor");
|
||||
expect(html).not.toContain("The profile could not be deleted");
|
||||
});
|
||||
|
||||
it("tells a guardian with no second factor where to get one", () => {
|
||||
// `requireFreshMfa` refuses an unenrolled account with its own code, and
|
||||
// "confirm your second factor" to somebody who has none is a dead end
|
||||
// offered by the surface that owns the way out of it.
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
deleteRef.value = failed(
|
||||
new ApiError("refused", 401, {
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
}),
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("does not have one yet");
|
||||
expect(html).toContain("Security");
|
||||
});
|
||||
|
||||
it("says a failed roster read failed, rather than looking like a slow one", () => {
|
||||
// Without this branch a 500 renders exactly as a still-loading roster —
|
||||
// no panel, no delete control — and the person waits for something that is
|
||||
// never coming.
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
rosterRef.value = "error";
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain("Could not load who looks after this record");
|
||||
// And the way back is reachable, which is what UI-STANDARDS §6 asks of an
|
||||
// error state.
|
||||
expect(html).toContain('data-slot="query-error-retry"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the confirmation says before it deletes", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>): string =>
|
||||
params ? `${key}(${JSON.stringify(params)})` : key;
|
||||
const tCount = (
|
||||
base: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
): string => `${base}:${count}(${JSON.stringify(params)})`;
|
||||
|
||||
it("names what goes, how many people lose it, and the way out", () => {
|
||||
const body = deleteProfileBody({
|
||||
t,
|
||||
tCount,
|
||||
name: "Managed record",
|
||||
activeGuardians: 2,
|
||||
});
|
||||
expect(body).toContain(
|
||||
'recordSharing.managed.deleteBody({"name":"Managed record"})',
|
||||
);
|
||||
expect(body).toContain("recordSharing.managed.deleteGuardians:2");
|
||||
// The delete route emits no last-Guardian refusal, because deleting IS the
|
||||
// way out of the floor. The copy says so rather than duplicating a refusal
|
||||
// this route never sends.
|
||||
expect(body).toContain("recordSharing.managed.deleteFloorNote");
|
||||
});
|
||||
|
||||
it("counts the guardians who actually hold the record", () => {
|
||||
// A pending invitation confers nothing, and the server counts the same
|
||||
// way. Counting it would tell somebody two people lose the record when
|
||||
// one does.
|
||||
expect(
|
||||
activeGuardianCount([
|
||||
guardian(),
|
||||
guardian({ grantId: "g2", state: "PENDING", acceptedAt: null }),
|
||||
]),
|
||||
).toBe(1);
|
||||
expect(activeGuardianCount([])).toBe(0);
|
||||
expect(activeGuardianCount(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("managing the guardians of a profile", () => {
|
||||
const OTHER = { id: "other", username: "housemate", displayName: "Jo" };
|
||||
|
||||
beforeEach(() => {
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
});
|
||||
|
||||
it("renders one row per guardian and says which of them is you", () => {
|
||||
rosterRef.value = [
|
||||
guardian({ account: SELF }),
|
||||
guardian({ grantId: "g2", account: OTHER }),
|
||||
];
|
||||
const html = renderGatedSection();
|
||||
expect(html.match(/data-slot="managed-profile-guardian"/g)).toHaveLength(2);
|
||||
expect(html).toContain('data-guardian-grant-id="g2"');
|
||||
expect(html).toContain("(you)");
|
||||
});
|
||||
|
||||
it("states the floor before the act when nobody else has accepted", () => {
|
||||
// The whole point of the roster read. Discovering this as a 409 after
|
||||
// inviting somebody is the state the release goal asks us to end.
|
||||
rosterRef.value = [
|
||||
guardian({ account: SELF }),
|
||||
guardian({
|
||||
grantId: "g2",
|
||||
account: OTHER,
|
||||
state: "PENDING",
|
||||
acceptedAt: null,
|
||||
}),
|
||||
];
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-sole-guardian"');
|
||||
expect(html).toContain("only person looking after");
|
||||
// And it names all three ways forward rather than only refusing.
|
||||
expect(html).toContain("wait for them to accept");
|
||||
expect(html).toContain("Records I can open");
|
||||
expect(html).toContain("delete the profile");
|
||||
});
|
||||
|
||||
it("stops stating the floor once a second guardian has accepted", () => {
|
||||
rosterRef.value = [
|
||||
guardian({ account: SELF }),
|
||||
guardian({ grantId: "g2", account: OTHER }),
|
||||
];
|
||||
expect(renderGatedSection()).not.toContain(
|
||||
'data-slot="managed-profile-sole-guardian"',
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no control to end one's own access, and says where that lives", () => {
|
||||
// `revokeManagedProfileGuardian` refuses a target grant whose grantee is
|
||||
// the caller, and the route answers the same 404 an unknown profile gets.
|
||||
// A self-removal button would be one that always fails.
|
||||
rosterRef.value = [guardian({ account: SELF })];
|
||||
const html = renderGatedSection();
|
||||
expect(html).not.toContain('data-slot="managed-profile-guardian-remove"');
|
||||
expect(html).toContain('data-slot="managed-profile-sole-guardian"');
|
||||
});
|
||||
|
||||
it("offers a confirmed removal on another guardian's row", () => {
|
||||
rosterRef.value = [
|
||||
guardian({ account: SELF }),
|
||||
guardian({ grantId: "g2", account: OTHER }),
|
||||
];
|
||||
const html = renderGatedSection();
|
||||
expect(
|
||||
html.match(/data-slot="managed-profile-guardian-remove"/g),
|
||||
).toHaveLength(1);
|
||||
expect(html).toMatch(
|
||||
/data-slot="managed-profile-guardian-remove"[^>]*class="[^"]*min-h-11/,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers to withdraw an invitation that has not been accepted", () => {
|
||||
// A pending invitation confers nothing, so withdrawing it can never
|
||||
// strand the record and the control is not withheld.
|
||||
rosterRef.value = [
|
||||
guardian({ account: SELF }),
|
||||
guardian({
|
||||
grantId: "g2",
|
||||
account: OTHER,
|
||||
state: "PENDING",
|
||||
acceptedAt: null,
|
||||
}),
|
||||
];
|
||||
const html = renderGatedSection();
|
||||
expect(
|
||||
html.match(/data-slot="managed-profile-guardian-remove"/g),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says an invitation only counts once it is accepted", () => {
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-pending-note"');
|
||||
expect(html).toContain("counts only once they accept");
|
||||
// In document order the sentence sits ahead of the control it qualifies.
|
||||
expect(
|
||||
html.indexOf('data-slot="managed-profile-pending-note"'),
|
||||
).toBeLessThan(html.indexOf('data-slot="managed-profile-guardian-submit"'));
|
||||
});
|
||||
|
||||
it("announces a refused removal on the guardian row it belongs to", () => {
|
||||
rosterRef.value = [
|
||||
guardian({ account: SELF }),
|
||||
guardian({ grantId: "g2", account: OTHER }),
|
||||
guardian({ grantId: "g3", account: { ...OTHER, id: "third" } }),
|
||||
];
|
||||
removeGuardianRef.value = {
|
||||
...idle(),
|
||||
isError: true,
|
||||
error: new ApiError("refused", 409, {
|
||||
errorCode: "managed_profile.guardian.required",
|
||||
}),
|
||||
variables: { grantId: "g2" },
|
||||
};
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-grant-error-for="g2"');
|
||||
expect(html).not.toContain('data-grant-error-for="g3"');
|
||||
// The floor, still handled as a refusal even though the panel states it
|
||||
// in advance: the roster can be stale by the time the click lands.
|
||||
expect(html).toContain("Add another guardian first");
|
||||
});
|
||||
|
||||
it("announces a refused invitation on the form, without clearing it", () => {
|
||||
inviteRef.value = failed(
|
||||
new ApiError("refused", 409, {
|
||||
errorCode: "managed_profile.guardian.duplicate",
|
||||
}),
|
||||
"ignored",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-guardian-error"');
|
||||
expect(html).toContain("already has access to this record");
|
||||
expect(html).toContain('data-slot="managed-profile-guardian-identifier"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a refused guardian invitation says", () => {
|
||||
const cases: Array<{ err: unknown; key: string; why: string }> = [
|
||||
{
|
||||
err: new ApiError("refused", 409, {
|
||||
errorCode: "managed_profile.guardian.duplicate",
|
||||
}),
|
||||
key: "recordSharing.managed.guardianErrorDuplicate",
|
||||
why: "that account already has access — which is also what inviting yourself answers, because the grantor is the profile and never the caller",
|
||||
},
|
||||
{
|
||||
err: new ApiError("refused", 422, {
|
||||
errorCode: "managed_profile.guardian.managed_invitee",
|
||||
}),
|
||||
key: "recordSharing.managed.guardianErrorManagedInvitee",
|
||||
why: "a managed profile cannot look after another record",
|
||||
},
|
||||
{
|
||||
err: new ApiError("refused", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
}),
|
||||
key: "recordSharing.actionError.profileNotFound",
|
||||
why: "a 404 WITH a code — not the same as an unknown identifier",
|
||||
},
|
||||
{
|
||||
err: new ApiError("refused", 404),
|
||||
key: "recordSharing.managed.guardianErrorNoAccount",
|
||||
why: "the codeless 404: no account carries that name or e-mail",
|
||||
},
|
||||
{
|
||||
err: new ApiError("refused", 429),
|
||||
key: "recordSharing.managed.guardianErrorRateLimit",
|
||||
why: "the other codeless arm, where waiting is the whole recovery",
|
||||
},
|
||||
{
|
||||
err: new ApiError("refused", 401, {
|
||||
errorCode: "auth.stepup.required",
|
||||
}),
|
||||
key: "recordSharing.managed.errorStepUp",
|
||||
why: "the step-up gate every route in this family carries",
|
||||
},
|
||||
{
|
||||
err: new ApiError("refused", 500),
|
||||
key: "recordSharing.managed.guardianErrorFailed",
|
||||
why: "an unclassified failure still says which act failed",
|
||||
},
|
||||
{
|
||||
err: new TypeError("Failed to fetch"),
|
||||
key: "recordSharing.managed.errorOffline",
|
||||
why: "never reached the server, so nothing was invited",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { err, key, why } of cases) {
|
||||
it(why, () => {
|
||||
expect(guardianInviteErrorKey(err)).toBe(key);
|
||||
});
|
||||
}
|
||||
|
||||
it("tells the two 404s apart, which is the whole reason for the order", () => {
|
||||
// The route publishes a code on one 404 and not on the other. Branching on
|
||||
// status first would tell somebody to check a spelling when the real
|
||||
// answer is that they are no longer a Guardian of that profile.
|
||||
expect(
|
||||
guardianInviteErrorKey(
|
||||
new ApiError("refused", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
}),
|
||||
),
|
||||
).not.toBe(guardianInviteErrorKey(new ApiError("refused", 404)));
|
||||
// And all nine cases above resolve to distinct sentences rather than one:
|
||||
// the failure this table is written against is one message for everything.
|
||||
expect(
|
||||
new Set(cases.map(({ err }) => guardianInviteErrorKey(err))).size,
|
||||
).toBe(cases.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which accounts count as profiles looked after", () => {
|
||||
it("keeps the managed entries and drops the rest", () => {
|
||||
const managed = entry();
|
||||
const shared = entry({ accountId: "a1", recordKind: "shared" });
|
||||
expect(managedProfilesOf([managed, shared])).toEqual([managed]);
|
||||
expect(managedProfilesOf(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function readForm(): string {
|
||||
return readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/settings/access/managed-profile-create-form.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
@@ -35,15 +35,50 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* not an `ApiError` never reached a response at all, which is what makes the
|
||||
* offline test a type check rather than a status check.
|
||||
*/
|
||||
export type GrantAction = "accept" | "revoke" | "renounce";
|
||||
export type GrantAction =
|
||||
"accept" | "revoke" | "renounce" | "deleteProfile" | "removeGuardian";
|
||||
|
||||
/** The generic sentence for each act, when nothing more specific applies. */
|
||||
const FAILED_KEY: Record<GrantAction, string> = {
|
||||
accept: "recordSharing.actionError.acceptFailed",
|
||||
revoke: "recordSharing.actionError.revokeFailed",
|
||||
renounce: "recordSharing.actionError.renounceFailed",
|
||||
deleteProfile: "recordSharing.actionError.deleteProfileFailed",
|
||||
removeGuardian: "recordSharing.actionError.removeGuardianFailed",
|
||||
};
|
||||
|
||||
/**
|
||||
* The acts whose routes resolve `requireFreshMfa`, and therefore the only ones
|
||||
* where a 401 is a gate rather than a lapsed session.
|
||||
*
|
||||
* A set rather than a condition, so adding a third step-up-gated act is one
|
||||
* line here instead of a boolean somebody has to re-derive.
|
||||
*/
|
||||
const STEP_UP_ACTIONS = new Set<GrantAction>([
|
||||
"deleteProfile",
|
||||
"removeGuardian",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Which of the two step-up sentences a 401 from the managed-profile family
|
||||
* earns.
|
||||
*
|
||||
* `requireFreshMfa` refuses for two different reasons and says which: a stale
|
||||
* proof (`auth.stepup.required`), which the person clears by proving their
|
||||
* factor, and NO factor at all (`auth.stepup.mfa_not_enrolled`), which they
|
||||
* cannot clear that way. Collapsing the two tells somebody with no second
|
||||
* factor to confirm one — a dead end offered by the very surface that owns
|
||||
* the route out of it.
|
||||
*
|
||||
* Exported and shared by every caller in the family (creation, the guardian
|
||||
* invitation, and the two acts below) so the two sentences cannot come apart.
|
||||
*/
|
||||
export function stepUpErrorKey(err: ApiError): string {
|
||||
return err.meta?.errorCode === "auth.stepup.mfa_not_enrolled"
|
||||
? "recordSharing.managed.errorMfaRequired"
|
||||
: "recordSharing.managed.errorStepUp";
|
||||
}
|
||||
|
||||
/**
|
||||
* The message key for a refused grant action.
|
||||
*
|
||||
@@ -68,6 +103,15 @@ export function grantActionErrorKey(err: unknown, action: GrantAction): string {
|
||||
// it is the same sentence.
|
||||
case "managed_profile.guardian.required":
|
||||
return "recordSharing.actionError.lastGuardian";
|
||||
// v1.37.0 — the only refusal the delete route classifies, and the only one
|
||||
// the guardian-removal route classifies besides the floor. It covers three
|
||||
// situations the server deliberately does not tell apart (no such profile,
|
||||
// not a managed one, the caller is not its Guardian), so the sentence
|
||||
// promises no diagnosis: it says the profile is not reachable and leaves
|
||||
// it there. Anything the route does NOT classify becomes the generic 500
|
||||
// envelope and must not borrow this sentence.
|
||||
case "managed_profile.not_found":
|
||||
return "recordSharing.actionError.profileNotFound";
|
||||
case "sharing.accept.expired":
|
||||
return "recordSharing.actionError.acceptExpired";
|
||||
case "sharing.accept.revoked":
|
||||
@@ -90,6 +134,24 @@ export function grantActionErrorKey(err: unknown, action: GrantAction): string {
|
||||
// route rate-limits without an error code, hence the status.
|
||||
if (err.status === 429) return "recordSharing.actionError.rateLimited";
|
||||
|
||||
// v1.37.0 — the step-up gate, on the two acts that carry one.
|
||||
//
|
||||
// Scoped by action rather than applied to every 401, and that is the whole
|
||||
// care in it. `DELETE /api/managed-profiles/{id}` and
|
||||
// `DELETE .../guardians/{grantId}` resolve `requireFreshMfa`, so a 401 there
|
||||
// is a gate somebody can clear. Accept, revoke and renounce resolve plain
|
||||
// `requireAuth`, where a 401 means the session ended — and telling that
|
||||
// person to confirm a second factor sends them looking for the wrong thing.
|
||||
//
|
||||
// These two are also the likeliest to hit it: both sit behind a
|
||||
// `ConfirmButton`, so the click that reaches the server is the second one,
|
||||
// minutes after the panel was opened, and the five-minute window is easy to
|
||||
// be outside of by then. Before this arm they fell through to "the profile
|
||||
// could not be deleted", which describes nothing and offers nothing.
|
||||
if (err.status === 401 && STEP_UP_ACTIONS.has(action)) {
|
||||
return stepUpErrorKey(err);
|
||||
}
|
||||
|
||||
return FAILED_KEY[action];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { UserRoundCog } from "lucide-react";
|
||||
|
||||
import {
|
||||
GrantActionAlert,
|
||||
grantActionErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
|
||||
import { ManagedProfileGuardians } from "@/components/settings/access/managed-profile-guardians";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDeleteManagedProfile,
|
||||
useManagedProfileGuardians,
|
||||
type ManagedProfileGuardian,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.37.0 — the records this account looks after.
|
||||
*
|
||||
* ## Why the list is not a read of its own
|
||||
*
|
||||
* There is no `GET /api/managed-profiles`, and there does not need to be. A
|
||||
* Guardian's relationship to a managed profile IS a MANAGE grant with the
|
||||
* profile as grantor, so the profiles somebody looks after are already on
|
||||
* `GET /api/auth/me` as the `managed` entries of `accountAccess.accounts` —
|
||||
* the same list that paints the switcher. Reading them from anywhere else
|
||||
* would be a second answer to a question the account payload already answers,
|
||||
* and the two would disagree the first time a grant lapsed.
|
||||
*
|
||||
* It is also what makes the creation contract simple to state: the new profile
|
||||
* appears here, in the switcher and in the banner from ONE invalidation, and
|
||||
* if the account payload is not refreshed it appears in none of them.
|
||||
*
|
||||
* ## Why this card, in this section
|
||||
*
|
||||
* The section's docblock states the rule — one concept, one section — and this
|
||||
* is the same concept from the third side: who can open a record. Two of the
|
||||
* cards here are about another adult opening yours or you opening theirs; this
|
||||
* one is about a record that exists only because somebody looks after it. A
|
||||
* settings area of its own would split "who may open which record" across two
|
||||
* pages, which is the split that rule exists to prevent.
|
||||
*
|
||||
* It sits directly after the invitation card because both are acts, and this
|
||||
* one creates the record the other two then talk about.
|
||||
*
|
||||
* ## One deletion mutation for the whole card
|
||||
*
|
||||
* Hoisted here rather than instantiated per row, so `variables` is what says
|
||||
* WHICH profile was refused — the same shape `grants-given-card.tsx` uses, and
|
||||
* the reason a static render can assert a failure state at all. The failure is
|
||||
* read off the mutation rather than copied into a `useState`, so an unrelated
|
||||
* invalidation cannot clear it and a second failure cannot paint one row's
|
||||
* message onto another's.
|
||||
*/
|
||||
export function ManagedProfileCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const remove = useDeleteManagedProfile();
|
||||
const profiles = managedProfilesOf(user?.accountAccess?.accounts);
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="managed-profile-card">
|
||||
<SettingsCardHeader
|
||||
icon={UserRoundCog}
|
||||
title={t("recordSharing.managed.title")}
|
||||
description={t("recordSharing.managed.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Content, not meta: this is what somebody is consenting to take on,
|
||||
and UI-STANDARDS §3 reserves muted for the incidental. */}
|
||||
<p data-slot="managed-profile-explainer" className="text-sm">
|
||||
{t("recordSharing.managed.explainer")}
|
||||
</p>
|
||||
|
||||
{profiles.length > 0 && (
|
||||
<ul data-slot="managed-profile-list" className="divide-y">
|
||||
{profiles.map((profile) => (
|
||||
<ManagedProfileRow
|
||||
key={profile.accountId}
|
||||
profile={profile}
|
||||
remove={remove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ManagedProfileCreateForm />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile: who it is, and the way to end it.
|
||||
*
|
||||
* The roster read lives here rather than in the card because it is per
|
||||
* profile, and because the delete control depends on it: the confirm copy
|
||||
* states how many people lose the record, and that number has to come from the
|
||||
* read that produced the row rather than from a local counter. The control is
|
||||
* withheld until the roster has answered, for the same reason `grants-given-
|
||||
* card.tsx` withholds its revoke until the retention window is in hand — a
|
||||
* disclosure that appears or not depending on which request landed first is
|
||||
* the defect that release already paid for once.
|
||||
*
|
||||
* A roster that 404s is a profile this account is no longer a Guardian of (the
|
||||
* account payload can be that much stale), and it leaves the row visible with
|
||||
* no destructive control, which is the right way round.
|
||||
*/
|
||||
function ManagedProfileRow({
|
||||
profile,
|
||||
remove,
|
||||
}: {
|
||||
profile: AccountAccessEntry;
|
||||
remove: ReturnType<typeof useDeleteManagedProfile>;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const guardians = useManagedProfileGuardians(profile.accountId);
|
||||
const name = accountLabel(profile);
|
||||
const failed = remove.isError && remove.variables === profile.accountId;
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="managed-profile-row"
|
||||
data-managed-profile-id={profile.accountId}
|
||||
className="space-y-2 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
{/* UI-STANDARDS §11 inline action row. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* A person's name is user data and stays content. */}
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{t("recordSharing.lookingAfter.kindManaged")}
|
||||
</p>
|
||||
</div>
|
||||
{guardians.data && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<ConfirmButton
|
||||
slot="managed-profile-delete"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
label={t("recordSharing.managed.delete")}
|
||||
title={t("recordSharing.managed.deleteTitle", { name })}
|
||||
body={deleteProfileBody({
|
||||
t,
|
||||
tCount,
|
||||
name,
|
||||
activeGuardians: activeGuardianCount(guardians.data),
|
||||
})}
|
||||
confirmLabel={t("recordSharing.managed.deleteConfirm")}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate(profile.accountId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{failed && (
|
||||
<GrantActionAlert
|
||||
grantId={profile.accountId}
|
||||
message={t(grantActionErrorKey(remove.error, "deleteProfile"))}
|
||||
retrying={remove.isPending}
|
||||
onRetry={() => remove.mutate(profile.accountId)}
|
||||
/>
|
||||
)}
|
||||
{/* A failed roster read is not an absent roster.
|
||||
Without this branch a 500 renders exactly as a still-loading one —
|
||||
the row with no guardian panel and no delete control — and the person
|
||||
waits for something that is never coming. UI-STANDARDS §6: a read
|
||||
that failed says so and offers the way back. It is deliberately NOT
|
||||
the same as the 404 arm: a 404 means this account is no longer a
|
||||
Guardian of that profile, which is an answer, and the row correctly
|
||||
shows no destructive control for it. */}
|
||||
{guardians.isError && (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.managed.rosterLoadError")}
|
||||
onRetry={() => void guardians.refetch()}
|
||||
/>
|
||||
)}
|
||||
{guardians.data && (
|
||||
<ManagedProfileGuardians
|
||||
profileId={profile.accountId}
|
||||
profileName={name}
|
||||
roster={guardians.data}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed entries of the account payload, in the order the server sent
|
||||
* them.
|
||||
*
|
||||
* Exported and pure so the filter can be pinned: `recordKind` is server-
|
||||
* resolved presentation metadata, and a card that showed every shared record
|
||||
* here would offer a delete control for an adult's own account.
|
||||
*/
|
||||
export function managedProfilesOf(
|
||||
accounts: AccountAccessEntry[] | undefined,
|
||||
): AccountAccessEntry[] {
|
||||
return (accounts ?? []).filter((entry) => entry.recordKind === "managed");
|
||||
}
|
||||
|
||||
/**
|
||||
* How many people actually hold this record right now.
|
||||
*
|
||||
* ACTIVE only, which is the same rule `activeGuardianWhere` enforces
|
||||
* server-side: an invitation nobody has accepted confers nothing, and counting
|
||||
* it would tell somebody two people lose the record when one does.
|
||||
*/
|
||||
export function activeGuardianCount(
|
||||
roster: ManagedProfileGuardian[] | undefined,
|
||||
): number {
|
||||
return (roster ?? []).filter((guardian) => guardian.state === "ACTIVE")
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* What deleting this profile does, as one paragraph.
|
||||
*
|
||||
* Exported and pure because the dialog it feeds only exists after a click and
|
||||
* an SSR render cannot see inside it — the same reason `revokeBody` is
|
||||
* exported from the grants card. The composition is the thing worth pinning.
|
||||
*
|
||||
* Three sentences, and the third is the one that is easy to leave out. The
|
||||
* delete route emits NO last-Guardian refusal, and that is not an oversight:
|
||||
* deleting the profile is the documented way out of the floor. A Guardian may
|
||||
* not strand a record with nobody looking after it, but they may end the
|
||||
* record. Saying so here is better than duplicating a refusal this route never
|
||||
* sends.
|
||||
*/
|
||||
export function deleteProfileBody({
|
||||
t,
|
||||
tCount,
|
||||
name,
|
||||
activeGuardians,
|
||||
}: {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
tCount: (
|
||||
baseKey: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
name: string;
|
||||
/** Taken from the roster read, never from a local counter. */
|
||||
activeGuardians: number;
|
||||
}): string {
|
||||
return [
|
||||
t("recordSharing.managed.deleteBody", { name }),
|
||||
tCount("recordSharing.managed.deleteGuardians", activeGuardians, {
|
||||
count: activeGuardians,
|
||||
}),
|
||||
t("recordSharing.managed.deleteFloorNote"),
|
||||
].join(" ");
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCreateManagedProfile } from "@/lib/queries/use-managed-profiles";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
detectBrowserTimezone,
|
||||
isValidTimezone,
|
||||
} from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.37.0 — creating a record that has no login of its own.
|
||||
*
|
||||
* ## Why the form is exactly four fields
|
||||
*
|
||||
* `POST /api/managed-profiles` parses with a `.strict()` schema. A strict
|
||||
* schema refuses an extra key outright, so a form that helpfully posted an
|
||||
* empty `heightCm` — or a `gender` somebody thought would be useful — is
|
||||
* refused with a validation error about a field the person never filled in.
|
||||
* The four fields below are the schema, and the submit handler names them one
|
||||
* by one rather than spreading the form state, so a fifth control cannot reach
|
||||
* the wire by accident.
|
||||
*
|
||||
* ## The date of birth is genuinely optional
|
||||
*
|
||||
* Not "optional but we will guess". A managed profile is frequently a child,
|
||||
* and a child's age changes what the product says about a reading — so the
|
||||
* temptation to synthesise a date from a birth year, or from an age, is real
|
||||
* and is refused here as it is in the route. An absent date of birth stays
|
||||
* absent, and everything downstream reads it as absent rather than as
|
||||
* "1 January of some year".
|
||||
*
|
||||
* ## Whose locale, and whose timezone
|
||||
*
|
||||
* The profile's locale is a property OF THE RECORD: it decides the language of
|
||||
* content generated for it. The chrome around this form stays in the actor's
|
||||
* language, which is a decision made elsewhere and is not re-litigated in the
|
||||
* copy here. The default is the actor's own language, because a record created
|
||||
* by a German speaker for a person in their household is overwhelmingly likely
|
||||
* to want German, and the control is right there for the case where it is not.
|
||||
*
|
||||
* The timezone defaults to the browser's, for the same reason and with less
|
||||
* ambiguity: the profile lives where the person creating it lives, until told
|
||||
* otherwise.
|
||||
*
|
||||
* ## The step-up is the expected first answer
|
||||
*
|
||||
* Every route in this family resolves `requireFreshMfa`. For anybody with a
|
||||
* second factor who has not proved it in the last few minutes, the FIRST
|
||||
* response to this form is a 401 carrying `auth.stepup.required` — not an
|
||||
* error, a gate. It gets its own sentence naming the next step, exactly as the
|
||||
* invitation form does, rather than the generic "could not be created", which
|
||||
* would send somebody looking for a problem with what they typed.
|
||||
*/
|
||||
export function ManagedProfileCreateForm() {
|
||||
const { t, locale: actorLocale } = useTranslations();
|
||||
const create = useCreateManagedProfile();
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [locale, setLocale] = useState<Locale>(actorLocale);
|
||||
const [timezone, setTimezone] = useState(() => browserTimezone());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
|
||||
const trimmedName = displayName.trim();
|
||||
const nameIssue = displayNameIssue(displayName);
|
||||
const timezoneIssue = isValidTimezone(timezone)
|
||||
? null
|
||||
: "recordSharing.managed.timezoneInvalid";
|
||||
const blocked = nameIssue !== null || timezoneIssue !== null;
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (blocked || create.isPending) return;
|
||||
setError(null);
|
||||
setCreated(null);
|
||||
create.mutate(
|
||||
{
|
||||
displayName: trimmedName,
|
||||
// Empty means absent. `null` is sent rather than the key being
|
||||
// omitted, so "this record has no date of birth" is something the
|
||||
// client says out loud.
|
||||
dateOfBirth: dateOfBirth.length > 0 ? dateOfBirth : null,
|
||||
locale,
|
||||
timezone,
|
||||
},
|
||||
{
|
||||
onSuccess: (profile) => {
|
||||
setDisplayName("");
|
||||
setDateOfBirth("");
|
||||
setLocale(actorLocale);
|
||||
setTimezone(browserTimezone());
|
||||
setCreated(profile.displayName);
|
||||
},
|
||||
// Nothing is cleared here, on purpose: a refused creation is one the
|
||||
// person is being asked to retry, and a form that emptied itself
|
||||
// would make them type it all again to find out whether the second
|
||||
// attempt fails the same way.
|
||||
onError: (err) => setError(t(createManagedProfileErrorKey(err))),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-create-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-name">
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="managed-profile-name"
|
||||
data-slot="managed-profile-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("recordSharing.managed.namePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={
|
||||
displayName.length > 0 && nameIssue !== null ? "true" : undefined
|
||||
}
|
||||
aria-describedby="managed-profile-name-hint"
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-name"
|
||||
hintId="managed-profile-name-hint"
|
||||
hint={t("recordSharing.managed.nameHint")}
|
||||
// Only once somebody has typed. "Give the record a name" on an empty
|
||||
// field they have not reached yet is a telling-off, not guidance.
|
||||
error={
|
||||
displayName.length > 0 && nameIssue !== null ? t(nameIssue) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-dob">
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="managed-profile-dob"
|
||||
data-slot="managed-profile-dob"
|
||||
data-testid="managed-profile-dob-input"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
aria-describedby="managed-profile-dob-hint"
|
||||
/>
|
||||
<p
|
||||
id="managed-profile-dob-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.managed.dobHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-locale">
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-locale"
|
||||
data-slot="managed-profile-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.localeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id="managed-profile-timezone"
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
{/* The picker's own list comes from the engine, so the select branch
|
||||
cannot produce an unknown zone. Its free-text fallback (older
|
||||
engines without `Intl.supportedValuesOf`) can, and this sentence is
|
||||
then the ONLY explanation for a submit button that will not press.
|
||||
It rides beside the picker rather than through its `hint` prop,
|
||||
because that prop renders muted meta and a refusal rendered as meta
|
||||
is a refusal nobody reads. */}
|
||||
<FieldGuidance
|
||||
slot="managed-profile-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="managed-profile-create-submit"
|
||||
disabled={create.isPending || blocked}
|
||||
>
|
||||
{create.isPending
|
||||
? t("recordSharing.managed.creating")
|
||||
: t("recordSharing.managed.create")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-create-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{created && (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-created"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("recordSharing.managed.created", { name: created })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A field's standing hint, and its refusal when there is one.
|
||||
*
|
||||
* Two nodes, never one that swaps. The form used to render a single muted
|
||||
* `<p>` whose TEXT changed from hint to error, which is wrong twice over:
|
||||
*
|
||||
* * a refusal is content somebody has to act on, and UI-STANDARDS §3
|
||||
* reserves `text-muted-foreground` for meta. The same reasoning is written
|
||||
* out at `grant-action-error.tsx` — a refusal rendered as meta is a
|
||||
* refusal nobody reads;
|
||||
* * a node whose text changes in place is not announced. `role="alert"` is
|
||||
* reliable when the node MOUNTS, which is why the panel's own alert is a
|
||||
* separate element rather than a class on an existing one. Somebody who
|
||||
* cannot see the screen pressed a button and got nothing, again.
|
||||
*
|
||||
* The hint stays where it was, muted, and keeps the id `aria-describedby`
|
||||
* points at. `hint` is optional for the one caller whose control renders its
|
||||
* own (the timezone picker), which needs only the error half.
|
||||
*
|
||||
* Exported and pure because the form's error states are not reachable from a
|
||||
* static render — there is no click, so no field is ever invalid — and a
|
||||
* rendering nobody can test is a rendering that drifts back to muted.
|
||||
*/
|
||||
export function FieldGuidance({
|
||||
slot,
|
||||
hintId,
|
||||
hint,
|
||||
error,
|
||||
}: {
|
||||
/** `data-slot` stem: `<slot>-hint` and `<slot>-error`. */
|
||||
slot: string;
|
||||
hintId?: string;
|
||||
hint?: string;
|
||||
error: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{hint !== undefined && (
|
||||
<p
|
||||
id={hintId}
|
||||
data-slot={`${slot}-hint`}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error !== null && (
|
||||
<p
|
||||
data-slot={`${slot}-error`}
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The browser's zone, or the app default when the engine will not say. */
|
||||
function browserTimezone(): string {
|
||||
const detected = detectBrowserTimezone();
|
||||
return isValidTimezone(detected) ? detected : DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route's own bound on the display name, checked before sending.
|
||||
*
|
||||
* Exported and pure so the bound can be pinned without a click. The route
|
||||
* trims and then requires 1..80, so the client trims first too: a name of
|
||||
* three spaces is empty to the server and must read as empty here, rather
|
||||
* than as a valid entry that comes back refused.
|
||||
*/
|
||||
export function displayNameIssue(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return "recordSharing.managed.nameRequired";
|
||||
if (trimmed.length > 80) return "recordSharing.managed.nameTooLong";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a refused creation.
|
||||
*
|
||||
* Four arms and each names a different next step. The step-up arm is the one
|
||||
* that matters: it is not a failure, it is the gate every route in this family
|
||||
* carries, and it is the answer most people will get the first time they use
|
||||
* this form in a session.
|
||||
*
|
||||
* A 422 gets its own sentence rather than the field-level list the server
|
||||
* actually sends. The multi-issue envelope rides `details.issues`, and the
|
||||
* client's fetch wrapper keeps only `meta` — so the issues do not reach the
|
||||
* browser at all. The form validates the same bounds the route does before it
|
||||
* sends anything, which makes a 422 a disagreement between the two rather than
|
||||
* something the person typed; saying so is more honest than inventing a
|
||||
* field-level message the server did not send. Recorded as a finding in
|
||||
* `02-13.1-CONTRACT-FINDINGS.md`.
|
||||
*/
|
||||
export function createManagedProfileErrorKey(err: unknown): string {
|
||||
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
|
||||
// The step-up gate. `requireFreshMfa` throws `StepUpRequiredError`, which is
|
||||
// a 401 carrying a stable code, and the request was authenticated enough to
|
||||
// reach it — so this is never a lapsed session.
|
||||
if (err.status === 401) return stepUpErrorKey(err);
|
||||
if (err.status === 422) return "recordSharing.managed.errorInvalid";
|
||||
// Ten an hour, the same ceiling the guardian invitation carries. Waiting is
|
||||
// the whole recovery, so it must not read as a failure — and it carries no
|
||||
// `errorCode`, like every other rate-limit refusal in this family.
|
||||
if (err.status === 429) return "recordSharing.managed.errorRateLimit";
|
||||
return "recordSharing.managed.errorFailed";
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
GrantActionAlert,
|
||||
grantActionErrorKey,
|
||||
stepUpErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import { endOfDayIso } from "@/components/settings/access/grant-invite-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useInviteManagedProfileGuardian,
|
||||
useRemoveManagedProfileGuardian,
|
||||
type ManagedProfileGuardian,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.37.0 — who else looks after this record, and how that changes.
|
||||
*
|
||||
* ## The floor is stated, and still enforced
|
||||
*
|
||||
* A managed profile may never be left with nobody looking after it. The server
|
||||
* enforces that by refusing the removal (409
|
||||
* `managed_profile.guardian.required`) and by counting only ACCEPTED,
|
||||
* unexpired MANAGE grants — `activeGuardianWhere`. This panel says the same
|
||||
* thing BEFORE the click, from the roster it is already showing, because
|
||||
* discovering a floor as a refusal after inviting somebody is the state the
|
||||
* release goal asks us to end.
|
||||
*
|
||||
* The pre-emptive statement is a courtesy and not the enforcement. The roster
|
||||
* can be seconds stale, another Guardian can renounce between the render and
|
||||
* the click, and the server is the only authority — so the 409 arm stays wired
|
||||
* through `grant-action-error.tsx` regardless. A surface that hid the control
|
||||
* AND dropped the refusal handler would fail silently the one time it matters.
|
||||
*
|
||||
* ## Why nobody can end their own access here
|
||||
*
|
||||
* `revokeManagedProfileGuardian` refuses a target grant whose grantee is the
|
||||
* caller, and the route turns that into the same 404 an unknown profile gets.
|
||||
* So a self-removal control would be a button that always fails. The way for a
|
||||
* Guardian to step away is the renounce control on "What I can open", which is
|
||||
* the same act from the delegate's own side and already carries the
|
||||
* last-Guardian refusal. The card says so rather than offering a dead control
|
||||
* or leaving somebody to hunt for it.
|
||||
*
|
||||
* That also means the last-ACTIVE-Guardian case and the own-row case coincide:
|
||||
* to see this roster at all you must be an active Guardian of the profile, so
|
||||
* "one ACTIVE Guardian" always means "you". The sentence names all three ways
|
||||
* forward — invite somebody who accepts, hand your own access back, or delete
|
||||
* the profile.
|
||||
*
|
||||
* ## The pending truth
|
||||
*
|
||||
* `inviteManagedProfileGuardian` mints the grant PENDING, through the ordinary
|
||||
* invitation flow, and the invitee accepts it on their own shared-access panel.
|
||||
* Until they do, they do not count. A Guardian who invites somebody and then
|
||||
* tries to leave is still refused, and no wording in the product said so before
|
||||
* this.
|
||||
*/
|
||||
export function ManagedProfileGuardians({
|
||||
profileId,
|
||||
profileName,
|
||||
roster,
|
||||
}: {
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
/** The resolved roster. The caller withholds this panel until it arrives. */
|
||||
roster: ManagedProfileGuardian[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const invite = useInviteManagedProfileGuardian();
|
||||
const remove = useRemoveManagedProfileGuardian();
|
||||
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [expiresOn, setExpiresOn] = useState("");
|
||||
const [invited, setInvited] = useState<string | null>(null);
|
||||
|
||||
const timezone = isValidTimezone(user?.timezone ?? "")
|
||||
? (user?.timezone as string)
|
||||
: DEFAULT_TIMEZONE;
|
||||
const active = roster.filter((g) => g.state === "ACTIVE");
|
||||
const soleGuardian = active.length <= 1;
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (value.length === 0 || invite.isPending) return;
|
||||
setInvited(null);
|
||||
invite.mutate(
|
||||
{
|
||||
profileId,
|
||||
identifier: value,
|
||||
expiresAt: endOfDayIso(expiresOn, timezone),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIdentifier("");
|
||||
setExpiresOn("");
|
||||
setInvited(value);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-slot="managed-profile-guardians" className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("recordSharing.managed.guardiansTitle", { name: profileName })}
|
||||
</h3>
|
||||
|
||||
<ul data-slot="managed-profile-guardian-list" className="divide-y">
|
||||
{roster.map((guardian) => {
|
||||
const isSelf = guardian.account.id === user?.id;
|
||||
// A PENDING invitation can always be withdrawn: it confers nothing,
|
||||
// so ending it cannot strand the record. The floor only ever bites
|
||||
// on an ACTIVE row, and the only ACTIVE row that could be the last
|
||||
// one is the caller's own — which the route refuses anyway.
|
||||
const removable = !isSelf;
|
||||
const failed =
|
||||
remove.isError && remove.variables?.grantId === guardian.grantId;
|
||||
return (
|
||||
<li
|
||||
key={guardian.grantId}
|
||||
data-slot="managed-profile-guardian"
|
||||
data-guardian-grant-id={guardian.grantId}
|
||||
data-guardian-state={guardian.state}
|
||||
className="space-y-2 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
{/* UI-STANDARDS §11 inline action row. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{accountLabel(guardian.account)}
|
||||
{isSelf ? ` ${t("recordSharing.managed.guardianYou")}` : ""}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{guardian.state === "ACTIVE" && guardian.acceptedAt
|
||||
? t("recordSharing.managed.guardianSince", {
|
||||
date: fmt.dateTime(new Date(guardian.acceptedAt)),
|
||||
})
|
||||
: t("recordSharing.managed.guardianPending", {
|
||||
date: fmt.dateTime(new Date(guardian.invitedAt)),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{removable && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<ConfirmButton
|
||||
slot="managed-profile-guardian-remove"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
label={t("recordSharing.managed.guardianRemove")}
|
||||
title={t("recordSharing.managed.guardianRemoveTitle", {
|
||||
name: accountLabel(guardian.account),
|
||||
})}
|
||||
body={t(
|
||||
guardian.state === "PENDING"
|
||||
? "recordSharing.managed.guardianWithdrawBody"
|
||||
: "recordSharing.managed.guardianRemoveBody",
|
||||
{
|
||||
name: accountLabel(guardian.account),
|
||||
profile: profileName,
|
||||
},
|
||||
)}
|
||||
confirmLabel={t(
|
||||
"recordSharing.managed.guardianRemoveConfirm",
|
||||
)}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() =>
|
||||
remove.mutate({
|
||||
profileId,
|
||||
grantId: guardian.grantId,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{failed && (
|
||||
<GrantActionAlert
|
||||
grantId={guardian.grantId}
|
||||
message={t(
|
||||
grantActionErrorKey(remove.error, "removeGuardian"),
|
||||
)}
|
||||
retrying={remove.isPending}
|
||||
onRetry={() =>
|
||||
remove.mutate({ profileId, grantId: guardian.grantId })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{soleGuardian && (
|
||||
// Content, not meta: it is the reason a control somebody is looking
|
||||
// for is not on the screen.
|
||||
<p data-slot="managed-profile-sole-guardian" className="text-sm">
|
||||
{t("recordSharing.managed.soleGuardian", { name: profileName })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-guardian-invite"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-guardian-identifier-${profileId}`}>
|
||||
{t("recordSharing.managed.guardianInviteLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`managed-guardian-identifier-${profileId}`}
|
||||
data-slot="managed-profile-guardian-identifier"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder={t("recordSharing.invite.identifierPlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={255}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.invite.identifierHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-guardian-expires-${profileId}`}>
|
||||
{t("recordSharing.invite.expiresLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`managed-guardian-expires-${profileId}`}
|
||||
data-slot="managed-profile-guardian-expires"
|
||||
data-testid={`managed-guardian-expires-input-${profileId}`}
|
||||
value={expiresOn}
|
||||
onChange={setExpiresOn}
|
||||
aria-describedby={`managed-guardian-expires-hint-${profileId}`}
|
||||
/>
|
||||
<p
|
||||
id={`managed-guardian-expires-hint-${profileId}`}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.managed.guardianExpiresHint")}
|
||||
</p>
|
||||
</div>
|
||||
{/* The truth the routes enforce and no wording stated. It sits ABOVE
|
||||
the button, where it is part of the decision, rather than below it
|
||||
where it would be a disclaimer. */}
|
||||
<p data-slot="managed-profile-pending-note" className="text-sm">
|
||||
{t("recordSharing.managed.guardianPendingNote")}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="managed-profile-guardian-submit"
|
||||
disabled={invite.isPending || identifier.trim().length === 0}
|
||||
>
|
||||
{invite.isPending
|
||||
? t("recordSharing.managed.guardianInviting")
|
||||
: t("recordSharing.managed.guardianInvite")}
|
||||
</Button>
|
||||
{invite.isError && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-guardian-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t(guardianInviteErrorKey(invite.error))}
|
||||
</p>
|
||||
)}
|
||||
{invited && (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-guardian-invited"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("recordSharing.managed.guardianInvited", { name: invited })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a refused Guardian invitation.
|
||||
*
|
||||
* Six refusals, and **two of them carry no `errorCode`** — the rate limit
|
||||
* (`guardians/route.ts`, before the body is even read) and the unknown-account
|
||||
* 404. That asymmetry is the route's actual shape, not laziness here: the
|
||||
* other four arms publish a stable code and are matched on it, and these two
|
||||
* have to be told apart by status because there is nothing else to branch on.
|
||||
* Recorded in `02-13.1-CONTRACT-FINDINGS.md` so whoever freezes the contract
|
||||
* decides deliberately whether to close it.
|
||||
*
|
||||
* The code switch runs FIRST, and the order matters: `managed_profile.not_found`
|
||||
* is a 404 WITH a code, and an unknown account is a 404 without one. Branching
|
||||
* on status first would tell somebody to check the spelling of a name when the
|
||||
* real answer is that they are not a Guardian of that profile any more.
|
||||
*/
|
||||
export function guardianInviteErrorKey(err: unknown): string {
|
||||
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
|
||||
|
||||
const code =
|
||||
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
|
||||
switch (code) {
|
||||
// There is deliberately NO arm for `managed_profile.guardian.self`. The
|
||||
// route declares one, and it cannot fire: `self_grant` needs the grantor
|
||||
// to equal the grantee, and the grantor here is the profile rather than
|
||||
// the caller — while naming the profile itself is caught one line earlier
|
||||
// as a managed invitee. Inviting yourself arrives as `duplicate`, which is
|
||||
// both true and the useful sentence. A client arm for a code the server
|
||||
// cannot send would be a branch nothing can reach.
|
||||
case "managed_profile.guardian.duplicate":
|
||||
return "recordSharing.managed.guardianErrorDuplicate";
|
||||
case "managed_profile.guardian.managed_invitee":
|
||||
return "recordSharing.managed.guardianErrorManagedInvitee";
|
||||
case "managed_profile.not_found":
|
||||
return "recordSharing.actionError.profileNotFound";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// The two codeless arms. Waiting is the recovery for one and correcting the
|
||||
// identifier is the recovery for the other, so they must not collapse.
|
||||
if (err.status === 429) return "recordSharing.managed.guardianErrorRateLimit";
|
||||
if (err.status === 404) return "recordSharing.managed.guardianErrorNoAccount";
|
||||
// The step-up gate this whole family carries. Not a failure — and it comes
|
||||
// in two shapes, because an account with no factor enrolled cannot clear it.
|
||||
if (err.status === 401) return stepUpErrorKey(err);
|
||||
return "recordSharing.managed.guardianErrorFailed";
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { activeGuardianWhere } from "@/lib/managed-profiles/lifecycle";
|
||||
import { grantState } from "@/lib/sharing/grants";
|
||||
import { GRANT_PARTY_SELECT, type GrantParty } from "@/lib/sharing/grant-view";
|
||||
|
||||
/**
|
||||
* v1.37.0 — who looks after one managed profile.
|
||||
*
|
||||
* The read the removal route always needed. `DELETE
|
||||
* /api/managed-profiles/{id}/guardians/{grantId}` shipped with integration
|
||||
* coverage and no caller, because nothing in the product could hand a browser a
|
||||
* `grantId` for it: `GET /api/account/grants` returns the CALLER's grants, and
|
||||
* a Guardian grant has the PROFILE as grantor, so a Guardian's given-list never
|
||||
* contains one.
|
||||
*
|
||||
* ## What it discloses, and what it deliberately does not
|
||||
*
|
||||
* `GRANT_PARTY_SELECT` — `{ id, username, displayName }` — and nothing else per
|
||||
* Guardian. That is exactly what one party to a grant already sees of another
|
||||
* on the sharing panel today, so this route widens nobody's reach; it makes
|
||||
* reachable, on the record's own page, a fact the same person can already read
|
||||
* one page over. No e-mail, no channel data, no `lastUsedAt`.
|
||||
*
|
||||
* `access` and `scope` are omitted because they are constants here: a Guardian
|
||||
* grant is always `MANAGE` with `scope: null` (`inviteManagedProfileGuardian`),
|
||||
* and publishing a constant invites a client to branch on it.
|
||||
*
|
||||
* ## Why `state` is derived and not read
|
||||
*
|
||||
* Through `grantState()`, the same function the request resolver's own
|
||||
* predicate is built on. A second derivation from `acceptedAt` would drift in
|
||||
* the dangerous direction — a roster saying "accepted" about a grant the
|
||||
* resolver has stopped honouring, or the reverse — and this roster is what the
|
||||
* card counts to decide whether a remove control may exist at all.
|
||||
*
|
||||
* EXPIRED and REVOKED rows are filtered out rather than published. The question
|
||||
* this answers is "who looks after this record NOW"; an ended Guardian grant is
|
||||
* history that belongs to the grant panel, not to a floor calculation.
|
||||
*/
|
||||
export interface ManagedProfileGuardianView {
|
||||
/** The grant row's id — what `DELETE .../guardians/{grantId}` takes. */
|
||||
grantId: string;
|
||||
account: GrantParty;
|
||||
state: "PENDING" | "ACTIVE";
|
||||
invitedAt: string;
|
||||
acceptedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The roster, or `null` when the caller may not have it.
|
||||
*
|
||||
* One `null` for three different situations — no such account, an account that
|
||||
* is not a managed profile, and a profile the caller is not an active Guardian
|
||||
* of — so the route answers the same 404 to all three. An adult record whose
|
||||
* owner happened to grant somebody MANAGE is not a managed profile and must be
|
||||
* indistinguishable from one that does not exist, exactly as it is in
|
||||
* `inviteManagedProfileGuardian`.
|
||||
*
|
||||
* No lock and no transaction. `withManagedProfileLock` exists to serialize
|
||||
* lifecycle TRANSITIONS so a reducer decides from a committed count rather than
|
||||
* a stale one; taking it for a panel refresh would put an advisory lock on a
|
||||
* read that guards no invariant. The roster is a snapshot and is allowed to be
|
||||
* one — which is why the last-Guardian refusal stays enforced at the route that
|
||||
* changes something, and the card's pre-emptive floor is a courtesy on top of
|
||||
* it rather than a replacement for it.
|
||||
*/
|
||||
export async function listManagedProfileGuardians(input: {
|
||||
profileId: string;
|
||||
callerId: string;
|
||||
now?: Date;
|
||||
}): Promise<ManagedProfileGuardianView[] | null> {
|
||||
const now = input.now ?? new Date();
|
||||
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: input.profileId },
|
||||
select: { id: true, managedProfileAt: true },
|
||||
});
|
||||
if (!profile?.managedProfileAt) return null;
|
||||
|
||||
const caller = await prisma.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: profile.id,
|
||||
granteeId: input.callerId,
|
||||
...activeGuardianWhere(now),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!caller) return null;
|
||||
|
||||
const grants = await prisma.accountGrant.findMany({
|
||||
where: {
|
||||
grantorId: profile.id,
|
||||
access: "MANAGE",
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
acceptedAt: true,
|
||||
revokedAt: true,
|
||||
expiresAt: true,
|
||||
invitedAt: true,
|
||||
grantee: { select: GRANT_PARTY_SELECT },
|
||||
},
|
||||
orderBy: { invitedAt: "asc" },
|
||||
});
|
||||
|
||||
const roster: ManagedProfileGuardianView[] = [];
|
||||
for (const grant of grants) {
|
||||
const state = grantState(grant, now);
|
||||
// The `where` above already excludes both, so this is the belt on the
|
||||
// derivation rather than the filter itself: `grantState` is the authority
|
||||
// on what a row IS, and a row it calls ended must not reach a roster the
|
||||
// last-Guardian floor is counted from.
|
||||
if (state !== "PENDING" && state !== "ACTIVE") continue;
|
||||
roster.push({
|
||||
grantId: grant.id,
|
||||
account: grant.grantee,
|
||||
state,
|
||||
invitedAt: grant.invitedAt.toISOString(),
|
||||
acceptedAt: grant.acceptedAt?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
return roster;
|
||||
}
|
||||
@@ -107,6 +107,39 @@ const endedGrantView = grantView
|
||||
"A grant that has just been ended, plus what the cleanup did. Nothing is deleted: the row survives as the consent record and DELETE names the act, not the storage.",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — one Guardian of a managed profile, as the roster publishes them.
|
||||
*
|
||||
* Deliberately narrower than {@link grantView}, and the narrowing is the
|
||||
* privacy decision rather than an omission. `access` and `scope` are constants
|
||||
* for a Guardian grant (MANAGE, whole record) and publishing a constant invites
|
||||
* a client to branch on it; `lastUsedAt`, `revokedAt` and `revokedBy` describe
|
||||
* a history this list is not about; and the party block is the same
|
||||
* `{ id, username, displayName }` one sharing party already sees of another,
|
||||
* with no e-mail.
|
||||
*/
|
||||
const managedProfileGuardian = z
|
||||
.object({
|
||||
grantId: z
|
||||
.string()
|
||||
.describe(
|
||||
"The grant row. This is the value `DELETE /api/managed-profiles/{id}/guardians/{grantId}` takes.",
|
||||
),
|
||||
account: grantParty,
|
||||
state: z
|
||||
.enum(["PENDING", "ACTIVE"])
|
||||
.describe(
|
||||
"PENDING means the invitation has been sent and not yet accepted. A PENDING Guardian does NOT count toward the last-Guardian floor: the server counts accepted, unexpired MANAGE grants, so inviting somebody and then trying to leave is still refused until they accept. Say so before the act rather than letting it arrive as a 409.",
|
||||
),
|
||||
invitedAt: z.string(),
|
||||
acceptedAt: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "ManagedProfileGuardian",
|
||||
description:
|
||||
"One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than listed with a state: the question is who looks after this record now.",
|
||||
});
|
||||
|
||||
const grantListResponse = z
|
||||
.object({
|
||||
given: z.array(grantView),
|
||||
@@ -553,6 +586,36 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/managed-profiles/{id}/guardians": {
|
||||
get: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Who looks after a managed profile",
|
||||
description:
|
||||
"The live Guardians of one managed profile — every unexpired, unrevoked MANAGE grant on it, PENDING and ACTIVE alike. Cookie transport only, by the same structural argument as the rest of this family: it resolves through the session and never falls through to the Bearer branch. A fresh second factor is NOT required, unlike every act on the same profile: this is a read, and it discloses nothing about a party the caller cannot already read on their own sharing panel. An ACTOR surface — the profile is named in the path and the caller acts as themselves, so it answers from the Guardian's own account rather than only while switched into the record. Anyone who is not an active Guardian of THIS profile gets the same 404 as an unknown id, including a READ, WRITE or MANAGE delegate of an ordinary record: the refusal is not an enumeration oracle. Count the ACTIVE rows to know whether removing a Guardian is possible — a PENDING invitation does not satisfy the last-Guardian floor.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "The profile's live Guardians, oldest invitation first.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(managedProfileGuardian),
|
||||
"ManagedProfileGuardianListEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/account/switch": {
|
||||
post: {
|
||||
tags: ["Account sharing"],
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* A created managed profile reaches the switcher.
|
||||
*
|
||||
* ## The claim, and why it is not "the mutation calls invalidate"
|
||||
*
|
||||
* The list of profiles somebody looks after is not a read of its own. It is
|
||||
* `accountAccess.accounts` on `GET /api/auth/me`, filtered to the managed
|
||||
* entries — the same block that paints the account switcher and the shared-
|
||||
* record banner. So "the new profile appears without a reload" is exactly one
|
||||
* property: the account payload comes back invalidated when the creation
|
||||
* succeeds. A mutation that refreshed the sharing panel and not the payload
|
||||
* would leave the profile invisible in the card, in the switcher and in the
|
||||
* banner at once, and nothing on screen would say a read was stale.
|
||||
*
|
||||
* That claim can only be answered by a real `QueryClient` with the reads in
|
||||
* it, so the hook's success handler is driven against one. `@tanstack/react-
|
||||
* query` is mocked down to the two functions the hook body calls, which is
|
||||
* what lets the body run outside React: the unit environment is `node`, there
|
||||
* is no jsdom and no testing-library in this repo.
|
||||
*
|
||||
* Mutation check, run: point `invalidateManagedProfileReads` at the grant list
|
||||
* alone and "refreshes the account payload" goes red while every other leg
|
||||
* here stays green.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { __resetRecordScopeForTests } from "@/lib/query-keys/record-scope";
|
||||
|
||||
/** The client the mocked `useQueryClient` hands back. Reassigned per case. */
|
||||
const clientRef: { value: QueryClient } = { value: seeded() };
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
// The hook body is what is under test, so the two hooks it calls are
|
||||
// replaced by the smallest things that let it run: one that records the
|
||||
// options object, one that hands over a real client.
|
||||
useMutation: (options: unknown) => options,
|
||||
useQueryClient: () => clientRef.value,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
invalidateManagedProfileReads,
|
||||
useCreateManagedProfile,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
function seeded(): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
|
||||
});
|
||||
client.setQueryData(queryKeys.accountGrants(), {
|
||||
given: [],
|
||||
received: [],
|
||||
retentionDays: 365,
|
||||
});
|
||||
client.setQueryData(queryKeys.accountActivity(), {
|
||||
entries: [],
|
||||
retentionDays: 365,
|
||||
truncated: false,
|
||||
});
|
||||
client.setQueryData(queryKeys.authMe(), { id: "guardian" });
|
||||
client.setQueryData(queryKeys.managedProfileGuardians("p1"), []);
|
||||
return client;
|
||||
}
|
||||
|
||||
function invalidated(client: QueryClient, key: readonly unknown[]): boolean {
|
||||
return (
|
||||
client.getQueryCache().find({ queryKey: key })?.state.isInvalidated === true
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRecordScopeForTests();
|
||||
clientRef.value = seeded();
|
||||
});
|
||||
|
||||
describe("what a managed-profile change refreshes", () => {
|
||||
it("starts from four live, valid entries", () => {
|
||||
// Non-zero proof. Without it a client that failed to seed and a client
|
||||
// whose entries were all invalidated read identically below: every
|
||||
// `find()` returns undefined and each assertion becomes a quiet
|
||||
// `undefined !== true`.
|
||||
const all = clientRef.value.getQueryCache().getAll();
|
||||
expect(all).toHaveLength(4);
|
||||
expect(all.every((q) => q.state.isInvalidated)).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes the account payload, which carries the switcher", () => {
|
||||
invalidateManagedProfileReads(clientRef.value);
|
||||
expect(invalidated(clientRef.value, queryKeys.authMe())).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes the grant list and the record-activity feed", () => {
|
||||
invalidateManagedProfileReads(clientRef.value);
|
||||
expect(invalidated(clientRef.value, queryKeys.accountGrants())).toBe(true);
|
||||
expect(invalidated(clientRef.value, queryKeys.accountActivity())).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes a profile's guardian roster through the family prefix", () => {
|
||||
// Accepting a Guardian invitation is an ordinary grant accept, and it
|
||||
// moves a row on a roster that endpoint knows nothing about. The prefix is
|
||||
// what keeps the two in step.
|
||||
invalidateManagedProfileReads(clientRef.value);
|
||||
expect(
|
||||
invalidated(clientRef.value, queryKeys.managedProfileGuardians("p1")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the creation hook, driven for real", () => {
|
||||
it("posts the four fields the strict schema accepts, and no others", async () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
mutationFn: (input: unknown) => Promise<unknown>;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { id: "p9" }, error: null }), {
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await options.mutationFn({
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
|
||||
const [path, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(path).toBe("/api/managed-profiles");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(Object.keys(JSON.parse(String(init.body))).sort()).toEqual([
|
||||
"dateOfBirth",
|
||||
"displayName",
|
||||
"locale",
|
||||
"timezone",
|
||||
]);
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("puts the new profile in the switcher by refreshing the payload", () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
options.onSuccess();
|
||||
expect(invalidated(clientRef.value, queryKeys.authMe())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.37.0 — the reads and writes behind the managed-profile surface.
|
||||
*
|
||||
* A managed profile is a record with no login of its own. It is created by the
|
||||
* person who will look after it, and from that moment it exists only through
|
||||
* its Guardians — which is why every call here refreshes the ACCOUNT payload
|
||||
* and not merely the panel it was fired from.
|
||||
*
|
||||
* That is the one property worth stating up front. The list of profiles a
|
||||
* Guardian looks after is not a read of its own: it is `accountAccess.accounts`
|
||||
* on `GET /api/auth/me`, filtered to the managed entries, because the Guardian's
|
||||
* relationship to the profile IS a MANAGE grant. So a creation that refreshed
|
||||
* the sharing panel and not the payload would leave the new profile invisible —
|
||||
* in the switcher, in the banner, and in this card — until the next boot. Every
|
||||
* mutation below therefore goes through `invalidateGrantReads`, the one entry
|
||||
* `use-account-grants.ts` already publishes for exactly this fan-out; the
|
||||
* managed-profile family rides it as a key prefix rather than as a second
|
||||
* invalidation path (`grantDependentKeys`).
|
||||
*/
|
||||
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
|
||||
import type { GrantParty } from "@/lib/queries/use-account-grants";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* Exactly the body `POST /api/managed-profiles` accepts.
|
||||
*
|
||||
* The route's schema is `.strict()`, so an extra key is a 422 with nothing
|
||||
* useful in it — a form that helpfully posted an empty `heightCm` would be
|
||||
* refused for a field the person never saw. Naming the four fields as a type
|
||||
* rather than passing the form state through is what stops that: a fifth field
|
||||
* added to the form cannot reach the wire without somebody deciding it should.
|
||||
*/
|
||||
export interface CreateManagedProfileInput {
|
||||
displayName: string;
|
||||
/** ISO `yyyy-MM-dd`, or null. Never synthesised from a year. */
|
||||
dateOfBirth: string | null;
|
||||
locale: Locale;
|
||||
/** IANA zone name. */
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/** What the create route answers with. */
|
||||
export interface ManagedProfileCreated {
|
||||
id: string;
|
||||
displayName: string;
|
||||
dateOfBirth: string | null;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
recordKind: "managed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a managed-profile lifecycle change makes stale.
|
||||
*
|
||||
* Exported as a plain function over a `QueryClient` for the same reason
|
||||
* `invalidateGrantReads` is: a test can hand it a real client, seed the reads
|
||||
* and check they came back invalidated. "The mutation calls invalidate" is a
|
||||
* claim about the source; "the account payload comes back invalidated" is the
|
||||
* claim that decides whether the new profile appears in the switcher.
|
||||
*/
|
||||
export function invalidateManagedProfileReads(queryClient: QueryClient): void {
|
||||
invalidateGrantReads(queryClient);
|
||||
}
|
||||
|
||||
/** The same refresh, bound to the mounted client. */
|
||||
function useInvalidateManagedProfiles() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => invalidateManagedProfileReads(queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a record somebody looks after.
|
||||
*
|
||||
* `requireFreshMfa` guards the route, so a 401 carrying
|
||||
* `meta.errorCode: auth.stepup.required` is the EXPECTED first answer for
|
||||
* anybody who has a second factor and has not proved it recently. The form
|
||||
* routes that into the app's re-verification path rather than into a generic
|
||||
* failure — see `managed-profile-create-form.tsx`.
|
||||
*/
|
||||
export function useCreateManagedProfile() {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileCreate(),
|
||||
mutationFn: (input: CreateManagedProfileInput) =>
|
||||
apiPost<ManagedProfileCreated>("/api/managed-profiles", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** One person who looks after a profile, as the roster publishes them. */
|
||||
export interface ManagedProfileGuardian {
|
||||
/** The grant row — what `DELETE .../guardians/{grantId}` takes. */
|
||||
grantId: string;
|
||||
account: GrantParty;
|
||||
/**
|
||||
* PENDING or ACTIVE. Only ACTIVE counts toward the last-Guardian floor, and
|
||||
* the server counts the same way — a pending invitation does not let the
|
||||
* last Guardian leave.
|
||||
*/
|
||||
state: "PENDING" | "ACTIVE";
|
||||
invitedAt: string;
|
||||
acceptedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who else looks after this profile.
|
||||
*
|
||||
* The read the removal control needs to exist at all: a Guardian grant has the
|
||||
* PROFILE as grantor, so it never appears in the caller's own given-list and
|
||||
* `GET /api/account/grants` cannot supply the `grantId`. It is also where the
|
||||
* last-Guardian floor is counted from BEFORE the act rather than discovered as
|
||||
* a 409 after it.
|
||||
*/
|
||||
export function useManagedProfileGuardians(profileId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.managedProfileGuardians(profileId),
|
||||
queryFn: () =>
|
||||
apiGet<ManagedProfileGuardian[]>(
|
||||
`/api/managed-profiles/${profileId}/guardians`,
|
||||
),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a managed profile, and everything recorded in it.
|
||||
*
|
||||
* The variable is the profile id, which is what lets a refusal be rendered on
|
||||
* the row it was refused for rather than somewhere near it.
|
||||
*
|
||||
* This route emits no last-Guardian refusal, and that is deliberate rather
|
||||
* than an omission: deleting the profile is the documented way OUT of the
|
||||
* floor. A Guardian may not strand a record with nobody looking after it, but
|
||||
* they may end the record.
|
||||
*/
|
||||
export function useDeleteManagedProfile() {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileDelete(),
|
||||
mutationFn: (profileId: string) =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly the body `POST /api/managed-profiles/{id}/guardians` accepts, plus
|
||||
* the profile the request is addressed to.
|
||||
*
|
||||
* The route's schema is `.strict()` and names two fields. `identifier` is the
|
||||
* invitee's username OR e-mail, matched case-insensitively — the same
|
||||
* identifier the ordinary grant invitation takes. `expiresAt` is an ISO
|
||||
* datetime WITH an offset: a bare `YYYY-MM-DD` is a 422, which is why the
|
||||
* caller runs it through the shared end-of-day helper rather than sending the
|
||||
* date field's raw value.
|
||||
*/
|
||||
export interface InviteManagedProfileGuardianInput {
|
||||
profileId: string;
|
||||
identifier: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer somebody else a share in looking after this profile.
|
||||
*
|
||||
* The grant is created PENDING and reaches the invitee's ordinary "invitations
|
||||
* to you" card, where they accept it exactly as they would any other. Nothing
|
||||
* here accepts on their behalf, and until they accept they do not count toward
|
||||
* the last-Guardian floor.
|
||||
*/
|
||||
export function useInviteManagedProfileGuardian() {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianInvite(),
|
||||
mutationFn: (input: InviteManagedProfileGuardianInput) =>
|
||||
apiPost(
|
||||
`/api/managed-profiles/${input.profileId}/guardians`,
|
||||
guardianInviteBody(input),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire body, composed once and exported.
|
||||
*
|
||||
* Pure and separate from the mutation so the integration suite can post the
|
||||
* REAL body through the REAL route rather than a hand-written copy of it. A
|
||||
* hand-written copy is the shape of test that stays green while the client
|
||||
* renames a field: both ends prove themselves and the assembly between them is
|
||||
* what dropped it.
|
||||
*
|
||||
* `profileId` addresses the request and is deliberately not in the body: the
|
||||
* route's schema is `.strict()` and would refuse it.
|
||||
*/
|
||||
export function guardianInviteBody(input: InviteManagedProfileGuardianInput): {
|
||||
identifier: string;
|
||||
expiresAt: string | null;
|
||||
} {
|
||||
return { identifier: input.identifier, expiresAt: input.expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* End another Guardian's access.
|
||||
*
|
||||
* Another Guardian's, never one's own: the service refuses a target grant whose
|
||||
* grantee is the caller and the route turns that into the same 404 an unknown
|
||||
* profile gets. A Guardian steps away through the renounce control on the
|
||||
* shared-access panel instead.
|
||||
*
|
||||
* `variables` carries both ids so a refusal can be rendered on the guardian row
|
||||
* it belongs to rather than on the panel.
|
||||
*/
|
||||
export function useRemoveManagedProfileGuardian() {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianRemove(),
|
||||
mutationFn: ({
|
||||
profileId,
|
||||
grantId,
|
||||
}: {
|
||||
profileId: string;
|
||||
grantId: string;
|
||||
}) => apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -81,11 +81,20 @@ export { recordSettingsKeys } from "./record-settings";
|
||||
* and the banner. A revoke that refreshed the panel but not the payload would
|
||||
* leave a switcher entry pointing at a record the server had just closed: the
|
||||
* affordance still on screen, the click already refused.
|
||||
*
|
||||
* v1.37.0 — the managed-profile family joins the bundle as a prefix. A
|
||||
* Guardian invitation is accepted through the ordinary
|
||||
* `POST /api/account/grants/{id}/accept`, and that acceptance moves a row on a
|
||||
* roster the accept route knows nothing about: the invitee stops being PENDING
|
||||
* and starts counting toward the last-Guardian floor. Listing the prefix here
|
||||
* keeps the roster in step with the act that changed it, rather than asking
|
||||
* every future grant transition to remember one more read.
|
||||
*/
|
||||
export const grantDependentKeys = [
|
||||
queryKeys.accountGrants(),
|
||||
queryKeys.accountActivity(),
|
||||
queryKeys.authMe(),
|
||||
queryKeys.managedProfiles(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,10 +28,38 @@ export const sharingKeys = {
|
||||
*/
|
||||
accountActivity: () => ["account", "activity"] as const,
|
||||
|
||||
/**
|
||||
* v1.37.0 — the managed-profile family, as one prefix.
|
||||
*
|
||||
* Present so a grant transition can invalidate the whole family at once:
|
||||
* accepting a Guardian invitation is an ordinary `POST
|
||||
* /api/account/grants/{id}/accept`, and it moves a row on a roster that
|
||||
* endpoint knows nothing about. Listing this prefix in `grantDependentKeys`
|
||||
* is what keeps the two in step without the accept path having to know a
|
||||
* managed profile exists.
|
||||
*/
|
||||
managedProfiles: () => ["managed-profiles"] as const,
|
||||
|
||||
/**
|
||||
* Who looks after one managed profile (`GET
|
||||
* /api/managed-profiles/{id}/guardians`). Keyed by profile because a
|
||||
* Guardian may look after more than one, and a single key for all of them
|
||||
* would be the same-key-different-shape collision the factory exists to
|
||||
* prevent.
|
||||
*/
|
||||
managedProfileGuardians: (profileId: string) =>
|
||||
["managed-profiles", profileId, "guardians"] as const,
|
||||
|
||||
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
|
||||
accountGrantInvite: () => ["account", "grants", "invite"] as const,
|
||||
accountGrantAccept: () => ["account", "grants", "accept"] as const,
|
||||
accountGrantRevoke: () => ["account", "grants", "revoke"] as const,
|
||||
accountGrantRenounce: () => ["account", "grants", "renounce"] as const,
|
||||
accountSwitchMutation: () => ["account", "switch"] as const,
|
||||
managedProfileCreate: () => ["managed-profiles", "create"] as const,
|
||||
managedProfileDelete: () => ["managed-profiles", "delete"] as const,
|
||||
managedProfileGuardianInvite: () =>
|
||||
["managed-profiles", "guardian", "invite"] as const,
|
||||
managedProfileGuardianRemove: () =>
|
||||
["managed-profiles", "guardian", "remove"] as const,
|
||||
};
|
||||
@@ -0,0 +1,887 @@
|
||||
/**
|
||||
* Every mutation and read the managed-profile surface can issue, through the
|
||||
* real routes and a real Postgres.
|
||||
*
|
||||
* The browser card is proved from a static render in
|
||||
* `src/components/settings/access/__tests__/managed-profile-affordances.test.tsx`,
|
||||
* which can say that a control exists and nothing about what happens when it is
|
||||
* pressed. This file is the other half: the same calls the card makes, against
|
||||
* the handlers themselves, including every refusal the card renders a sentence
|
||||
* for.
|
||||
*
|
||||
* The roster read is where most of the weight sits, because it is the one
|
||||
* capability this release adds to the server. Its field set is asserted with
|
||||
* `Object.keys` rather than by presence: an over-disclosing route passes every
|
||||
* "contains" assertion ever written about it, and the decision that the roster
|
||||
* publishes no e-mail address is a privacy decision rather than plumbing.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers";
|
||||
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-managed-profile-surface-32-bytes-min-1234509876";
|
||||
|
||||
const { hashToken } = await import("@/lib/auth/hmac");
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar, queuedSessionIds } =
|
||||
await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => {
|
||||
const snapshot = new Map(cookieJar);
|
||||
const queuedSessionId = queuedSessionIds.shift();
|
||||
if (queuedSessionId) {
|
||||
snapshot.set("healthlog_session", queuedSessionId);
|
||||
}
|
||||
return {
|
||||
get: (name: string) => {
|
||||
const value = snapshot.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
interface Person {
|
||||
id: string;
|
||||
username: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/** An account with a second factor and a session that has just proved it. */
|
||||
async function person(label: string): Promise<Person> {
|
||||
const suffix = sequence++;
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: `${label}-${suffix}`,
|
||||
email: `${label}-${suffix}@example.test`,
|
||||
totpConfirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { id: user.id, username: user.username, sessionId: session.id };
|
||||
}
|
||||
|
||||
function signIn(who: Person): void {
|
||||
headerJar.delete("authorization");
|
||||
cookieJar.set("healthlog_session", who.sessionId);
|
||||
}
|
||||
|
||||
/** A wildcard Bearer token for `who`, with no cookie session armed. */
|
||||
async function signInWithBearer(who: Person): Promise<void> {
|
||||
const raw = `hlk_surface_${"0".repeat(48)}`;
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId: who.id,
|
||||
name: "surface",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
cookieJar.clear();
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
}
|
||||
|
||||
async function listGuardians(profileId: string) {
|
||||
const { GET } =
|
||||
await import("@/app/api/managed-profiles/[id]/guardians/route");
|
||||
return GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/managed-profiles/${profileId}/guardians`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: profileId }) },
|
||||
);
|
||||
}
|
||||
|
||||
/** `POST /api/managed-profiles`, with the body the card composes. */
|
||||
async function createProfile(body: unknown) {
|
||||
const { POST } = await import("@/app/api/managed-profiles/route");
|
||||
return POST(
|
||||
new NextRequest("http://localhost/api/managed-profiles", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteProfile(profileId: string) {
|
||||
const { DELETE } = await import("@/app/api/managed-profiles/[id]/route");
|
||||
return DELETE(
|
||||
new NextRequest(`http://localhost/api/managed-profiles/${profileId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: profileId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function inviteGuardian(
|
||||
profileId: string,
|
||||
body: { identifier: string; expiresAt?: string | null },
|
||||
) {
|
||||
const { POST } =
|
||||
await import("@/app/api/managed-profiles/[id]/guardians/route");
|
||||
return POST(
|
||||
new NextRequest(
|
||||
`http://localhost/api/managed-profiles/${profileId}/guardians`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ id: profileId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function removeGuardian(profileId: string, grantId: string) {
|
||||
const { DELETE } =
|
||||
await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route");
|
||||
return DELETE(
|
||||
new NextRequest(
|
||||
`http://localhost/api/managed-profiles/${profileId}/guardians/${grantId}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: profileId, grantId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function errorCodeOf(response: Response): Promise<unknown> {
|
||||
return (await response.json()).meta?.errorCode;
|
||||
}
|
||||
|
||||
/** A profile with its creator as sole Guardian, plus a pending second one. */
|
||||
async function profileWithPendingSecondGuardian() {
|
||||
const creator = await person("creator");
|
||||
const invitee = await person("invitee");
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: creator.id,
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
const invitation = await inviteGrant({
|
||||
grantorId: profile.id,
|
||||
granteeId: invitee.id,
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
});
|
||||
return { creator, invitee, profile, invitation };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
queuedSessionIds.length = 0;
|
||||
});
|
||||
|
||||
describe("who looks after a managed profile (real Postgres)", () => {
|
||||
it("shows an active Guardian every live grant, pending and accepted", async () => {
|
||||
const { creator, invitee, profile } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
|
||||
const response = await listGuardians(profile.id);
|
||||
expect(response.status).toBe(200);
|
||||
const roster = (await response.json()).data as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
expect(roster).toHaveLength(2);
|
||||
const byName = new Map(
|
||||
roster.map((row) => [
|
||||
(row.account as { username: string }).username,
|
||||
row,
|
||||
]),
|
||||
);
|
||||
expect(byName.get(creator.username)?.state).toBe("ACTIVE");
|
||||
expect(byName.get(creator.username)?.acceptedAt).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
expect(byName.get(invitee.username)?.state).toBe("PENDING");
|
||||
expect(byName.get(invitee.username)?.acceptedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("discloses exactly five fields, and the party block carries no e-mail", async () => {
|
||||
// `Object.keys`, not `toContain`. A route that also published the
|
||||
// invitee's e-mail address, their notification channels or their last
|
||||
// access would satisfy every presence assertion in this file.
|
||||
const { profile, creator } = await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
|
||||
const roster = (await (await listGuardians(profile.id)).json())
|
||||
.data as Array<Record<string, unknown>>;
|
||||
expect(roster.length).toBeGreaterThan(0);
|
||||
for (const row of roster) {
|
||||
expect(Object.keys(row).sort()).toEqual([
|
||||
"acceptedAt",
|
||||
"account",
|
||||
"grantId",
|
||||
"invitedAt",
|
||||
"state",
|
||||
]);
|
||||
expect(Object.keys(row.account as object).sort()).toEqual([
|
||||
"displayName",
|
||||
"id",
|
||||
"username",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers a second Guardian with the same roster", async () => {
|
||||
const { creator, invitee, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
|
||||
|
||||
signIn(creator);
|
||||
const first = (await (await listGuardians(profile.id)).json()).data;
|
||||
signIn(invitee);
|
||||
const second = (await (await listGuardians(profile.id)).json()).data;
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(
|
||||
(second as Array<{ state: string }>).every((r) => r.state === "ACTIVE"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses somebody who is not a Guardian of that profile", async () => {
|
||||
const { profile } = await profileWithPendingSecondGuardian();
|
||||
const stranger = await person("stranger");
|
||||
signIn(stranger);
|
||||
|
||||
const response = await listGuardians(profile.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect((await response.json()).meta?.errorCode).toBe(
|
||||
"managed_profile.not_found",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers an unknown id with the same bytes as a refused one", async () => {
|
||||
// The refusal must not be an enumeration oracle: "no such profile" and
|
||||
// "not yours" have to be indistinguishable from the outside.
|
||||
const { profile } = await profileWithPendingSecondGuardian();
|
||||
const stranger = await person("stranger");
|
||||
signIn(stranger);
|
||||
|
||||
const refused = await (await listGuardians(profile.id)).json();
|
||||
const unknown = await (await listGuardians("does-not-exist")).json();
|
||||
expect(unknown).toEqual(refused);
|
||||
});
|
||||
|
||||
it.each(["READ", "WRITE", "MANAGE"] as const)(
|
||||
"refuses a %s delegate of an ordinary record against that record",
|
||||
async (access) => {
|
||||
// Holding a grant is not holding this read. An adult record whose owner
|
||||
// granted MANAGE is not a managed profile, and must 404 exactly as an
|
||||
// unknown id does.
|
||||
const owner = await person("owner");
|
||||
const delegate = await person("delegate");
|
||||
const invitation = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access,
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: delegate.id });
|
||||
signIn(delegate);
|
||||
|
||||
const response = await listGuardians(owner.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect((await response.json()).meta?.errorCode).toBe(
|
||||
"managed_profile.not_found",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("drops a revoked Guardian from the roster and refuses them the read", async () => {
|
||||
const { creator, invitee, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
|
||||
await getPrismaClient().accountGrant.update({
|
||||
where: { id: invitation.id },
|
||||
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
|
||||
});
|
||||
|
||||
signIn(invitee);
|
||||
expect((await listGuardians(profile.id)).status).toBe(404);
|
||||
|
||||
signIn(creator);
|
||||
const roster = (await (await listGuardians(profile.id)).json())
|
||||
.data as Array<{ account: { username: string } }>;
|
||||
expect(roster).toHaveLength(1);
|
||||
expect(roster[0].account.username).toBe(creator.username);
|
||||
});
|
||||
|
||||
it("drops an expired Guardian grant from the roster", async () => {
|
||||
const { creator, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
await getPrismaClient().accountGrant.update({
|
||||
where: { id: invitation.id },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
signIn(creator);
|
||||
const roster = (await (await listGuardians(profile.id)).json())
|
||||
.data as Array<{ account: { username: string } }>;
|
||||
expect(roster).toHaveLength(1);
|
||||
expect(roster[0].account.username).toBe(creator.username);
|
||||
});
|
||||
|
||||
it("refuses a Bearer caller, because the family is cookie-only", async () => {
|
||||
const { creator, profile } = await profileWithPendingSecondGuardian();
|
||||
await signInWithBearer(creator);
|
||||
|
||||
// The same account, the same wildcard reach it has everywhere else, and
|
||||
// still no answer here: `requireCookieAuth` never falls through to the
|
||||
// Bearer branch.
|
||||
expect((await listGuardians(profile.id)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every call the managed-profile card can make", () => {
|
||||
it("creates a profile from exactly the body the form composes", async () => {
|
||||
const creator = await person("creator");
|
||||
signIn(creator);
|
||||
|
||||
const response = await createProfile({
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "de",
|
||||
timezone: "Europe/Zurich",
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
const created = (await response.json()).data;
|
||||
expect(created.recordKind).toBe("managed");
|
||||
|
||||
// The creator is its Guardian, accepted, from the same transaction — which
|
||||
// is why the card can read the profile off the account payload rather than
|
||||
// from a list endpoint that does not exist.
|
||||
signIn(creator);
|
||||
const roster = (await (await listGuardians(created.id)).json())
|
||||
.data as Array<{ state: string; account: { id: string } }>;
|
||||
expect(roster).toEqual([
|
||||
expect.objectContaining({
|
||||
state: "ACTIVE",
|
||||
account: expect.objectContaining({ id: creator.id }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a body carrying a field the form does not collect", async () => {
|
||||
// The schema is `.strict()`. This is the reason the client names its four
|
||||
// fields one at a time instead of spreading its form state: a helpful
|
||||
// extra key is a 422 about something nobody filled in.
|
||||
signIn(await person("creator"));
|
||||
const response = await createProfile({
|
||||
displayName: "Managed record",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
heightCm: 120,
|
||||
});
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
it("refuses a display name that is only whitespace", async () => {
|
||||
// The bound the form mirrors before it sends: the route trims first.
|
||||
signIn(await person("creator"));
|
||||
expect(
|
||||
(
|
||||
await createProfile({
|
||||
displayName: " ",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
})
|
||||
).status,
|
||||
).toBe(422);
|
||||
});
|
||||
|
||||
it("deletes a profile, and answers a second attempt with the same 404", async () => {
|
||||
const { creator, profile } = await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
|
||||
expect((await deleteProfile(profile.id)).status).toBe(200);
|
||||
expect(
|
||||
await getPrismaClient().user.findUnique({ where: { id: profile.id } }),
|
||||
).toBeNull();
|
||||
|
||||
const second = await deleteProfile(profile.id);
|
||||
expect(second.status).toBe(404);
|
||||
expect(await errorCodeOf(second)).toBe("managed_profile.not_found");
|
||||
});
|
||||
|
||||
it("refuses deletion by somebody who is not a Guardian", async () => {
|
||||
const { profile } = await profileWithPendingSecondGuardian();
|
||||
signIn(await person("stranger"));
|
||||
|
||||
const response = await deleteProfile(profile.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
|
||||
// And the record is still there.
|
||||
expect(
|
||||
await getPrismaClient().user.findUnique({ where: { id: profile.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("invites a second Guardian as PENDING, with the offset expiry the form sends", async () => {
|
||||
const creator = await person("creator");
|
||||
const invitee = await person("invitee");
|
||||
signIn(creator);
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: creator.id,
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
// The shape `endOfDayIso` produces: an ISO instant, offset-bearing. A bare
|
||||
// `YYYY-MM-DD` is a 422 here, which is why the client never sends the date
|
||||
// field's raw value.
|
||||
const expiresAt = new Date(Date.now() + 86_400_000).toISOString();
|
||||
const response = await inviteGuardian(profile.id, {
|
||||
identifier: invitee.username,
|
||||
expiresAt,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
const grant = (await response.json()).data;
|
||||
expect(grant.acceptedAt).toBeNull();
|
||||
expect(new Date(grant.expiresAt).toISOString()).toBe(expiresAt);
|
||||
|
||||
// PENDING, and therefore not counted by the floor — which is the sentence
|
||||
// the card states before anybody tries to leave.
|
||||
const roster = (await (await listGuardians(profile.id)).json())
|
||||
.data as Array<{ state: string }>;
|
||||
expect(roster.filter((r) => r.state === "ACTIVE")).toHaveLength(1);
|
||||
expect(roster.filter((r) => r.state === "PENDING")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts the body the client actually composes, field names and all", async () => {
|
||||
// Both ends and the pipe between them. The route's schema is `.strict()`,
|
||||
// so a client that renamed `identifier` would be refused with a 422 — and
|
||||
// a test that posted a hand-written copy of the body would stay green
|
||||
// through exactly that rename. This posts the composer's own output.
|
||||
const { guardianInviteBody } =
|
||||
await import("@/lib/queries/use-managed-profiles");
|
||||
const creator = await person("creator");
|
||||
const invitee = await person("invitee");
|
||||
signIn(creator);
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: creator.id,
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const body = guardianInviteBody({
|
||||
profileId: profile.id,
|
||||
identifier: invitee.username,
|
||||
expiresAt: null,
|
||||
});
|
||||
// Non-zero proof, deliberately name-agnostic: an empty body would be
|
||||
// refused for the wrong reason and would make the 201 below prove nothing.
|
||||
// The NAMES are proved by the route accepting it, not by a second copy of
|
||||
// them written here.
|
||||
expect(Object.keys(body)).toHaveLength(2);
|
||||
|
||||
const response = await inviteGuardian(
|
||||
profile.id,
|
||||
body as { identifier: string; expiresAt: string | null },
|
||||
);
|
||||
expect(response.status).toBe(201);
|
||||
expect((await response.json()).data.acceptedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a bare calendar date as an expiry", async () => {
|
||||
const creator = await person("creator");
|
||||
const invitee = await person("invitee");
|
||||
signIn(creator);
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: creator.id,
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await inviteGuardian(profile.id, {
|
||||
identifier: invitee.username,
|
||||
expiresAt: "2027-01-30",
|
||||
})
|
||||
).status,
|
||||
).toBe(422);
|
||||
});
|
||||
|
||||
it("answers an invitation addressed to yourself with the duplicate refusal", async () => {
|
||||
// NOT `managed_profile.guardian.self`, and the plan predicted otherwise.
|
||||
// `self_grant` fires when the grantor equals the grantee, and the grantor
|
||||
// here is the PROFILE — never the caller. So inviting yourself is simply
|
||||
// an account that already has access, which is what the person is told,
|
||||
// and the route's 422 self arm cannot be reached. Recorded as a finding;
|
||||
// the client has no arm for a code the server cannot send.
|
||||
const { creator, profile } = await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
const response = await inviteGuardian(profile.id, {
|
||||
identifier: creator.username,
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
expect(await errorCodeOf(response)).toBe(
|
||||
"managed_profile.guardian.duplicate",
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot be made to answer the self-invitation 422 either", async () => {
|
||||
// The other door to `self_grant` is naming the profile itself, and that
|
||||
// one is closed by the managed-invitee check one line earlier. Both paths
|
||||
// are asserted so the arm is known to be dead rather than assumed to be.
|
||||
const { creator, profile } = await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
const response = await inviteGuardian(profile.id, {
|
||||
identifier: profile.username,
|
||||
});
|
||||
expect(response.status).toBe(422);
|
||||
expect(await errorCodeOf(response)).toBe(
|
||||
"managed_profile.guardian.managed_invitee",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an invitation to somebody who already has access", async () => {
|
||||
const { creator, invitee, profile } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
const response = await inviteGuardian(profile.id, {
|
||||
identifier: invitee.username,
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
expect(await errorCodeOf(response)).toBe(
|
||||
"managed_profile.guardian.duplicate",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a managed profile as a Guardian", async () => {
|
||||
const { creator, profile } = await profileWithPendingSecondGuardian();
|
||||
const { profile: other } = await createManagedProfile({
|
||||
creatorId: creator.id,
|
||||
displayName: "Second record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
signIn(creator);
|
||||
const response = await inviteGuardian(profile.id, {
|
||||
identifier: other.username,
|
||||
});
|
||||
expect(response.status).toBe(422);
|
||||
expect(await errorCodeOf(response)).toBe(
|
||||
"managed_profile.guardian.managed_invitee",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers an unknown identifier with a 404 carrying NO error code", async () => {
|
||||
// The asymmetry the client has to branch on status for. Four arms of this
|
||||
// route publish a code; this one and the rate limit do not.
|
||||
const { creator, profile } = await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
const response = await inviteGuardian(profile.id, {
|
||||
identifier: "nobody-here",
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses an invitation from somebody who is not a Guardian", async () => {
|
||||
const { profile, invitee } = await profileWithPendingSecondGuardian();
|
||||
signIn(await person("stranger"));
|
||||
const response = await inviteGuardian(profile.id, {
|
||||
identifier: invitee.username,
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
|
||||
});
|
||||
|
||||
it("ends another Guardian's access and clears their switched sessions", async () => {
|
||||
const { creator, invitee, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
|
||||
signIn(creator);
|
||||
|
||||
const response = await removeGuardian(profile.id, invitation.id);
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
await getPrismaClient().accountGrant.findUniqueOrThrow({
|
||||
where: { id: invitation.id },
|
||||
}),
|
||||
).toMatchObject({ revokedBy: "GRANTOR" });
|
||||
|
||||
// And the roster the card re-reads no longer carries them.
|
||||
const roster = (await (await listGuardians(profile.id)).json())
|
||||
.data as unknown[];
|
||||
expect(roster).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("withdraws a pending invitation without touching the accepted count", async () => {
|
||||
const { creator, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
signIn(creator);
|
||||
|
||||
expect((await removeGuardian(profile.id, invitation.id)).status).toBe(200);
|
||||
const roster = (await (await listGuardians(profile.id)).json())
|
||||
.data as Array<{ state: string }>;
|
||||
expect(roster).toHaveLength(1);
|
||||
expect(roster[0].state).toBe("ACTIVE");
|
||||
});
|
||||
|
||||
it("refuses a Guardian ending their OWN access, and leaves the grant alone", async () => {
|
||||
// This is the shape the card's missing self-removal control comes from.
|
||||
// `revokeManagedProfileGuardian` refuses a target whose grantee is the
|
||||
// caller, and the route turns that into `managed_profile.not_found` — NOT
|
||||
// into the last-Guardian 409. A self-removal button would therefore be one
|
||||
// that always fails, with a message that does not describe what happened.
|
||||
// Stepping away is the renounce control on the shared-access panel.
|
||||
const { creator, profile } = await profileWithPendingSecondGuardian();
|
||||
const own = await getPrismaClient().accountGrant.findFirstOrThrow({
|
||||
where: { grantorId: profile.id, granteeId: creator.id },
|
||||
});
|
||||
signIn(creator);
|
||||
|
||||
const response = await removeGuardian(profile.id, own.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
|
||||
expect(
|
||||
await getPrismaClient().accountGrant.findUniqueOrThrow({
|
||||
where: { id: own.id },
|
||||
}),
|
||||
).toMatchObject({ revokedAt: null, revokedBy: null });
|
||||
});
|
||||
|
||||
it("refuses removal by somebody who is not a Guardian of the profile", async () => {
|
||||
const { profile, invitation } = await profileWithPendingSecondGuardian();
|
||||
signIn(await person("stranger"));
|
||||
const response = await removeGuardian(profile.id, invitation.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
|
||||
});
|
||||
|
||||
it("cannot be made to answer the last-Guardian 409 through this route", async () => {
|
||||
// Recorded as a property rather than as a gap in coverage. The refusal
|
||||
// fires only when the TARGET is active and the accepted count is at most
|
||||
// one — and the caller must be an accepted Guardian to get this far, and
|
||||
// may not target themselves. Two accepted Guardians is therefore the
|
||||
// minimum for the branch to be evaluated at all, and two is never one.
|
||||
//
|
||||
// The floor is real; it is enforced on the renounce route, on account
|
||||
// deletion and on a data wipe. It is this ROUTE that cannot express it,
|
||||
// which is why the card states the floor from the roster instead of
|
||||
// waiting for a refusal that will not come.
|
||||
const { creator, invitee, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
|
||||
signIn(creator);
|
||||
|
||||
// Two accepted Guardians: the removal is allowed rather than refused.
|
||||
expect((await removeGuardian(profile.id, invitation.id)).status).toBe(200);
|
||||
|
||||
// And with one left, the only target available to that Guardian is
|
||||
// themselves — which is the 404 above, not the 409.
|
||||
const own = await getPrismaClient().accountGrant.findFirstOrThrow({
|
||||
where: { grantorId: profile.id, granteeId: creator.id },
|
||||
});
|
||||
const self = await removeGuardian(profile.id, own.id);
|
||||
expect(self.status).toBe(404);
|
||||
expect(await errorCodeOf(self)).not.toBe(
|
||||
"managed_profile.guardian.required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the three caller states the roster read has to refuse", () => {
|
||||
it("refuses a Guardian whose invitation has not been accepted", async () => {
|
||||
// PENDING confers nothing. `activeGuardianWhere` requires `acceptedAt`, so
|
||||
// an invitee who can see the invitation on their own panel still cannot
|
||||
// read the roster of the record it is for — and must not be able to, or
|
||||
// an invitation would disclose the household before it was accepted.
|
||||
const { invitee, profile } = await profileWithPendingSecondGuardian();
|
||||
signIn(invitee);
|
||||
|
||||
const response = await listGuardians(profile.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
|
||||
});
|
||||
|
||||
it("refuses a Guardian whose grant has expired", async () => {
|
||||
// The other half of the same predicate, and the one a `revokedAt IS NULL`
|
||||
// check alone would miss: nobody ended this grant, the clock did.
|
||||
const { invitee, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
|
||||
await getPrismaClient().accountGrant.update({
|
||||
where: { id: invitation.id },
|
||||
data: { expiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
signIn(invitee);
|
||||
|
||||
const response = await listGuardians(profile.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
|
||||
});
|
||||
|
||||
it("refuses a holder of MANAGE on an ordinary record, against a real managed profile", async () => {
|
||||
// Distinct from the leg above that points a delegate at THEIR OWN grantor:
|
||||
// this caller holds a live MANAGE grant somewhere, and aims it at somebody
|
||||
// else's managed profile. Holding the level is not holding this record.
|
||||
const { profile } = await profileWithPendingSecondGuardian();
|
||||
const owner = await person("owner");
|
||||
const manager = await person("manager");
|
||||
const invitation = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: manager.id,
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: manager.id });
|
||||
signIn(manager);
|
||||
|
||||
const response = await listGuardians(profile.id);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await errorCodeOf(response)).toBe("managed_profile.not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what deletion does to a Guardian who is standing inside the record", () => {
|
||||
it("puts their session back in their own account rather than nowhere", async () => {
|
||||
// The composition worth an integration leg: `Session.actingAsUserId` is a
|
||||
// foreign key with ON DELETE SET NULL, and the epoch is bumped by a
|
||||
// database trigger on that column. Deleting the profile therefore has to
|
||||
// leave the other Guardian's browser in a state it can prove — back in
|
||||
// their own record, on a NEW epoch — rather than pointed at a row that is
|
||||
// gone. A session left holding a dangling selector is the shape that
|
||||
// strands somebody behind the fence with no way out.
|
||||
const { creator, invitee, profile, invitation } =
|
||||
await profileWithPendingSecondGuardian();
|
||||
await acceptGrant({ grantId: invitation.id, granteeId: invitee.id });
|
||||
await switchSessionTo(invitee.sessionId, profile.id);
|
||||
|
||||
const before = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: invitee.sessionId },
|
||||
});
|
||||
// Non-zero proof: they really were inside the record before the delete.
|
||||
expect(before.actingAsUserId).toBe(profile.id);
|
||||
|
||||
signIn(creator);
|
||||
expect((await deleteProfile(profile.id)).status).toBe(200);
|
||||
|
||||
const after = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: invitee.sessionId },
|
||||
});
|
||||
// The session survives — deleting a record must not sign anybody out —
|
||||
// and it is back in its own account.
|
||||
expect(after.actingAsUserId).toBeNull();
|
||||
// And the context moved, so a request formed inside the deleted record is
|
||||
// refused as stale rather than served against nothing.
|
||||
expect(after.recordEpoch).toBeGreaterThan(before.recordEpoch);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many profiles one account may create in an hour", () => {
|
||||
it("refuses the eleventh, with the same ceiling the guardian route carries", async () => {
|
||||
// Ten an hour. The guardian invitation has been bounded since it shipped;
|
||||
// this route creates a real account row per call and was not, which is the
|
||||
// kind of asymmetry between two endpoints of one family that nobody
|
||||
// notices until it is used.
|
||||
const creator = await person("creator");
|
||||
signIn(creator);
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const allowed = await createProfile({
|
||||
displayName: `Managed record ${i}`,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
expect(allowed.status, `creation ${i}`).toBe(201);
|
||||
}
|
||||
|
||||
const refused = await createProfile({
|
||||
displayName: "One too many",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
expect(refused.status).toBe(429);
|
||||
// No error code, like every other rate-limit refusal in this family — the
|
||||
// client branches on status, which is recorded in the findings.
|
||||
expect(await errorCodeOf(refused)).toBeUndefined();
|
||||
// And nothing was created by the refused call.
|
||||
expect(
|
||||
await getPrismaClient().user.count({
|
||||
where: { managedProfileAt: { not: null } },
|
||||
}),
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it("bounds the caller and not the instance", async () => {
|
||||
// A per-user bucket, so one busy account cannot stop another from creating
|
||||
// a record. `checkRateLimit` is keyed by caller id; this proves the key
|
||||
// really carries it rather than being a single global bucket.
|
||||
const first = await person("creator");
|
||||
signIn(first);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await createProfile({
|
||||
displayName: `Managed record ${i}`,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await createProfile({
|
||||
displayName: "One too many",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
})
|
||||
).status,
|
||||
).toBe(429);
|
||||
|
||||
const second = await person("creator");
|
||||
signIn(second);
|
||||
expect(
|
||||
(
|
||||
await createProfile({
|
||||
displayName: "Somebody else's first",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
})
|
||||
).status,
|
||||
).toBe(201);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user