@tinytars/frame 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AccountMenu.svelte +210 -0
- package/AttachPicker.svelte +36 -0
- package/Diagnostics.svelte +188 -0
- package/ExportTab.svelte +86 -0
- package/LeafActionMenu.svelte +168 -0
- package/LeafCard.svelte +72 -0
- package/LoginScreen.svelte +124 -0
- package/Onboarding.svelte +122 -0
- package/VisibilitySettings.svelte +62 -0
- package/account-methods.svelte.ts +321 -0
- package/anchored-menu.svelte.ts +208 -0
- package/attach-controller.ts +27 -0
- package/brand.ts +8 -0
- package/menu-items.ts +17 -0
- package/menu-registry.svelte.ts +18 -0
- package/package.json +65 -0
- package/recovery-controller.svelte.ts +301 -0
- package/roster-session.svelte.ts +263 -0
- package/support-access.svelte.ts +229 -0
- package/vault-principals.svelte.ts +245 -0
- package/vault-session.svelte.ts +77 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Getting back in: the four ways a person who cannot sign in ends up signed in again, and the one
|
|
2
|
+
// way they give that up on purpose.
|
|
3
|
+
//
|
|
4
|
+
// App's second controller extraction, and the one with the least test cover behind it. This
|
|
5
|
+
// recovery ladder was built first, with its server-side attempt cap fixed afterward, but the
|
|
6
|
+
// browser half had no unit test at all: which code kind is in play, what is shown once and never
|
|
7
|
+
// again, and what is left behind when a step fails. Those are state questions, and an e2e that
|
|
8
|
+
// drives the happy path cannot ask them.
|
|
9
|
+
//
|
|
10
|
+
// Same getter-parameterized factory shape as vault-principals.svelte.ts and the other controllers in
|
|
11
|
+
// this package. What stays with App is what App owns: the lock screen's email field, the account private key, and
|
|
12
|
+
// what a successful recovery does next (enter the account, persist the key). Two of the five handlers
|
|
13
|
+
// report through panels this module does not own — the Account modal's busy/error line and the lock
|
|
14
|
+
// screen's — so both are injected rather than duplicated here.
|
|
15
|
+
|
|
16
|
+
import type { VaultSession } from "./vault-session.svelte";
|
|
17
|
+
import {
|
|
18
|
+
detectRecoveryKind,
|
|
19
|
+
issueRecoveryCode,
|
|
20
|
+
recoverAccount,
|
|
21
|
+
redeemRecoveryCode,
|
|
22
|
+
regenerateRecoveryCode,
|
|
23
|
+
revokeRecoveryEnvelope,
|
|
24
|
+
} from "@tinytars/vault/auth-recovery";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The minimum a roster row must carry to have a code issued for it. Structural, and the controller is
|
|
28
|
+
* generic over it, so the host keeps its own richer row type (and its label function) without this
|
|
29
|
+
* module importing the roster's shape.
|
|
30
|
+
*/
|
|
31
|
+
export interface RecoveryPatient {
|
|
32
|
+
patientAccountId: string;
|
|
33
|
+
envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What both recovery paths hand back; the shape `enterAccount` takes. */
|
|
37
|
+
export interface RecoveredSession {
|
|
38
|
+
vaultId: string | null;
|
|
39
|
+
r2Key: string | null;
|
|
40
|
+
privateKey: CryptoKey;
|
|
41
|
+
dek: CryptoKey | null;
|
|
42
|
+
rotationPending?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RecoveryControllerDeps {
|
|
46
|
+
/** The unlocked-session key material; `providerKey` is what authorizes issuing a code. */
|
|
47
|
+
session: VaultSession;
|
|
48
|
+
/** The lock screen's email field, read fresh — `enterAccount` clears it, so it must never be captured. */
|
|
49
|
+
getEmail: () => string;
|
|
50
|
+
/**
|
|
51
|
+
* The account private key, or null with the "sign in again" message already posted. Regenerating a
|
|
52
|
+
* recovery code needs an EXTRACTABLE key, which a resumed session may not have; App owns that check
|
|
53
|
+
* because it owns the key.
|
|
54
|
+
*/
|
|
55
|
+
ensureExtractableKey: () => CryptoKey | null;
|
|
56
|
+
/** Re-reads the method list so the new recovery credential appears. App owns the Account modal. */
|
|
57
|
+
refreshAccount: () => Promise<void>;
|
|
58
|
+
setAccountBusy: (busy: boolean) => void;
|
|
59
|
+
reportAccountError: (message: string | null) => void;
|
|
60
|
+
/** The lock screen's busy flag and shared error line. */
|
|
61
|
+
setUnlocking: (busy: boolean) => void;
|
|
62
|
+
reportError: (message: string | null) => void;
|
|
63
|
+
/** Enter the account and persist the key so the session survives a refresh. App owns both. */
|
|
64
|
+
enterRecovered: (r: RecoveredSession) => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RecoveryController<P extends RecoveryPatient = RecoveryPatient> {
|
|
68
|
+
/** The roster row whose one-time code dialog is open, or null when it is closed. */
|
|
69
|
+
readonly issuedFor: P | null;
|
|
70
|
+
readonly issuedCode: string | null;
|
|
71
|
+
readonly issuedExpiresAt: string | null;
|
|
72
|
+
readonly issuing: boolean;
|
|
73
|
+
readonly issueError: string | null;
|
|
74
|
+
|
|
75
|
+
/** Lock screen: recovery form shown instead of sign-in. */
|
|
76
|
+
recoverMode: boolean;
|
|
77
|
+
codeInput: string;
|
|
78
|
+
newPassword: string;
|
|
79
|
+
/**
|
|
80
|
+
* Which ladder rung `codeInput` currently looks like: "code" is the patient's own recovery code,
|
|
81
|
+
* "grant" is the one-time code a clinician reads to a patient who has lost theirs. Read off the
|
|
82
|
+
* pasted/typed string's shape (`detectRecoveryKind`) — the two never collide by construction, so
|
|
83
|
+
* there is nothing for the caller to pick.
|
|
84
|
+
*/
|
|
85
|
+
readonly kind: "code" | "grant";
|
|
86
|
+
|
|
87
|
+
/** The freshly minted recovery code, shown once in the Account modal and never fetched again. */
|
|
88
|
+
readonly regeneratedCode: string | null;
|
|
89
|
+
/** Whether the org still holds a recovery envelope for this vault, per the server. */
|
|
90
|
+
readonly orgHeld: boolean;
|
|
91
|
+
readonly orgRevokedAt: string | null;
|
|
92
|
+
/** The two-step confirm on removing the org recovery key. */
|
|
93
|
+
revokeConfirm: boolean;
|
|
94
|
+
/** Dismissed for this session only — the nudge is per-session, not a stored preference. */
|
|
95
|
+
nudgeDismissed: boolean;
|
|
96
|
+
|
|
97
|
+
/** Provider action: mint a one-time code to read to a locked-out patient. */
|
|
98
|
+
issueForPatient(p: P): Promise<void>;
|
|
99
|
+
closeIssueDialog(): void;
|
|
100
|
+
/** Mint a replacement recovery code for one's own account. */
|
|
101
|
+
regenerate(): Promise<void>;
|
|
102
|
+
/** Lock screen: redeem whichever code kind is selected, then enter. */
|
|
103
|
+
recover(): Promise<void>;
|
|
104
|
+
/** Give up org recovery: from here the password is the only key to the record. */
|
|
105
|
+
revokeOrgKey(): Promise<void>;
|
|
106
|
+
/** Adopt the server's answer about the org envelope. Called when the Account modal loads. */
|
|
107
|
+
applyPrincipals(p: { envelopePrincipalIds: string[]; orgAccountId: string; orgRecoveryRevokedAt: string | null }): void;
|
|
108
|
+
/**
|
|
109
|
+
* Closing the Account modal drops the once-shown code and rearms the revoke confirm — but leaves the
|
|
110
|
+
* lock-screen fields alone, which is why this is not `reset()`. Reopening the modal must not present
|
|
111
|
+
* a half-pressed "Yes, remove it", and the code is unrecoverable anyway once the modal is gone.
|
|
112
|
+
*/
|
|
113
|
+
closeAccountBlocks(): void;
|
|
114
|
+
/** Clears everything on sign-out. */
|
|
115
|
+
reset(): void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createRecoveryController<P extends RecoveryPatient = RecoveryPatient>(
|
|
119
|
+
deps: RecoveryControllerDeps,
|
|
120
|
+
): RecoveryController<P> {
|
|
121
|
+
let issuedFor = $state<P | null>(null);
|
|
122
|
+
let issuedCode = $state<string | null>(null);
|
|
123
|
+
let issuedExpiresAt = $state<string | null>(null);
|
|
124
|
+
let issuing = $state(false);
|
|
125
|
+
let issueError = $state<string | null>(null);
|
|
126
|
+
|
|
127
|
+
let recoverMode = $state(false);
|
|
128
|
+
let codeInput = $state("");
|
|
129
|
+
let newPassword = $state("");
|
|
130
|
+
|
|
131
|
+
let regeneratedCode = $state<string | null>(null);
|
|
132
|
+
let orgHeld = $state(false);
|
|
133
|
+
let orgRevokedAt = $state<string | null>(null);
|
|
134
|
+
let revokeConfirm = $state(false);
|
|
135
|
+
let nudgeDismissed = $state(false);
|
|
136
|
+
|
|
137
|
+
/** The Account modal's busy/error envelope, whose line belongs to App. */
|
|
138
|
+
async function inAccount(body: () => Promise<void>): Promise<void> {
|
|
139
|
+
deps.setAccountBusy(true);
|
|
140
|
+
try {
|
|
141
|
+
await body();
|
|
142
|
+
} catch (e) {
|
|
143
|
+
deps.reportAccountError((e as Error).message);
|
|
144
|
+
} finally {
|
|
145
|
+
deps.setAccountBusy(false);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
get issuedFor() {
|
|
151
|
+
return issuedFor;
|
|
152
|
+
},
|
|
153
|
+
get issuedCode() {
|
|
154
|
+
return issuedCode;
|
|
155
|
+
},
|
|
156
|
+
get issuedExpiresAt() {
|
|
157
|
+
return issuedExpiresAt;
|
|
158
|
+
},
|
|
159
|
+
get issuing() {
|
|
160
|
+
return issuing;
|
|
161
|
+
},
|
|
162
|
+
get issueError() {
|
|
163
|
+
return issueError;
|
|
164
|
+
},
|
|
165
|
+
get recoverMode() {
|
|
166
|
+
return recoverMode;
|
|
167
|
+
},
|
|
168
|
+
set recoverMode(v: boolean) {
|
|
169
|
+
recoverMode = v;
|
|
170
|
+
},
|
|
171
|
+
get codeInput() {
|
|
172
|
+
return codeInput;
|
|
173
|
+
},
|
|
174
|
+
set codeInput(v: string) {
|
|
175
|
+
codeInput = v;
|
|
176
|
+
},
|
|
177
|
+
get newPassword() {
|
|
178
|
+
return newPassword;
|
|
179
|
+
},
|
|
180
|
+
set newPassword(v: string) {
|
|
181
|
+
newPassword = v;
|
|
182
|
+
},
|
|
183
|
+
get kind() {
|
|
184
|
+
return detectRecoveryKind(codeInput);
|
|
185
|
+
},
|
|
186
|
+
get regeneratedCode() {
|
|
187
|
+
return regeneratedCode;
|
|
188
|
+
},
|
|
189
|
+
get orgHeld() {
|
|
190
|
+
return orgHeld;
|
|
191
|
+
},
|
|
192
|
+
get orgRevokedAt() {
|
|
193
|
+
return orgRevokedAt;
|
|
194
|
+
},
|
|
195
|
+
get revokeConfirm() {
|
|
196
|
+
return revokeConfirm;
|
|
197
|
+
},
|
|
198
|
+
set revokeConfirm(v: boolean) {
|
|
199
|
+
revokeConfirm = v;
|
|
200
|
+
},
|
|
201
|
+
get nudgeDismissed() {
|
|
202
|
+
return nudgeDismissed;
|
|
203
|
+
},
|
|
204
|
+
set nudgeDismissed(v: boolean) {
|
|
205
|
+
nudgeDismissed = v;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
async issueForPatient(p: P) {
|
|
209
|
+
if (!deps.session.providerKey) return;
|
|
210
|
+
issuedFor = p;
|
|
211
|
+
issuedCode = null;
|
|
212
|
+
issuedExpiresAt = null;
|
|
213
|
+
issueError = null;
|
|
214
|
+
issuing = true;
|
|
215
|
+
try {
|
|
216
|
+
const r = await issueRecoveryCode(p.patientAccountId, p.envelope, deps.session.providerKey);
|
|
217
|
+
issuedCode = r.code;
|
|
218
|
+
issuedExpiresAt = r.expiresAt;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
issueError = (e as Error).message;
|
|
221
|
+
} finally {
|
|
222
|
+
issuing = false;
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
closeIssueDialog() {
|
|
227
|
+
// Cleared on close, not merely hidden: the code is shown once, and leaving it in component state
|
|
228
|
+
// would keep it alive in memory for the rest of the session for no reason.
|
|
229
|
+
issuedFor = null;
|
|
230
|
+
issuedCode = null;
|
|
231
|
+
issuedExpiresAt = null;
|
|
232
|
+
issueError = null;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async regenerate() {
|
|
236
|
+
deps.reportAccountError(null);
|
|
237
|
+
const pk = deps.ensureExtractableKey();
|
|
238
|
+
if (!pk) return;
|
|
239
|
+
await inAccount(async () => {
|
|
240
|
+
regeneratedCode = await regenerateRecoveryCode(pk);
|
|
241
|
+
await deps.refreshAccount();
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async recover() {
|
|
246
|
+
const email = deps.getEmail();
|
|
247
|
+
if (!email || !codeInput.trim()) return;
|
|
248
|
+
deps.setUnlocking(true);
|
|
249
|
+
deps.reportError(null);
|
|
250
|
+
try {
|
|
251
|
+
const r =
|
|
252
|
+
detectRecoveryKind(codeInput) === "grant"
|
|
253
|
+
? await redeemRecoveryCode(email, codeInput.trim(), newPassword)
|
|
254
|
+
: await recoverAccount(email, codeInput.trim(), newPassword);
|
|
255
|
+
await deps.enterRecovered(r);
|
|
256
|
+
// Only on success: a failed attempt keeps the typed code, because the usual failure is a
|
|
257
|
+
// mistyped character and clearing the form would make the retry a re-entry.
|
|
258
|
+
recoverMode = false;
|
|
259
|
+
codeInput = "";
|
|
260
|
+
newPassword = "";
|
|
261
|
+
} catch (e) {
|
|
262
|
+
deps.reportError((e as Error).message);
|
|
263
|
+
} finally {
|
|
264
|
+
deps.setUnlocking(false);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async revokeOrgKey() {
|
|
269
|
+
deps.reportAccountError(null);
|
|
270
|
+
await inAccount(async () => {
|
|
271
|
+
const r = await revokeRecoveryEnvelope();
|
|
272
|
+
orgHeld = false;
|
|
273
|
+
orgRevokedAt = r.revokedAt;
|
|
274
|
+
revokeConfirm = false;
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
closeAccountBlocks() {
|
|
279
|
+
regeneratedCode = null;
|
|
280
|
+
revokeConfirm = false;
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
applyPrincipals(p) {
|
|
284
|
+
orgHeld = p.envelopePrincipalIds.includes(p.orgAccountId);
|
|
285
|
+
orgRevokedAt = p.orgRecoveryRevokedAt;
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
reset() {
|
|
289
|
+
issuedFor = null;
|
|
290
|
+
issuedCode = null;
|
|
291
|
+
issuedExpiresAt = null;
|
|
292
|
+
issueError = null;
|
|
293
|
+
regeneratedCode = null;
|
|
294
|
+
recoverMode = false;
|
|
295
|
+
codeInput = "";
|
|
296
|
+
newPassword = "";
|
|
297
|
+
revokeConfirm = false;
|
|
298
|
+
nudgeDismissed = false;
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// How a session starts, and whose record it is on.
|
|
2
|
+
//
|
|
3
|
+
// App's fifth and last controller extraction. This is the cluster that answers "who is
|
|
4
|
+
// signed in": the cold-load resume, the owner/provider/support routing that follows a login, and the
|
|
5
|
+
// clinician roster that a provider drills into. It had no unit test at all, and two of its rules are
|
|
6
|
+
// security properties rather than conveniences — a stored account key whose session has expired is
|
|
7
|
+
// DROPPED rather than kept, and the resume marker is only written once the key is actually stored, so
|
|
8
|
+
// a browser that refuses persistence does not advertise a resume it cannot perform.
|
|
9
|
+
//
|
|
10
|
+
// Same getter-parameterized factory shape as the other four. The vault itself stays with App, per
|
|
11
|
+
// vault-session.svelte.ts's own note: the key material's lifetime is the security property, and the
|
|
12
|
+
// plaintext record cannot outlive it because it cannot be re-read without one.
|
|
13
|
+
|
|
14
|
+
import type { VaultSession, VaultEntry } from "./vault-session.svelte";
|
|
15
|
+
import { getAccountKey, putAccountKey, clearAccountKey } from "@tinytars/vault/key-store";
|
|
16
|
+
import { unwrapDEKWithPrivateKey } from "@tinytars/vault/crypto";
|
|
17
|
+
import { b64ToBytes } from "@tinytars/vault/base64";
|
|
18
|
+
import { resumeSession, bootstrapGoogleSession, getMyAccount } from "@tinytars/vault/auth-client";
|
|
19
|
+
import { revokeProvider } from "@tinytars/vault/auth-grants";
|
|
20
|
+
|
|
21
|
+
/** Non-sensitive flag marking that a session may be resumable on reload. */
|
|
22
|
+
export const RESUME_MARKER = "hd_resume";
|
|
23
|
+
|
|
24
|
+
export interface RosterPatient extends VaultEntry {
|
|
25
|
+
/** The provider can DELETE /api/providers/{linkId} to drop this patient. */
|
|
26
|
+
linkId: string;
|
|
27
|
+
vaultId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a login, a resume or a recovery all hand back. */
|
|
31
|
+
export interface EnteredAccount {
|
|
32
|
+
vaultId: string | null;
|
|
33
|
+
r2Key: string | null;
|
|
34
|
+
privateKey: CryptoKey;
|
|
35
|
+
dek: CryptoKey | null;
|
|
36
|
+
rotationPending?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RosterSessionDeps {
|
|
40
|
+
session: VaultSession;
|
|
41
|
+
reportError: (message: string | null) => void;
|
|
42
|
+
/** The lock screen's fields, cleared the moment a session begins. */
|
|
43
|
+
clearLoginForm: () => void;
|
|
44
|
+
/** Owner path: decrypt and install the owner's own vault. App owns the decrypted record. */
|
|
45
|
+
openOwnVault: (r2Key: string, dek: CryptoKey) => Promise<void>;
|
|
46
|
+
/** Owner path, after the vault is open: the recovery-envelope backfill, account info, any re-key. */
|
|
47
|
+
afterOwnerEnter: (rotationPending: boolean) => Promise<void>;
|
|
48
|
+
/** A support provider gets the audited console; a clinician gets the roster and the refresh token. */
|
|
49
|
+
beginSupportSession: () => Promise<void>;
|
|
50
|
+
beginClinicianSession: () => Promise<void>;
|
|
51
|
+
/** The drill-in: unwrap, decrypt, and move the app to this patient. One body, both consoles. */
|
|
52
|
+
openPatientVault: (entry: VaultEntry, providerKey: CryptoKey) => Promise<void>;
|
|
53
|
+
/** Drops the decrypted record. Paired with session.close(), which drops the key that made it. */
|
|
54
|
+
closeVault: () => void;
|
|
55
|
+
/** The roster-removal confirmation. Injected so the dialog stays the host's. */
|
|
56
|
+
confirmRemoval: (label: string) => boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RosterSession {
|
|
60
|
+
/** True for a clinician or support account — one without a vault of its own. */
|
|
61
|
+
readonly isProvider: boolean;
|
|
62
|
+
/** The clinician's roster. Empty in an owner session. */
|
|
63
|
+
readonly patients: RosterPatient[];
|
|
64
|
+
/** Who is being viewed, when a provider has drilled in. Null on one's own record. */
|
|
65
|
+
readonly enteredPatient: { email: string | null; displayName: string } | null;
|
|
66
|
+
/**
|
|
67
|
+
* True while the cold-load resume is still deciding, so the lock screen does not flash. Starts
|
|
68
|
+
* false and is raised by `bootResume` itself — synchronously, before its first await — so the host
|
|
69
|
+
* has no window in which it must own a second copy of this flag. App previously did own one, and
|
|
70
|
+
* that copy was never lowered, leaving every reloaded page on "Restoring your session…" forever.
|
|
71
|
+
*/
|
|
72
|
+
readonly resuming: boolean;
|
|
73
|
+
|
|
74
|
+
/** Routes a login/resume/recovery result to the owner, clinician or support path. */
|
|
75
|
+
enterAccount(r: EnteredAccount): Promise<void>;
|
|
76
|
+
/** The cold-load resume, dispatched by the marker so we only probe when there is one. */
|
|
77
|
+
bootResume(): Promise<void>;
|
|
78
|
+
/** Stores the account key and marks the session resumable — in that order, and only in that order. */
|
|
79
|
+
persistSessionKey(privateKey: CryptoKey): Promise<void>;
|
|
80
|
+
loadPatients(): Promise<void>;
|
|
81
|
+
removeFromRoster(p: RosterPatient): Promise<void>;
|
|
82
|
+
/** The label a roster row shows, which is the email whenever the "name" is a signup placeholder. */
|
|
83
|
+
label(p: RosterPatient): string;
|
|
84
|
+
enterPatient(p: RosterPatient): Promise<void>;
|
|
85
|
+
/** Leaves a patient's record without leaving the provider's session. */
|
|
86
|
+
backToRoster(): void;
|
|
87
|
+
/** Records who was entered. Called by the host once the vault is actually installed. */
|
|
88
|
+
setEnteredPatient(who: { email: string | null; displayName: string } | null): void;
|
|
89
|
+
reset(): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createRosterSession(deps: RosterSessionDeps): RosterSession {
|
|
93
|
+
let isProvider = $state(false);
|
|
94
|
+
let patients = $state<RosterPatient[]>([]);
|
|
95
|
+
let enteredPatient = $state<{ email: string | null; displayName: string } | null>(null);
|
|
96
|
+
let resuming = $state(false);
|
|
97
|
+
|
|
98
|
+
async function loadPatients(): Promise<void> {
|
|
99
|
+
const res = await fetch("/api/providers/patients", { cache: "no-store" });
|
|
100
|
+
// A failed fetch leaves the previous roster standing rather than blanking it. A provider whose
|
|
101
|
+
// network blipped keeps the list they were working from; an empty roster would read as "you have
|
|
102
|
+
// no patients", which is a different and wrong statement.
|
|
103
|
+
if (res.ok) patients = ((await res.json()).patients ?? []) as RosterPatient[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function enterAccount(r: EnteredAccount): Promise<void> {
|
|
107
|
+
deps.clearLoginForm();
|
|
108
|
+
if (r.vaultId && r.r2Key && r.dek) {
|
|
109
|
+
deps.session.setOwnerKey(r.privateKey);
|
|
110
|
+
isProvider = false;
|
|
111
|
+
await deps.openOwnVault(r.r2Key, r.dek);
|
|
112
|
+
await deps.afterOwnerEnter(r.rotationPending ?? false);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
deps.session.setProviderKey(r.privateKey);
|
|
116
|
+
isProvider = true;
|
|
117
|
+
const acct = await getMyAccount();
|
|
118
|
+
if (acct.providerKind === "support") await deps.beginSupportSession();
|
|
119
|
+
else {
|
|
120
|
+
await loadPatients();
|
|
121
|
+
await deps.beginClinicianSession();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Password/passkey: the account private key is a non-extractable CryptoKey persisted in IndexedDB.
|
|
127
|
+
* With a valid session, re-fetch the vault location and owner envelope, unwrap the DEK with the
|
|
128
|
+
* stored key, and enter. A stored key whose session has expired is dropped, not kept — otherwise a
|
|
129
|
+
* shared machine keeps a key for an account nobody is signed into any more.
|
|
130
|
+
*/
|
|
131
|
+
async function resumeFromStoredKey(): Promise<boolean> {
|
|
132
|
+
let storedKey: CryptoKey | null = null;
|
|
133
|
+
try {
|
|
134
|
+
storedKey = await getAccountKey();
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (!storedKey) return false;
|
|
139
|
+
const r = await resumeSession();
|
|
140
|
+
if (!r) {
|
|
141
|
+
try {
|
|
142
|
+
await clearAccountKey();
|
|
143
|
+
} catch {
|
|
144
|
+
/* best-effort */
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const dek = r.ownerEnvelope
|
|
149
|
+
? await unwrapDEKWithPrivateKey(b64ToBytes(r.ownerEnvelope.wrappedDEK), r.ownerEnvelope.ephemeralPublicKeyJwk, storedKey)
|
|
150
|
+
: null;
|
|
151
|
+
await enterAccount({ vaultId: r.vaultId, r2Key: r.r2Key, privateKey: storedKey, dek, rotationPending: r.rotationPending });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Google: no client-held key (server custody). A non-Google session 401s and stays locked. */
|
|
156
|
+
async function resumeGoogle(): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
await enterAccount(await bootstrapGoogleSession());
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
get isProvider() {
|
|
167
|
+
return isProvider;
|
|
168
|
+
},
|
|
169
|
+
get patients() {
|
|
170
|
+
return patients;
|
|
171
|
+
},
|
|
172
|
+
get enteredPatient() {
|
|
173
|
+
return enteredPatient;
|
|
174
|
+
},
|
|
175
|
+
get resuming() {
|
|
176
|
+
return resuming;
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
enterAccount,
|
|
180
|
+
loadPatients,
|
|
181
|
+
|
|
182
|
+
async bootResume() {
|
|
183
|
+
resuming = true;
|
|
184
|
+
try {
|
|
185
|
+
const marker = localStorage.getItem(RESUME_MARKER);
|
|
186
|
+
if (marker === "key" && (await resumeFromStoredKey())) return;
|
|
187
|
+
if (marker === "google" && (await resumeGoogle())) return;
|
|
188
|
+
// Nothing resumed — clear the marker so the next load goes straight to the lock screen
|
|
189
|
+
// instead of paying for a probe that already failed once.
|
|
190
|
+
localStorage.removeItem(RESUME_MARKER);
|
|
191
|
+
} catch {
|
|
192
|
+
localStorage.removeItem(RESUME_MARKER);
|
|
193
|
+
} finally {
|
|
194
|
+
resuming = false;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async persistSessionKey(privateKey: CryptoKey) {
|
|
199
|
+
try {
|
|
200
|
+
await putAccountKey(privateKey);
|
|
201
|
+
// Only once the key is genuinely stored. Setting the marker first would promise a resume that
|
|
202
|
+
// private browsing cannot deliver, and the next load would probe, fail, and clear it anyway.
|
|
203
|
+
localStorage.setItem(RESUME_MARKER, "key");
|
|
204
|
+
} catch {
|
|
205
|
+
/* persistence unavailable; the session still works this page, it just re-auths on refresh */
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
// Dropping a patient deletes the provider's envelope; the patient must re-grant to restore
|
|
210
|
+
// access. The patient's own vault is untouched.
|
|
211
|
+
async removeFromRoster(p: RosterPatient) {
|
|
212
|
+
if (!deps.confirmRemoval(p.displayName)) return;
|
|
213
|
+
deps.reportError(null);
|
|
214
|
+
try {
|
|
215
|
+
await revokeProvider(p.linkId);
|
|
216
|
+
await loadPatients();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
deps.reportError((e as Error).message);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
// Names are auto-derived at signup (password/passkey → the email local-part;
|
|
223
|
+
// Google-without-name → "New member"), so when the "name" is really a placeholder we show the
|
|
224
|
+
// full email, which actually tells two patients apart.
|
|
225
|
+
label(p: RosterPatient): string {
|
|
226
|
+
const name = p.displayName?.trim() ?? "";
|
|
227
|
+
if (!p.email) return name;
|
|
228
|
+
// Exact match against the verbatim local-part — that is the untouched auto value. A
|
|
229
|
+
// capitalised or edited name like "Liz" is a real, human-set one and stands.
|
|
230
|
+
const localPart = p.email.split("@")[0];
|
|
231
|
+
return name === "" || name === "New member" || name === localPart ? p.email : name;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async enterPatient(p: RosterPatient) {
|
|
235
|
+
const providerKey = deps.session.providerKey;
|
|
236
|
+
if (!providerKey) return;
|
|
237
|
+
deps.reportError(null);
|
|
238
|
+
try {
|
|
239
|
+
await deps.openPatientVault(p, providerKey);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
deps.reportError((e as Error).message);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
backToRoster() {
|
|
246
|
+
deps.closeVault();
|
|
247
|
+
deps.session.close();
|
|
248
|
+
enteredPatient = null;
|
|
249
|
+
// The roster, the provider key and the provider flag all stay — this provider is still signed in
|
|
250
|
+
// and still needs their key to open the next patient.
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
setEnteredPatient(who) {
|
|
254
|
+
enteredPatient = who;
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
reset() {
|
|
258
|
+
isProvider = false;
|
|
259
|
+
patients = [];
|
|
260
|
+
enteredPatient = null;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|