@rightkit/onboarding 0.6.1 → 0.6.3
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/README.md +33 -3
- package/dist/Onboarding.d.ts +6 -2
- package/dist/Onboarding.js +60 -22
- package/dist/adapters.d.ts +59 -0
- package/dist/adapters.js +93 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/legal-receipt.d.ts +45 -0
- package/dist/legal-receipt.js +91 -0
- package/dist/steps.js +2 -2
- package/dist/types.d.ts +62 -4
- package/onboarding.css +1 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ First-run onboarding framework for Right Suite apps. The legal agreement gate co
|
|
|
13
13
|
|
|
14
14
|
| Import | Contents |
|
|
15
15
|
|---|---|
|
|
16
|
-
| `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep`, `signInStep` (Google/Apple/OAuth buttons that call the shared `rightkit-oauth` owner; copy says "Opens your browser"; never an in-app webview, bogusyogi/rightkit#279); types from `types.ts`, `ModelsStepOptions` |
|
|
16
|
+
| `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep`, `signInStep` (Google/Apple/OAuth buttons that call the shared `rightkit-oauth` owner; copy says "Opens your browser"; never an in-app webview, bogusyogi/rightkit#279); native receipt: `createNativeLegalReceiptPort`, `LEGAL_RECEIPT_COMMANDS`, `reopenLegalAcceptance`, `resetLegalAcceptance`; adapters: `licenseBackendFromView`, `licenseStateFromView`, `signInBackendFromOAuth`; types from `types.ts` (including `LegalReceiptPort`, `LegalReceiptCreateRequest`, `DeclaredLegalDocument`), `LicenseStepCopy`, `LicenseStepOptions`, `ModelsStepOptions`, `LicenseViewClient`, `LicenseViewLike`, `LicenseViewBackendOptions`, `OAuthSignInPort`, `LegalReceiptInvoke`, `NativeLegalReceiptPortOptions` |
|
|
17
17
|
| `@rightkit/onboarding/onboarding.css` | Stylesheet |
|
|
18
18
|
|
|
19
19
|
## Peer dependencies
|
|
@@ -21,7 +21,7 @@ First-run onboarding framework for Right Suite apps. The legal agreement gate co
|
|
|
21
21
|
| Peer | Range |
|
|
22
22
|
|---|---|
|
|
23
23
|
| `@rightkit/react-shell` | `>=0.2.0 <1` |
|
|
24
|
-
| `@rightkit/legal-ui` | `>=0.1
|
|
24
|
+
| `@rightkit/legal-ui` | `>=0.4.1 <1` |
|
|
25
25
|
| `react` | `>=18 <21` |
|
|
26
26
|
|
|
27
27
|
## Example
|
|
@@ -45,7 +45,37 @@ import "@rightkit/onboarding/onboarding.css";
|
|
|
45
45
|
|
|
46
46
|
Free or open-source apps without a legal gate pass `legalMode="none"` (no legal step, `legal` not needed, `appName` for the rail) or `legalMode="notice"` (no gate or acceptance; a first Notice step lists `legal.documents` as links). The default is `"gate"`.
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
`layout` is `"window"` by default (the surface fills the viewport, as in a dedicated onboarding window). `layout="contained"` removes the viewport minimum height so the host sizes the surface; it is passed to the legal gate as well (`@rightkit/legal-ui` `>=0.4.1`).
|
|
49
|
+
|
|
50
|
+
Acceptance is stored as `legalAcceptance: { version, documentsHash, acceptedAt, eligibilityBasis }`. An app can pass `legalReceipt` and keep its own receipt as the source of truth (`read()` is consulted on load and overrides the settings copy). The receipt is recorded one of two ways:
|
|
51
|
+
|
|
52
|
+
- **Native (preferred): `create(request)`.** The renderer declares `{ version, documentsHash, documents, eligibilityBasis, surface: "first_run" }` and no time; the native service stamps its own UTC `acceptedAt`, compares the declaration with the manifest it embeds, stores the receipt and returns the record. `receiptDocuments` sets the declared document set (default: the gate's titles and URLs; document bodies are never sent). Build the declaration with `acceptanceDeclaration(manifest)` and the digest with `materialAcceptanceDigestAsync(manifest)` from `@rightkit/legal/browser`. A throw, an invalid record, or a record for another version, digest or eligibility keeps the gate closed with an alert; if only the settings save failed afterwards, a retry reuses the receipt already created instead of stamping a second one.
|
|
53
|
+
- **Legacy: `write(record)` (deprecated).** The renderer builds the record, including `acceptedAt` from the webview clock, and the app stores it. It is used only when the port has no `create`. A port with neither cannot accept.
|
|
54
|
+
|
|
55
|
+
`createNativeLegalReceiptPort({ invoke })` is the port over the host's legal-receipt service (`rightkit_legal_receipt_read`, `rightkit_legal_receipt_create`, `rightkit_legal_receipt_reset`; `LEGAL_RECEIPT_COMMANDS`, override with `commands`). The native side registers those commands once under the host caller policy (`rightkit-tauri-shell` host services, trusted for the window that shows the gate) and stores the receipt under the app root. Command names are provisional until that service is published. A persistence failure, in any path, keeps the gate closed, and a bare legacy `legalAccepted` flag with no record never passes.
|
|
56
|
+
|
|
57
|
+
`reopenLegalAcceptance({ settings })` makes the gate show again as a re-acceptance on the next mount (the old record stays until the user agrees; progress is kept). `resetLegalAcceptance({ settings, legalReceipt? })` clears the receipt first (`legalReceipt.reset`, required when a port is given) and then the settings copy, so the next mount shows the first-run gate.
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { createNativeLegalReceiptPort } from "@rightkit/onboarding";
|
|
61
|
+
import { acceptanceDeclaration, materialAcceptanceDigestAsync } from "@rightkit/legal/browser";
|
|
62
|
+
import { invoke } from "@tauri-apps/api/core";
|
|
63
|
+
|
|
64
|
+
const declaration = acceptanceDeclaration(manifest);
|
|
65
|
+
<Onboarding
|
|
66
|
+
legalReceipt={createNativeLegalReceiptPort({ invoke })}
|
|
67
|
+
requiredVersion={declaration.acceptanceVersion}
|
|
68
|
+
requiredDocumentsHash={await materialAcceptanceDigestAsync(manifest)}
|
|
69
|
+
receiptDocuments={declaration.documents}
|
|
70
|
+
/* ... */
|
|
71
|
+
/>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Licence and sign-in adapters
|
|
75
|
+
|
|
76
|
+
`licenseBackendFromView(client, options)` turns the native licence client (`createLicenseClient` from `@rightkit/license` 0.3.0, structurally: this package does not import it) into the `LicenseBackend` the licence step takes. The lifecycle decides the status (`grace`, `lifetime` and `verification_required` show as active; `migration_required`, `clock_repair_required`, `revoked` and `invalid` as invalid; anything unknown is invalid, never active). Pass `messageText: licenseMessage` for the detail text, `trialEmail` (where the trial email comes from; a trial is offered only with it and `client.requestTrial`), and optionally `purchaseUrl`, `openExternal`, `labels`. The step displays what the native service reports and decides nothing.
|
|
77
|
+
|
|
78
|
+
`signIn` adds the built-in sign-in step after the licence step and is handed to every step as `ctx.signIn`. `signInBackendFromOAuth(port, providers)` builds it over the app's `rightkit-oauth` commands (`port.status()` and `port.signIn(provider)`, which call `OAuthClient::sign_in_with_browser` and open the default browser); only `signedIn` and `account` are read from the answers, and a sign-in that resolves without `signedIn: true` rejects so the step shows its retry message. `signInStep(backend)` still works as an app step. Steps can set `canContinue(context)`, `onContinue(context)` (a veto: `false` or a throw keeps the step), `continueLabel`, and `skippable` (a per-step Skip button). `ctx.setResult(value)` stores a value that `onComplete({ completed, skipped, steps })` receives. `labels` overrides the button and status text, and `licenseStep(backend, { copy })` overrides the licence copy.
|
|
49
79
|
|
|
50
80
|
## When the legal gate re-prompts
|
|
51
81
|
|
package/dist/Onboarding.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import type { OnboardingProps, PersistedOnboardingState } from "./types.js";
|
|
1
|
+
import type { LegalAcceptanceRecord, OnboardingProps, PersistedOnboardingState } from "./types.js";
|
|
2
|
+
/** @internal Validates one stored or returned acceptance record; `undefined` when it is not a well-formed record. */
|
|
3
|
+
export declare function readAcceptance(value: unknown): LegalAcceptanceRecord | undefined;
|
|
4
|
+
/** @internal Normalises the persisted `onboarding.state` value. */
|
|
5
|
+
export declare function readState(value: unknown): PersistedOnboardingState;
|
|
2
6
|
/** True when the stored acceptance satisfies the host's requirement. */
|
|
3
7
|
export declare function isLegalAccepted(state: PersistedOnboardingState, requiredVersion?: string, custom?: (state: PersistedOnboardingState) => boolean, requiredDocumentsHash?: string): boolean;
|
|
4
8
|
export declare function onboardingStateKey(): string;
|
|
5
|
-
export declare function Onboarding({ settings, legal, legalMode, appName, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt, labels: labelOverrides }: OnboardingProps): import("react").JSX.Element | null;
|
|
9
|
+
export declare function Onboarding({ settings, legal, legalMode, appName, steps, permissions, models, license, signIn, receiptDocuments, onComplete, onSkip, className, layout, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt, labels: labelOverrides }: OnboardingProps): import("react").JSX.Element | null;
|
package/dist/Onboarding.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { LegalGate } from "@rightkit/legal-ui";
|
|
4
4
|
import { Button } from "@rightkit/react-shell/react";
|
|
5
5
|
import { cx } from "./ui.js";
|
|
6
|
-
import { licenseStep, modelsStep, permissionsStep } from "./steps.js";
|
|
6
|
+
import { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
|
|
7
7
|
const KEY = "onboarding.state";
|
|
8
8
|
const INITIAL = { version: 1, currentStep: 0, completed: [], skipped: false, legalAccepted: false };
|
|
9
9
|
const DEFAULT_LABELS = { skip: "Set up later", skipStep: "Skip", back: "Back", continue: "Continue", finish: "Finish setup", loading: "Loading setup…" };
|
|
@@ -14,7 +14,8 @@ function isCanonicalUtc(value) {
|
|
|
14
14
|
const parsed = Date.parse(value);
|
|
15
15
|
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
/** @internal Validates one stored or returned acceptance record; `undefined` when it is not a well-formed record. */
|
|
18
|
+
export function readAcceptance(value) {
|
|
18
19
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
19
20
|
return undefined;
|
|
20
21
|
const r = value;
|
|
@@ -28,7 +29,8 @@ function readAcceptance(value) {
|
|
|
28
29
|
return undefined;
|
|
29
30
|
return { version: r.version, documentsHash: r.documentsHash, acceptedAt: r.acceptedAt, eligibilityBasis: r.eligibilityBasis };
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
/** @internal Normalises the persisted `onboarding.state` value. */
|
|
33
|
+
export function readState(value) {
|
|
32
34
|
if (!value || typeof value !== "object")
|
|
33
35
|
return INITIAL;
|
|
34
36
|
const candidate = value;
|
|
@@ -44,6 +46,7 @@ function readState(value) {
|
|
|
44
46
|
legalAccepted: legalAcceptance !== undefined || (!hasStoredAcceptance && candidate.legalAccepted === true),
|
|
45
47
|
eligibilityBasis: legalAcceptance?.eligibilityBasis ?? (candidate.eligibilityBasis === "individual" || candidate.eligibilityBasis === "enterprise" ? candidate.eligibilityBasis : undefined),
|
|
46
48
|
...(legalAcceptance ? { legalAcceptance } : {}),
|
|
49
|
+
...(candidate.legalReopened === true ? { legalReopened: true } : {}),
|
|
47
50
|
...(Array.isArray(candidate.skippedSteps) ? { skippedSteps: candidate.skippedSteps.filter((x) => typeof x === "string") } : {}),
|
|
48
51
|
};
|
|
49
52
|
}
|
|
@@ -56,6 +59,9 @@ function withReceipt(state, record) {
|
|
|
56
59
|
}
|
|
57
60
|
/** True when the stored acceptance satisfies the host's requirement. */
|
|
58
61
|
export function isLegalAccepted(state, requiredVersion, custom, requiredDocumentsHash) {
|
|
62
|
+
// A reopened agreement (`reopenLegalAcceptance`) is not accepted until the user agrees again, whatever the host's check says.
|
|
63
|
+
if (state.legalReopened === true)
|
|
64
|
+
return false;
|
|
59
65
|
if (custom)
|
|
60
66
|
return custom(state);
|
|
61
67
|
// A stored record whose timestamp is not canonical UTC is not an acceptance,
|
|
@@ -104,7 +110,7 @@ function NoticeList({ documents }) {
|
|
|
104
110
|
return _jsx("p", { className: "rk-onboarding__muted", children: "No notices to show." });
|
|
105
111
|
return _jsx("ul", { className: "rk-onboarding__notices", children: documents.map((d) => _jsx("li", { children: d.onOpen ? _jsx("button", { type: "button", onClick: d.onOpen, children: d.title }) : d.url ? _jsx("a", { href: d.url, target: "_blank", rel: "noreferrer", children: d.title }) : d.title }, d.title)) });
|
|
106
112
|
}
|
|
107
|
-
export function Onboarding({ settings, legal, legalMode = "gate", appName, steps = [], permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt, labels: labelOverrides }) {
|
|
113
|
+
export function Onboarding({ settings, legal, legalMode = "gate", appName, steps = [], permissions, models, license, signIn, receiptDocuments, onComplete, onSkip, className, layout = "window", requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt, labels: labelOverrides }) {
|
|
108
114
|
if (legalMode === "gate" && !legal)
|
|
109
115
|
throw new Error("Onboarding: `legal` is required when legalMode is \"gate\"");
|
|
110
116
|
const L = { ...DEFAULT_LABELS, ...labelOverrides };
|
|
@@ -121,6 +127,8 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
|
|
|
121
127
|
/** Values passed to `ctx.setResult`, keyed by step id; handed to `onComplete`. */
|
|
122
128
|
const stepResults = useRef({});
|
|
123
129
|
const [continuing, setContinuing] = useState(false);
|
|
130
|
+
/** A receipt the native service already created for this agreement when saving the settings failed; a retry reuses it instead of stamping a second one. */
|
|
131
|
+
const createdReceipt = useRef(null);
|
|
124
132
|
/** Persists a patch; throws when the settings backend rejects it. */
|
|
125
133
|
const persist = useCallback(async (patch) => {
|
|
126
134
|
setSaving(true);
|
|
@@ -183,8 +191,9 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
|
|
|
183
191
|
...(permissions ? [permissionsStep(permissions)] : []),
|
|
184
192
|
...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro: modelsIntro, startGuard: modelsStarted })] : []),
|
|
185
193
|
...(license ? [licenseStep(license)] : []),
|
|
194
|
+
...(signIn ? [signInStep(signIn)] : []),
|
|
186
195
|
...steps,
|
|
187
|
-
], [gated, legalMode, legal?.documents, license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
|
|
196
|
+
], [gated, legalMode, legal?.documents, license, signIn, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
|
|
188
197
|
const activeState = state ?? INITIAL;
|
|
189
198
|
const accepted = !gated || isLegalAccepted(activeState, requiredVersion, isAccepted, requiredDocumentsHash);
|
|
190
199
|
const needsGate = state !== null && !accepted;
|
|
@@ -216,7 +225,7 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
|
|
|
216
225
|
return () => { live = false; };
|
|
217
226
|
}, [needsGate, gateCleared, beforeLegalGate]);
|
|
218
227
|
const setResult = useCallback((value) => { stepResults.current[activeId] = value; }, [activeId]);
|
|
219
|
-
const context = { state: activeState, settings, setState: saveStep, permissions, models, license, ready: readyMap, setReady, setResult };
|
|
228
|
+
const context = { state: activeState, settings, setState: saveStep, permissions, models, license, signIn, ready: readyMap, setReady, setResult };
|
|
220
229
|
const canContinue = active?.canContinue ? active.canContinue(context) : true;
|
|
221
230
|
const runCallback = async (callback) => {
|
|
222
231
|
try {
|
|
@@ -266,36 +275,65 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
|
|
|
266
275
|
}
|
|
267
276
|
await markAndMove(index + 1, { completed: active.id });
|
|
268
277
|
};
|
|
278
|
+
const rootClass = cx("rk-onboarding", layout === "contained" && "rk-onboarding--contained", className);
|
|
269
279
|
if (error && !state)
|
|
270
|
-
return _jsxs("div", { className:
|
|
280
|
+
return _jsxs("div", { className: rootClass, children: [_jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }), _jsx(Button, { onClick: () => window.location.reload(), children: "Retry" })] });
|
|
271
281
|
if (state && !active)
|
|
272
|
-
return _jsxs("main", { className:
|
|
282
|
+
return _jsxs("main", { className: rootClass, "data-testid": "onboarding", children: [_jsx("p", { className: "rk-onboarding__muted", children: "Nothing to set up." }), _jsx(Button, { variant: "primary", onClick: () => void runCallback(() => onComplete(resultOf(activeState.completed, activeState.skippedSteps ?? []))), children: L.finish })] });
|
|
273
283
|
if (!state || !active)
|
|
274
|
-
return _jsx("div", { className:
|
|
284
|
+
return _jsx("div", { className: rootClass, "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: L.loading }) });
|
|
275
285
|
if (needsGate) {
|
|
276
286
|
if (!gateCleared)
|
|
277
|
-
return _jsxs("div", { className:
|
|
287
|
+
return _jsxs("div", { className: rootClass, "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx("p", { className: "rk-onboarding__muted", children: L.loading })] });
|
|
278
288
|
if (!legal)
|
|
279
289
|
return null;
|
|
280
290
|
const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
+
/** Records the acceptance and returns the record to mirror into the settings state. A throw keeps the gate closed. */
|
|
292
|
+
const recordAcceptance = async (gate, basis) => {
|
|
293
|
+
const version = requiredVersion ?? "1";
|
|
294
|
+
const hash = documentsHash ?? requiredDocumentsHash ?? await hashDocuments(gate.documents);
|
|
295
|
+
if (legalReceipt?.create) {
|
|
296
|
+
// Native path: the service stamps the time and stores the receipt; nothing is built in the renderer.
|
|
297
|
+
const key = [version, hash, basis].join("\n");
|
|
298
|
+
if (createdReceipt.current?.key === key)
|
|
299
|
+
return createdReceipt.current.record;
|
|
300
|
+
const declared = receiptDocuments ?? gate.documents.map((d) => ({ title: d.title, ...(d.url ? { url: d.url } : {}) }));
|
|
301
|
+
const created = readAcceptance(await legalReceipt.create({ version, documentsHash: hash, documents: declared, eligibilityBasis: basis, surface: "first_run" }));
|
|
302
|
+
if (!created)
|
|
303
|
+
throw new Error("The legal receipt service returned an invalid record.");
|
|
304
|
+
// Bind the response to the declaration sent and any independent required hash.
|
|
305
|
+
if (created.eligibilityBasis !== basis || created.version !== version || created.documentsHash !== hash || (requiredDocumentsHash !== undefined && created.documentsHash !== requiredDocumentsHash)) {
|
|
306
|
+
throw new Error("The legal receipt does not match the agreement that was shown.");
|
|
307
|
+
}
|
|
308
|
+
createdReceipt.current = { key, record: created };
|
|
309
|
+
return created;
|
|
310
|
+
}
|
|
311
|
+
// LEGACY: the renderer builds the record with the webview clock. Deprecated; implement `legalReceipt.create`.
|
|
312
|
+
const record = { version, documentsHash: hash, acceptedAt: new Date().toISOString(), eligibilityBasis: basis };
|
|
313
|
+
if (legalReceipt) {
|
|
314
|
+
if (!legalReceipt.write)
|
|
315
|
+
throw new Error("The legal receipt can neither create nor write an acceptance.");
|
|
316
|
+
await legalReceipt.write(record);
|
|
317
|
+
}
|
|
318
|
+
return record;
|
|
319
|
+
};
|
|
320
|
+
return _jsxs("div", { className: cx("rk-onboarding rk-onboarding--legal", layout === "contained" && "rk-onboarding--contained", className), "data-testid": "onboarding-legal", "data-variant": reacceptance ? "re-acceptance" : "first-run", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx(LegalGate, { ...legal, layout: layout, variant: reacceptance ? "re-acceptance" : "first-run", onQuit: () => void legal.onQuit?.(), onAgree: async ({ eligibilityBasis }) => {
|
|
321
|
+
let legalAcceptance;
|
|
322
|
+
try {
|
|
323
|
+
legalAcceptance = await recordAcceptance(legal, eligibilityBasis);
|
|
324
|
+
}
|
|
325
|
+
catch (e) {
|
|
326
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
327
|
+
throw e;
|
|
291
328
|
}
|
|
292
329
|
const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
|
|
293
|
-
await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
|
|
330
|
+
await persist({ legalAccepted: true, legalReopened: false, eligibilityBasis, legalAcceptance, ...progress });
|
|
331
|
+
createdReceipt.current = null;
|
|
294
332
|
if (!reacceptance && builtins.length === 1)
|
|
295
333
|
await onComplete(resultOf(["legal"], []));
|
|
296
334
|
} })] });
|
|
297
335
|
}
|
|
298
336
|
const body = active.render(context);
|
|
299
|
-
return _jsxs("main", { className:
|
|
337
|
+
return _jsxs("main", { className: rootClass, "data-testid": "onboarding", "data-step-id": active.id, children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("aside", { className: "rk-onboarding__rail", children: [_jsx("div", { className: "rk-onboarding__brand", children: appName ?? legal?.appName }), _jsx("nav", { "aria-label": "Setup progress", children: builtins.map((step, i) => _jsxs("button", { type: "button", className: cx("rk-onboarding__step", i === index && "is-active", activeState.completed.includes(step.id) && "is-done"), "aria-current": i === index ? "step" : undefined, disabled: (gated && i === 0 && builtins.length > 1) || continuing, onClick: () => { if (!continuing && i >= lead && (i <= index || canContinue))
|
|
300
338
|
void save({ currentStep: i }); }, children: [_jsx("span", { children: activeState.completed.includes(step.id) ? "✓" : i + 1 }), step.label] }, step.id)) })] }), _jsxs("section", { className: "rk-onboarding__main", children: [_jsxs("header", { children: [_jsxs("p", { className: "rk-onboarding__eyebrow", children: ["Step ", index + 1, " of ", builtins.length] }), active.title ? _jsx("h1", { children: active.title }) : null, active.description ? _jsx("p", { className: "rk-onboarding__description", children: active.description }) : null] }), _jsx("div", { className: "rk-onboarding__content", children: body }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, _jsxs("footer", { children: [allowSkip ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children: L.skip }) : null, active.skippable ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => void markAndMove(index + 1, { skipped: active.id }), children: L.skipStep }) : null, _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), _jsx(Button, { variant: "secondary", disabled: index <= lead || saving || continuing, onClick: () => void save({ currentStep: index - 1 }), children: L.back }), _jsx(Button, { variant: "primary", disabled: saving || continuing || !canContinue, onClick: () => void advance(), children: active.continueLabel ?? (index === builtins.length - 1 ? L.finish : L.continue) })] })] })] });
|
|
301
339
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LicenseBackend, LicenseState, SignInBackend, SignInProvider } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The fields of `@rightkit/license` 0.3.0 `LicenseView` that the adapter reads. Structural on purpose: this package does not
|
|
4
|
+
* import the licence package, so `createLicenseClient(...)` output is accepted as it is and an app that has no licence step
|
|
5
|
+
* never loads it.
|
|
6
|
+
*/
|
|
7
|
+
export interface LicenseViewLike {
|
|
8
|
+
status: string;
|
|
9
|
+
lifecycle?: string;
|
|
10
|
+
tier?: string | null;
|
|
11
|
+
/** Unix milliseconds. */
|
|
12
|
+
validUntil?: number | null;
|
|
13
|
+
/** A closed `MessageCode`; text comes from `messageText`. */
|
|
14
|
+
message?: string;
|
|
15
|
+
}
|
|
16
|
+
/** The slice of `createLicenseClient(...)` the licence step needs. */
|
|
17
|
+
export interface LicenseViewClient {
|
|
18
|
+
status(): Promise<LicenseViewLike>;
|
|
19
|
+
activate(key: string): Promise<LicenseViewLike>;
|
|
20
|
+
/** Present on the real client. A trial needs `trialEmail` as well. */
|
|
21
|
+
requestTrial?(email: string): Promise<{
|
|
22
|
+
accepted: boolean;
|
|
23
|
+
message?: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface LicenseViewBackendOptions {
|
|
27
|
+
/** Text for a `MessageCode`: pass `licenseMessage` from `@rightkit/license`. Without it the step shows no detail text. */
|
|
28
|
+
messageText?: (code: string) => string;
|
|
29
|
+
/**
|
|
30
|
+
* Where the trial email comes from (an input the app renders, or the signed-in account). A trial is offered only when this
|
|
31
|
+
* and the client's `requestTrial` both exist. Returning nothing refuses the trial with a message.
|
|
32
|
+
*/
|
|
33
|
+
trialEmail?: () => string | undefined | Promise<string | undefined>;
|
|
34
|
+
purchaseUrl?: string;
|
|
35
|
+
openExternal?: (url: string) => void | Promise<void>;
|
|
36
|
+
/** Replaces the English status labels. */
|
|
37
|
+
labels?: Partial<Record<LicenseState["status"], string>>;
|
|
38
|
+
}
|
|
39
|
+
/** Maps a native `LicenseView` to the step's `LicenseState`. The lifecycle decides; the coarse status is the fallback. An unknown value is `invalid`, never `active`. */
|
|
40
|
+
export declare function licenseStateFromView(view: LicenseViewLike, options?: Pick<LicenseViewBackendOptions, "messageText" | "labels">): LicenseState;
|
|
41
|
+
/**
|
|
42
|
+
* `LicenseBackend` over a native licence client (`@rightkit/license` 0.3.0 `createLicenseClient`). The step shows the state
|
|
43
|
+
* the native service reports and never decides entitlement itself; errors from the client reach the step unchanged (they are
|
|
44
|
+
* closed, fixed-text `LicenseClientError`s).
|
|
45
|
+
*/
|
|
46
|
+
export declare function licenseBackendFromView(client: LicenseViewClient, options?: LicenseViewBackendOptions): LicenseBackend;
|
|
47
|
+
/**
|
|
48
|
+
* The app's `rightkit-oauth` commands as a typed port. Both calls go to the shared owner
|
|
49
|
+
* (`OAuthClient::sign_in_with_browser` through the app's command): the default browser opens, no in-app webview, and no token
|
|
50
|
+
* reaches the renderer. Results are untrusted `unknown`; only `signedIn` and `account` are read.
|
|
51
|
+
*/
|
|
52
|
+
export interface OAuthSignInPort {
|
|
53
|
+
/** Account already signed in, if any. */
|
|
54
|
+
status(): Promise<unknown>;
|
|
55
|
+
/** Open the default browser for `provider`; resolves when the sign-in finishes. */
|
|
56
|
+
signIn(provider: SignInProvider): Promise<unknown>;
|
|
57
|
+
}
|
|
58
|
+
/** `SignInBackend` over `port`. A sign-in that resolves without `signedIn: true` (closed browser tab, cancel) rejects, so the step shows its retry message. */
|
|
59
|
+
export declare function signInBackendFromOAuth(port: OAuthSignInPort, providers: SignInBackend["providers"]): SignInBackend;
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const BY_LIFECYCLE = {
|
|
2
|
+
unlicensed: "none",
|
|
3
|
+
migration_required: "invalid",
|
|
4
|
+
trial: "trial",
|
|
5
|
+
expired_trial: "expired",
|
|
6
|
+
active: "active",
|
|
7
|
+
grace: "active",
|
|
8
|
+
lifetime: "active",
|
|
9
|
+
lifetime_provisional: "active",
|
|
10
|
+
verification_required: "active",
|
|
11
|
+
expired: "expired",
|
|
12
|
+
clock_repair_required: "invalid",
|
|
13
|
+
revoked: "invalid",
|
|
14
|
+
invalid: "invalid",
|
|
15
|
+
};
|
|
16
|
+
const BY_STATUS = {
|
|
17
|
+
none: "none",
|
|
18
|
+
active: "active",
|
|
19
|
+
grace: "active",
|
|
20
|
+
expired: "expired",
|
|
21
|
+
revoked: "invalid",
|
|
22
|
+
invalid: "invalid",
|
|
23
|
+
unverifiable: "invalid",
|
|
24
|
+
};
|
|
25
|
+
const DEFAULT_LABELS = {
|
|
26
|
+
none: "No licence",
|
|
27
|
+
trial: "Trial",
|
|
28
|
+
active: "Active",
|
|
29
|
+
expired: "Expired",
|
|
30
|
+
invalid: "Needs attention",
|
|
31
|
+
};
|
|
32
|
+
/** Maps a native `LicenseView` to the step's `LicenseState`. The lifecycle decides; the coarse status is the fallback. An unknown value is `invalid`, never `active`. */
|
|
33
|
+
export function licenseStateFromView(view, options = {}) {
|
|
34
|
+
const status = (view.lifecycle !== undefined ? BY_LIFECYCLE[view.lifecycle] : undefined) ?? BY_STATUS[view.status] ?? "invalid";
|
|
35
|
+
const text = view.message !== undefined && view.message !== "none" ? options.messageText?.(view.message) : undefined;
|
|
36
|
+
const validUntil = typeof view.validUntil === "number" && Number.isFinite(view.validUntil) ? new Date(view.validUntil).toISOString() : null;
|
|
37
|
+
return {
|
|
38
|
+
status,
|
|
39
|
+
label: options.labels?.[status] ?? DEFAULT_LABELS[status],
|
|
40
|
+
...(text ? { detail: text } : {}),
|
|
41
|
+
...(typeof view.tier === "string" && view.tier ? { tier: view.tier } : {}),
|
|
42
|
+
validUntil,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `LicenseBackend` over a native licence client (`@rightkit/license` 0.3.0 `createLicenseClient`). The step shows the state
|
|
47
|
+
* the native service reports and never decides entitlement itself; errors from the client reach the step unchanged (they are
|
|
48
|
+
* closed, fixed-text `LicenseClientError`s).
|
|
49
|
+
*/
|
|
50
|
+
export function licenseBackendFromView(client, options = {}) {
|
|
51
|
+
const state = (view) => licenseStateFromView(view, options);
|
|
52
|
+
const { requestTrial } = client;
|
|
53
|
+
const { trialEmail } = options;
|
|
54
|
+
return {
|
|
55
|
+
load: async () => state(await client.status()),
|
|
56
|
+
activate: async (key) => state(await client.activate(key)),
|
|
57
|
+
...(requestTrial && trialEmail ? {
|
|
58
|
+
startTrial: async () => {
|
|
59
|
+
const email = (await trialEmail())?.trim();
|
|
60
|
+
if (!email)
|
|
61
|
+
throw new Error("An email address is needed to start a trial.");
|
|
62
|
+
const outcome = await requestTrial.call(client, email);
|
|
63
|
+
const current = state(await client.status());
|
|
64
|
+
const text = outcome.message !== undefined && outcome.message !== "none" ? options.messageText?.(outcome.message) : undefined;
|
|
65
|
+
if (!outcome.accepted)
|
|
66
|
+
throw new Error(text ?? "A trial could not be started.");
|
|
67
|
+
return text ? { ...current, detail: text } : current;
|
|
68
|
+
},
|
|
69
|
+
} : {}),
|
|
70
|
+
...(options.purchaseUrl ? { purchaseUrl: options.purchaseUrl } : {}),
|
|
71
|
+
...(options.openExternal ? { openExternal: options.openExternal } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function accountOf(value) {
|
|
75
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
76
|
+
return { signedIn: false };
|
|
77
|
+
const v = value;
|
|
78
|
+
const account = typeof v.account === "string" && v.account.trim() ? v.account.trim().slice(0, 320) : undefined;
|
|
79
|
+
return v.signedIn === true ? { signedIn: true, ...(account ? { account } : {}) } : { signedIn: false };
|
|
80
|
+
}
|
|
81
|
+
/** `SignInBackend` over `port`. A sign-in that resolves without `signedIn: true` (closed browser tab, cancel) rejects, so the step shows its retry message. */
|
|
82
|
+
export function signInBackendFromOAuth(port, providers) {
|
|
83
|
+
return {
|
|
84
|
+
providers,
|
|
85
|
+
load: async () => accountOf(await port.status()),
|
|
86
|
+
start: async (provider) => {
|
|
87
|
+
const result = accountOf(await port.signIn(provider));
|
|
88
|
+
if (!result.signedIn)
|
|
89
|
+
throw new Error("Sign-in did not finish. Try again.");
|
|
90
|
+
return result;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
|
|
2
2
|
export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
|
|
3
|
-
export type { ModelsStepOptions } from "./steps.js";
|
|
3
|
+
export type { LicenseStepCopy, LicenseStepOptions, ModelsStepOptions } from "./steps.js";
|
|
4
|
+
export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
|
|
5
|
+
export type { LicenseViewBackendOptions, LicenseViewClient, LicenseViewLike, OAuthSignInPort } from "./adapters.js";
|
|
6
|
+
export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
|
|
7
|
+
export type { LegalReceiptInvoke, NativeLegalReceiptPortOptions } from "./legal-receipt.js";
|
|
4
8
|
export type * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
|
|
2
2
|
export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
|
|
3
|
+
export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
|
|
4
|
+
export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LegalReceiptPort, SettingsBackend } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The commands of the native legal-receipt host service. The native side registers them once under the host caller policy
|
|
4
|
+
* (`rightkit-tauri-shell` host services); the trusted window is the one that shows the legal gate. Names are provisional
|
|
5
|
+
* until the host service is published; an app whose host registers other names passes `commands`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LEGAL_RECEIPT_COMMANDS: Readonly<{
|
|
8
|
+
read: "rightkit_legal_receipt_read";
|
|
9
|
+
create: "rightkit_legal_receipt_create";
|
|
10
|
+
reset: "rightkit_legal_receipt_reset";
|
|
11
|
+
}>;
|
|
12
|
+
/** Tauri's `invoke`, structurally (`@tauri-apps/api/core`). No import, so the package stays free of Tauri. */
|
|
13
|
+
export type LegalReceiptInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
14
|
+
export interface NativeLegalReceiptPortOptions {
|
|
15
|
+
invoke: LegalReceiptInvoke;
|
|
16
|
+
commands?: Partial<typeof LEGAL_RECEIPT_COMMANDS>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `LegalReceiptPort` over the native legal-receipt service.
|
|
20
|
+
*
|
|
21
|
+
* - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record.
|
|
22
|
+
* - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
|
|
23
|
+
* service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
|
|
24
|
+
* refusal or a malformed answer throws, which keeps the gate closed.
|
|
25
|
+
* - `reset` clears the receipt.
|
|
26
|
+
* There is no `write`: the renderer never supplies a stored record.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createNativeLegalReceiptPort({ invoke, commands }: NativeLegalReceiptPortOptions): LegalReceiptPort;
|
|
29
|
+
/**
|
|
30
|
+
* Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
|
|
31
|
+
* `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
|
|
32
|
+
* clears the flag. Progress through the other steps is kept.
|
|
33
|
+
*/
|
|
34
|
+
export declare function reopenLegalAcceptance({ settings }: {
|
|
35
|
+
settings: SettingsBackend;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Forgets the acceptance entirely: the app's receipt is cleared first (`legalReceipt.reset`, required when a receipt port is
|
|
39
|
+
* given, so a failed reset cannot leave the receipt accepted), then the settings copy, so the next mount shows the first-run
|
|
40
|
+
* gate. Other steps' progress is kept.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resetLegalAcceptance({ settings, legalReceipt }: {
|
|
43
|
+
settings: SettingsBackend;
|
|
44
|
+
legalReceipt?: LegalReceiptPort;
|
|
45
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { onboardingStateKey, readAcceptance, readState } from "./Onboarding.js";
|
|
2
|
+
/**
|
|
3
|
+
* The commands of the native legal-receipt host service. The native side registers them once under the host caller policy
|
|
4
|
+
* (`rightkit-tauri-shell` host services); the trusted window is the one that shows the legal gate. Names are provisional
|
|
5
|
+
* until the host service is published; an app whose host registers other names passes `commands`.
|
|
6
|
+
*/
|
|
7
|
+
export const LEGAL_RECEIPT_COMMANDS = Object.freeze({
|
|
8
|
+
read: "rightkit_legal_receipt_read",
|
|
9
|
+
create: "rightkit_legal_receipt_create",
|
|
10
|
+
reset: "rightkit_legal_receipt_reset",
|
|
11
|
+
});
|
|
12
|
+
/** Host commands reject with `{ code, message }`; the gate and steps show `Error#message`. */
|
|
13
|
+
function hostError(error) {
|
|
14
|
+
if (error instanceof Error)
|
|
15
|
+
return error;
|
|
16
|
+
const message = error && typeof error === "object" ? error.message : error;
|
|
17
|
+
return new Error(typeof message === "string" && message.trim() ? message : "The legal receipt service failed.");
|
|
18
|
+
}
|
|
19
|
+
async function call(invoke, command, args) {
|
|
20
|
+
try {
|
|
21
|
+
return await invoke(command, args);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw hostError(error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `LegalReceiptPort` over the native legal-receipt service.
|
|
29
|
+
*
|
|
30
|
+
* - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record.
|
|
31
|
+
* - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
|
|
32
|
+
* service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
|
|
33
|
+
* refusal or a malformed answer throws, which keeps the gate closed.
|
|
34
|
+
* - `reset` clears the receipt.
|
|
35
|
+
* There is no `write`: the renderer never supplies a stored record.
|
|
36
|
+
*/
|
|
37
|
+
export function createNativeLegalReceiptPort({ invoke, commands }) {
|
|
38
|
+
const names = { ...LEGAL_RECEIPT_COMMANDS, ...commands };
|
|
39
|
+
return {
|
|
40
|
+
async read() {
|
|
41
|
+
const value = await call(invoke, names.read);
|
|
42
|
+
return value === null || value === undefined ? null : readAcceptance(value) ?? null;
|
|
43
|
+
},
|
|
44
|
+
async create(request) {
|
|
45
|
+
const created = readAcceptance(await call(invoke, names.create, {
|
|
46
|
+
request: {
|
|
47
|
+
version: request.version,
|
|
48
|
+
documentsHash: request.documentsHash,
|
|
49
|
+
documents: request.documents,
|
|
50
|
+
eligibilityBasis: request.eligibilityBasis,
|
|
51
|
+
surface: request.surface,
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
if (!created)
|
|
55
|
+
throw new Error("The legal receipt service returned an invalid record.");
|
|
56
|
+
return created;
|
|
57
|
+
},
|
|
58
|
+
async reset() { await call(invoke, names.reset); },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
|
|
63
|
+
* `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
|
|
64
|
+
* clears the flag. Progress through the other steps is kept.
|
|
65
|
+
*/
|
|
66
|
+
export async function reopenLegalAcceptance({ settings }) {
|
|
67
|
+
const key = onboardingStateKey();
|
|
68
|
+
const snapshot = await settings.load();
|
|
69
|
+
const state = readState(snapshot.values[key]);
|
|
70
|
+
await settings.patch({ [key]: { ...state, legalReopened: true } }, snapshot.revision);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Forgets the acceptance entirely: the app's receipt is cleared first (`legalReceipt.reset`, required when a receipt port is
|
|
74
|
+
* given, so a failed reset cannot leave the receipt accepted), then the settings copy, so the next mount shows the first-run
|
|
75
|
+
* gate. Other steps' progress is kept.
|
|
76
|
+
*/
|
|
77
|
+
export async function resetLegalAcceptance({ settings, legalReceipt }) {
|
|
78
|
+
if (legalReceipt) {
|
|
79
|
+
if (!legalReceipt.reset)
|
|
80
|
+
throw new Error("The legal receipt cannot be reset.");
|
|
81
|
+
await legalReceipt.reset();
|
|
82
|
+
}
|
|
83
|
+
const key = onboardingStateKey();
|
|
84
|
+
const snapshot = await settings.load();
|
|
85
|
+
const next = { ...readState(snapshot.values[key]), legalAccepted: false };
|
|
86
|
+
delete next.legalAcceptance;
|
|
87
|
+
delete next.eligibilityBasis;
|
|
88
|
+
delete next.legalReopened;
|
|
89
|
+
next.completed = next.completed.filter((id) => id !== "legal");
|
|
90
|
+
await settings.patch({ [key]: next }, snapshot.revision);
|
|
91
|
+
}
|
package/dist/steps.js
CHANGED
|
@@ -24,7 +24,7 @@ function PermissionsBody({ backend }) {
|
|
|
24
24
|
const [error, setError] = useState(null);
|
|
25
25
|
const reload = useCallback(() => backend.list().then(setItems, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
|
|
26
26
|
useEffect(() => void reload(), [reload]);
|
|
27
|
-
return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-permissions", children: [error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, items.length === 0 && !error ? _jsx("p", { className: "rk-onboarding__muted", children: "Checking permissions\u2026" }) : null, items.map((item) => (_jsxs("div", { className: "rk-onboarding__row", "data-permission-id": item.id, children: [_jsxs("div", { children: [_jsx("strong", { children: item.label }), _jsx("p", { children: item.description ?? item.detail })] }), _jsxs("div", { className: "rk-onboarding__row-actions", children: [_jsx(Badge, { tone: permissionTone(item.state), children: item.state }), item.state === "needed" || item.state === "denied" ? (_jsx(Button, { size: "sm", variant: "secondary", disabled: busy === item.id, onClick: () => { setBusy(item.id); setError(null); void backend.request(item.id).then((next) => setItems((current) => current.map((x) => x.id === next.id ? next : x)), (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null)); }, children: item.state === "denied" ? "Try again" : "Allow" })) : null] })] }, item.id)))] }));
|
|
27
|
+
return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-permissions", children: [error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, items.length === 0 && error ? _jsx(Button, { size: "sm", variant: "secondary", onClick: () => { setError(null); void reload(); }, children: "Try again" }) : null, items.length === 0 && !error ? _jsx("p", { className: "rk-onboarding__muted", children: "Checking permissions\u2026" }) : null, items.map((item) => (_jsxs("div", { className: "rk-onboarding__row", "data-permission-id": item.id, children: [_jsxs("div", { children: [_jsx("strong", { children: item.label }), _jsx("p", { children: item.description ?? item.detail })] }), _jsxs("div", { className: "rk-onboarding__row-actions", children: [_jsx(Badge, { tone: permissionTone(item.state), children: item.state }), item.state === "needed" || item.state === "denied" ? (_jsx(Button, { size: "sm", variant: "secondary", disabled: busy === item.id, onClick: () => { setBusy(item.id); setError(null); void backend.request(item.id).then((next) => setItems((current) => current.map((x) => x.id === next.id ? next : x)), (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null)); }, children: item.state === "denied" ? "Try again" : "Allow" })) : null] })] }, item.id)))] }));
|
|
28
28
|
}
|
|
29
29
|
export function permissionsStep(backend, options = {}) {
|
|
30
30
|
return { id: "permissions", label: options.label ?? "Permissions", title: options.title ?? "Allow what this app needs", description: options.description ?? "You can change these permissions later in System Settings.", skippable: true, render: () => _jsx(PermissionsBody, { backend: backend }) };
|
|
@@ -82,7 +82,7 @@ function ModelsBody({ backend, onReady, autoStartDownload, shouldAutoStart, onMo
|
|
|
82
82
|
// run is intentionally omitted: it only closes over setters.
|
|
83
83
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
84
|
}, [autoStartDownload, shouldAutoStart, onModelsStepEnter, loaded, models, backend]);
|
|
85
|
-
return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-models", children: [intro ? _jsx("div", { className: "rk-onboarding__intro", "data-testid": "onboarding-models-intro", children: intro }) : null, error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, models.map((model) => {
|
|
85
|
+
return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-models", children: [intro ? _jsx("div", { className: "rk-onboarding__intro", "data-testid": "onboarding-models-intro", children: intro }) : null, error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, error && !loaded ? _jsx(Button, { size: "sm", variant: "secondary", onClick: () => { setError(null); void reload(); }, children: "Try again" }) : null, models.map((model) => {
|
|
86
86
|
const progress = model.progress ?? (model.state === "installed" ? 1 : 0);
|
|
87
87
|
const active = model.state === "downloading" || model.state === "verifying";
|
|
88
88
|
const cancel = backend.cancel;
|
package/dist/types.d.ts
CHANGED
|
@@ -88,15 +88,56 @@ export interface LegalAcceptanceRecord {
|
|
|
88
88
|
acceptedAt: string;
|
|
89
89
|
eligibilityBasis: EligibilityBasis;
|
|
90
90
|
}
|
|
91
|
+
/** One document of the set the user was shown. Shaped so `@rightkit/legal`'s `acceptanceDeclaration(manifest).documents` fits as is. */
|
|
92
|
+
export interface DeclaredLegalDocument {
|
|
93
|
+
id?: string;
|
|
94
|
+
role?: string;
|
|
95
|
+
title?: string;
|
|
96
|
+
url?: string;
|
|
97
|
+
version?: string;
|
|
98
|
+
sha256?: string;
|
|
99
|
+
treatment?: string;
|
|
100
|
+
material?: boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* What the renderer declares to a native receipt service when the user agrees. It carries no time: the service stamps its
|
|
104
|
+
* own UTC `acceptedAt`, compares `version`, `documentsHash` and `documents` with the manifest it embeds, and refuses a
|
|
105
|
+
* declaration that does not match.
|
|
106
|
+
*/
|
|
107
|
+
export interface LegalReceiptCreateRequest {
|
|
108
|
+
/** Acceptance version the app requires (`requiredVersion`, else "1"). */
|
|
109
|
+
version: string;
|
|
110
|
+
/** Digest of the document set shown (`documentsHash`, else `requiredDocumentsHash`, else a hash of titles and URLs). */
|
|
111
|
+
documentsHash: string;
|
|
112
|
+
/** The declared set (`receiptDocuments`, else the gate's documents as `{ title, url }`). */
|
|
113
|
+
documents: readonly DeclaredLegalDocument[];
|
|
114
|
+
eligibilityBasis: EligibilityBasis;
|
|
115
|
+
/** Always "first_run": onboarding is the first-run and re-acceptance surface. */
|
|
116
|
+
surface: "first_run";
|
|
117
|
+
}
|
|
91
118
|
/**
|
|
92
119
|
* App-owned persistence for the legal acceptance. Lets an app keep its existing receipt (any shape, any store) and map it
|
|
93
120
|
* to and from the package's record. When supplied it is the source of truth: `read()` is consulted on load and overrides
|
|
94
|
-
* whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows)
|
|
95
|
-
*
|
|
121
|
+
* whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows).
|
|
122
|
+
*
|
|
123
|
+
* Recording the acceptance, in order of preference:
|
|
124
|
+
* - `create(request)`: the NATIVE path. The service owns the time and the stored receipt and returns the record it stored;
|
|
125
|
+
* the renderer builds nothing. A throw, or a record that does not match the required version or hash, keeps the gate
|
|
126
|
+
* closed. When `create` exists, `write` is not called.
|
|
127
|
+
* - `write(record)`: LEGACY. The renderer builds the record, including its `acceptedAt` from the webview clock, and the app
|
|
128
|
+
* stores it. Kept for apps that have not moved to a native receipt service.
|
|
129
|
+
*
|
|
130
|
+
* `reset()` clears the stored receipt (used by `resetLegalAcceptance`). A port needs `create` or `write`; with neither,
|
|
131
|
+
* agreeing fails and the gate stays closed.
|
|
96
132
|
*/
|
|
97
133
|
export interface LegalReceiptPort {
|
|
98
134
|
read(): LegalAcceptanceRecord | null | Promise<LegalAcceptanceRecord | null>;
|
|
99
|
-
|
|
135
|
+
/** Native receipt creation: returns the stored record, stamped with native time. Preferred over `write`. */
|
|
136
|
+
create?(request: LegalReceiptCreateRequest): LegalAcceptanceRecord | Promise<LegalAcceptanceRecord>;
|
|
137
|
+
/** @deprecated Renderer-built record with webview time. Implement `create` on a native receipt service instead. */
|
|
138
|
+
write?(record: LegalAcceptanceRecord): void | Promise<void>;
|
|
139
|
+
/** Clear the stored receipt so the gate shows again as a first run. */
|
|
140
|
+
reset?(): void | Promise<void>;
|
|
100
141
|
}
|
|
101
142
|
export interface PersistedOnboardingState {
|
|
102
143
|
version: 1;
|
|
@@ -108,6 +149,11 @@ export interface PersistedOnboardingState {
|
|
|
108
149
|
/** Legacy mirror of `legalAcceptance.eligibilityBasis`. */
|
|
109
150
|
eligibilityBasis?: EligibilityBasis;
|
|
110
151
|
legalAcceptance?: LegalAcceptanceRecord;
|
|
152
|
+
/**
|
|
153
|
+
* Set by `reopenLegalAcceptance`: the stored acceptance no longer counts, so the gate shows as a re-acceptance. The old
|
|
154
|
+
* record stays until a new acceptance replaces it, which clears the flag.
|
|
155
|
+
*/
|
|
156
|
+
legalReopened?: boolean;
|
|
111
157
|
/** Step ids the user skipped with the per-step Skip button (not marked complete). */
|
|
112
158
|
skippedSteps?: string[];
|
|
113
159
|
}
|
|
@@ -178,6 +224,8 @@ export interface LegalStepConfig extends Omit<LegalGateProps, "variant" | "onAgr
|
|
|
178
224
|
*/
|
|
179
225
|
export type LegalMode = "gate" | "notice" | "none";
|
|
180
226
|
export interface OnboardingProps {
|
|
227
|
+
/** Window-sized by default; use `contained` when the host sizes the setup surface. */
|
|
228
|
+
layout?: "window" | "contained";
|
|
181
229
|
settings: SettingsBackend;
|
|
182
230
|
/** Required when `legalMode` is `gate` (default). Optional for `notice` (the documents shown) and `none`. */
|
|
183
231
|
legal?: LegalStepConfig;
|
|
@@ -190,6 +238,13 @@ export interface OnboardingProps {
|
|
|
190
238
|
permissions?: PermissionsBackend;
|
|
191
239
|
models?: ModelsBackend;
|
|
192
240
|
license?: LicenseBackend;
|
|
241
|
+
/**
|
|
242
|
+
* Adds the built-in sign-in step after the licence step and hands the backend to every step as `ctx.signIn`. Build it
|
|
243
|
+
* with `signInBackendFromOAuth` over the app's `rightkit-oauth` commands.
|
|
244
|
+
*/
|
|
245
|
+
signIn?: SignInBackend;
|
|
246
|
+
/** The document set declared to `legalReceipt.create`. Default: the gate's documents as `{ title, url }`. */
|
|
247
|
+
receiptDocuments?: readonly DeclaredLegalDocument[];
|
|
193
248
|
/** Called once when setup finishes, with the completed and skipped step ids and the step results. */
|
|
194
249
|
onComplete: (result: OnboardingResult) => void | Promise<void>;
|
|
195
250
|
onSkip?: () => void | Promise<void>;
|
|
@@ -228,7 +283,10 @@ export interface OnboardingProps {
|
|
|
228
283
|
dragRegion?: boolean;
|
|
229
284
|
/** Also render the drag region on the legal stage (loading and gate). Default false: the legal gate has no strip. */
|
|
230
285
|
legalDragRegion?: boolean;
|
|
231
|
-
/**
|
|
286
|
+
/**
|
|
287
|
+
* Read and record the legal acceptance in the app's own (preferably native) receipt instead of (overriding) the settings
|
|
288
|
+
* backend's copy. See `LegalReceiptPort`; `createNativeLegalReceiptPort` builds one over the host's receipt commands.
|
|
289
|
+
*/
|
|
232
290
|
legalReceipt?: LegalReceiptPort;
|
|
233
291
|
}
|
|
234
292
|
export type { LegalGateDocument, LegalGateTheme };
|
package/onboarding.css
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* @rightkit/onboarding — uses app-shell tokens and legal-ui's locked light gate. */
|
|
2
2
|
.rk-onboarding { position: relative; min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); background: var(--rk-plane, #fffcf5); color: var(--rk-ink, #231f1a); font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
|
3
|
+
.rk-onboarding--contained { min-height: 0; }
|
|
3
4
|
.rk-onboarding *, .rk-onboarding *::before, .rk-onboarding *::after { box-sizing: border-box; }
|
|
4
5
|
.rk-onboarding__rail { display: flex; flex-direction: column; gap: 22px; padding: 28px 14px; background: var(--rk-chrome, #eee9df); border-right: .5px solid var(--rk-hairline, rgba(35,31,26,.11)); }
|
|
5
6
|
.rk-onboarding__brand { padding: 0 10px; font-size: 19px; font-weight: 650; letter-spacing: -.02em; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/onboarding",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "First-run onboarding framework: ordered steps, progress, skip/resume, legal gate first, permissions, model download and licence/trial steps.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
],
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"@rightkit/react-shell": ">=0.2.0 <1",
|
|
28
|
-
"@rightkit/legal-ui": ">=0.1
|
|
28
|
+
"@rightkit/legal-ui": ">=0.4.1 <1",
|
|
29
29
|
"react": ">=18 <21"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@rightkit/react-shell": "0.7.
|
|
33
|
-
"@rightkit/legal-ui": "0.4.
|
|
34
|
-
"@rightkit/qa-chrome": "0.1.
|
|
32
|
+
"@rightkit/react-shell": "0.7.2",
|
|
33
|
+
"@rightkit/legal-ui": "0.4.1",
|
|
34
|
+
"@rightkit/qa-chrome": "0.1.9",
|
|
35
35
|
"@testing-library/jest-dom": "^7.0.1",
|
|
36
36
|
"@testing-library/dom": "^10.4.2",
|
|
37
37
|
"@testing-library/react": "^16.3.3",
|