mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: full screen onboarding with a plugin picker and per step versions
This commit is contained in:
1 parent
1e74ce4827
commit
b9dd3488a5
59 files changed
+5413
-643
No files matched your search
+57
-14
@@ -16,7 +16,10 @@
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
|
||||
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5"
|
||||
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "automations",
|
||||
@@ -28,13 +31,19 @@
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
|
||||
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d"
|
||||
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
|
||||
"sha256": "8d60f6299a38fcc69a17ca30abb0e2a168aa880c0cfb50d57f1dd37158d811a5"
|
||||
"sha256": "8d60f6299a38fcc69a17ca30abb0e2a168aa880c0cfb50d57f1dd37158d811a5",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fleets",
|
||||
@@ -52,7 +61,10 @@
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
|
||||
"sha256": "1cfc81f724418fd68306e3ac30daa529b03aa065740a866f6fbcaa67edb440ac"
|
||||
"sha256": "1cfc81f724418fd68306e3ac30daa529b03aa065740a866f6fbcaa67edb440ac",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ldap",
|
||||
@@ -82,7 +94,10 @@
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
|
||||
"sha256": "5201dc1ce4846ef9f509a4c609698e2b2970e31fbc152bb2fe5c39c72c5c6d39"
|
||||
"sha256": "5201dc1ce4846ef9f509a4c609698e2b2970e31fbc152bb2fe5c39c72c5c6d39",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret-sources",
|
||||
@@ -94,7 +109,10 @@
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
|
||||
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4"
|
||||
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4",
|
||||
"onboarding": {
|
||||
"recommended": "desktop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "session-recording",
|
||||
@@ -112,19 +130,28 @@
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
|
||||
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5"
|
||||
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
|
||||
"sha256": "66d2a4e87a2b5ac5551c74a4fad5808a6ee81d6e8f0a43c1f6456a12d6232714"
|
||||
"sha256": "66d2a4e87a2b5ac5551c74a4fad5808a6ee81d6e8f0a43c1f6456a12d6232714",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
|
||||
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5"
|
||||
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5",
|
||||
"onboarding": {
|
||||
"enabledByEnv": ["OIDC_CLIENT_ID"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "step-ca",
|
||||
@@ -142,7 +169,11 @@
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
|
||||
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c"
|
||||
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "termix-identity",
|
||||
@@ -160,13 +191,19 @@
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
|
||||
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9"
|
||||
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
|
||||
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4"
|
||||
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vault",
|
||||
@@ -196,13 +233,19 @@
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
|
||||
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713"
|
||||
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
|
||||
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd"
|
||||
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -694,7 +694,11 @@ export interface ActionSlotDefinition {
|
||||
export interface SlotContribution {
|
||||
actionId: string;
|
||||
titleKey: string;
|
||||
/** Secondary text, for slots that show one (onboarding cards). */
|
||||
/**
|
||||
* Secondary text, for slots that show one. The onboarding.* slots that
|
||||
* used it are deprecated: use registerOnboardingStep. The feature and
|
||||
* workflow card slots are no longer shown.
|
||||
*/
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
kind?: "button" | "component" | string;
|
||||
@@ -705,6 +709,65 @@ export interface SlotContribution {
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type OnboardingMode = "full" | "partial" | "rerun";
|
||||
|
||||
/**
|
||||
* Where a plugin step sits in onboarding. "setup" comes right after the
|
||||
* appearance steps, for things that need doing before the feature works
|
||||
* (like pointing at a guacd server). "explore" (the default) comes after
|
||||
* that, "security" after the account security step. Onboarding is for
|
||||
* choices, not for explaining features.
|
||||
*/
|
||||
export type OnboardingSection = "setup" | "explore" | "security";
|
||||
|
||||
export interface OnboardingStepProps {
|
||||
/**
|
||||
* "full" is a new user's first run, "partial" shows only steps they have
|
||||
* not seen yet (a plugin installed later, a step whose version went up),
|
||||
* "rerun" is the user running setup again from settings.
|
||||
*/
|
||||
mode: OnboardingMode;
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
/** Blocks or allows Next. Steps can continue by default. */
|
||||
setCanContinue: (ok: boolean) => void;
|
||||
/**
|
||||
* Runs when Next is pressed, before moving on. Return false to stay on the
|
||||
* step (to show an error, say). Pass null to clear it.
|
||||
*/
|
||||
setBeforeNext: (fn: (() => boolean | Promise<boolean>) | null) => void;
|
||||
/**
|
||||
* False on a first run, which shows before the app shell exists: app.tabs
|
||||
* and the shell callbacks do nothing until it is true.
|
||||
*/
|
||||
shellReady: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStepContribution {
|
||||
/** Unique within the plugin. Lowercase letters, digits and dashes. */
|
||||
id: string;
|
||||
/**
|
||||
* Starts at 1. Raise it when the step changes enough that people who saw
|
||||
* it should see it again; they get just this step, not all of onboarding.
|
||||
*/
|
||||
version?: number;
|
||||
titleKey: string;
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
component: ComponentType<OnboardingStepProps>;
|
||||
/** "admin" shows it only to users who can manage plugins. */
|
||||
audience?: "all" | "admin";
|
||||
/** A permission the user must hold, like registerAction's. */
|
||||
permission?: string;
|
||||
section?: OnboardingSection;
|
||||
order?: number;
|
||||
when?: (context: {
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
mode: OnboardingMode;
|
||||
}) => boolean;
|
||||
}
|
||||
|
||||
export interface SshAuthEditorProps {
|
||||
form: Record<string, unknown>;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
@@ -894,6 +957,13 @@ export interface TermixApp extends TermixAppInfo {
|
||||
slotId: string,
|
||||
contribution: SlotContribution,
|
||||
) => Disposer;
|
||||
/**
|
||||
* Adds a step to onboarding. New users see it in their first run; people
|
||||
* who already finished onboarding see just this step the next time they
|
||||
* open Termix (or right away, if they install the plugin mid-session).
|
||||
* Needs plugin API 1.1.
|
||||
*/
|
||||
registerOnboardingStep: (step: OnboardingStepContribution) => Disposer;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
/**
|
||||
* Offers a component to other plugins and to core by id, rendered where
|
||||
|
||||
@@ -21,7 +21,7 @@ import { isKnownCapability } from "./capabilities.js";
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.0.0";
|
||||
export const PLUGIN_API_VERSION = "1.1.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -81,6 +81,7 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
|
||||
"termix",
|
||||
"plugin",
|
||||
"plugins",
|
||||
"onboarding",
|
||||
"users",
|
||||
"system",
|
||||
"sdk",
|
||||
|
||||
@@ -2139,6 +2139,8 @@ export interface RenderedPluginApp {
|
||||
paletteGroups: () => string[];
|
||||
keybindingActions: () => string[];
|
||||
keybindingDefaults: () => string[];
|
||||
/** Onboarding step ids, without the plugin prefix. */
|
||||
onboardingSteps: () => string[];
|
||||
};
|
||||
/** A registered palette group's items, as the palette loads them on open. */
|
||||
loadPaletteGroup: (
|
||||
@@ -2160,6 +2162,19 @@ export interface RenderedPluginApp {
|
||||
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderDashboardCard: (id: string) => HTMLElement;
|
||||
/**
|
||||
* Renders an onboarding step with OnboardingStepProps for a first run,
|
||||
* overridable. Read what the step set with the returned `canContinue()`
|
||||
* and `next()` (runs its beforeNext, true when it has none).
|
||||
*/
|
||||
renderOnboardingStep: (
|
||||
id: string,
|
||||
props?: Record<string, unknown>,
|
||||
) => {
|
||||
element: HTMLElement;
|
||||
canContinue: () => boolean;
|
||||
next: () => Promise<boolean>;
|
||||
};
|
||||
/** Renders one component of a contributed extension, "view" by default. */
|
||||
renderExtension: (
|
||||
pointId: string,
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
findLocalPluginBuilds,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
buildBundledIndex,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
@@ -62,6 +63,13 @@ async function main() {
|
||||
console.log(`copied local build of ${id} (not pinned)`);
|
||||
}
|
||||
|
||||
const staged = [...pinned, ...extra];
|
||||
fs.writeFileSync(
|
||||
path.join(destination, "bundled-index.json"),
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
|
||||
`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
pinsFromIndex,
|
||||
buildBundledIndex,
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
rebuildLocalPlugins,
|
||||
@@ -83,6 +84,73 @@ describe("parseBundledPlugins", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads onboarding defaults", () => {
|
||||
const { plugins, problems } = parseBundledPlugins({
|
||||
plugins: [
|
||||
{
|
||||
id: "kept",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { recommended: true },
|
||||
},
|
||||
{
|
||||
id: "desk",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { recommended: "desktop" },
|
||||
},
|
||||
{
|
||||
id: "ask",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { recommended: true, consent: true },
|
||||
},
|
||||
{
|
||||
id: "env",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { enabledByEnv: ["OIDC_CLIENT_ID"] },
|
||||
},
|
||||
{ id: "plain", source: "tmxplug", path: "a", sha256: SHA },
|
||||
],
|
||||
});
|
||||
expect(problems).toEqual([]);
|
||||
expect(plugins.map((p: { onboarding: unknown }) => p.onboarding)).toEqual([
|
||||
{ recommended: true, consent: false },
|
||||
{ recommended: "desktop", consent: false },
|
||||
{ recommended: true, consent: true },
|
||||
{ recommended: false, consent: false, enabledByEnv: ["OIDC_CLIENT_ID"] },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects bad onboarding defaults", () => {
|
||||
const entry = { source: "tmxplug", path: "a", sha256: SHA };
|
||||
const { plugins, problems } = parseBundledPlugins({
|
||||
plugins: [
|
||||
{ ...entry, id: "a", onboarding: "yes" },
|
||||
{ ...entry, id: "b", onboarding: { recommended: "web" } },
|
||||
{ ...entry, id: "c", onboarding: { consent: 1 } },
|
||||
{ ...entry, id: "d", onboarding: { pinned: true } },
|
||||
{ ...entry, id: "e", onboarding: { enabledByEnv: ["lower"] } },
|
||||
{ ...entry, id: "f", onboarding: { enabledByEnv: [] } },
|
||||
],
|
||||
});
|
||||
expect(plugins).toEqual([]);
|
||||
expect(problems).toEqual([
|
||||
"a has an onboarding field that is not an object",
|
||||
'b onboarding.recommended must be true, false or "desktop"',
|
||||
"c onboarding.consent must be a boolean",
|
||||
'd has an unknown onboarding field "pinned"',
|
||||
"e onboarding.enabledByEnv must be a list of environment variable names",
|
||||
"f onboarding.enabledByEnv must be a list of environment variable names",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the list in this repo", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
expect(() => loadBundledPlugins(root)).not.toThrow();
|
||||
@@ -141,6 +209,20 @@ describe("tmxplug entries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundledIndex", () => {
|
||||
it("lists the onboarding defaults of staged plugins only", () => {
|
||||
const plugins = [
|
||||
{ id: "a", onboarding: { recommended: true, consent: false } },
|
||||
{ id: "b", onboarding: { recommended: "desktop", consent: false } },
|
||||
{ id: "c", onboarding: null },
|
||||
];
|
||||
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
|
||||
version: 1,
|
||||
plugins: { a: { onboarding: { recommended: true, consent: false } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinsFromIndex", () => {
|
||||
const index = {
|
||||
plugins: [
|
||||
@@ -184,6 +266,15 @@ describe("pinsFromIndex", () => {
|
||||
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the onboarding defaults of an existing pin", () => {
|
||||
const current = [
|
||||
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
||||
];
|
||||
const pins = pinsFromIndex(index, null, current);
|
||||
expect(pins[1].onboarding).toEqual({ recommended: true, consent: false });
|
||||
expect(pins[0]).not.toHaveProperty("onboarding");
|
||||
});
|
||||
|
||||
it("pins only the ids asked for", () => {
|
||||
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
|
||||
"beta",
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
*
|
||||
* A tmxplug entry is pinned by sha256 in this file, which is reviewed like
|
||||
* any other change, so the build does not need the registry signature.
|
||||
*
|
||||
* An entry can also carry "onboarding": { "recommended": true | "desktop",
|
||||
* "consent": true, "enabledByEnv": ["NAME"] }. The first-run plugin picker
|
||||
* keeps recommended plugins by default ("desktop" only in the desktop app),
|
||||
* and shows consent ones (like telemetry) in their own opt-in section.
|
||||
* enabledByEnv counts the plugin as recommended when any of those
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* Plugins without it start unchecked.
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
@@ -14,8 +22,54 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
||||
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
|
||||
function parseOnboarding(value, id, problems) {
|
||||
if (value === undefined) return null;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
problems.push(`${id} has an onboarding field that is not an object`);
|
||||
return null;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!ONBOARDING_KEYS.has(key)) {
|
||||
problems.push(`${id} has an unknown onboarding field "${key}"`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const { recommended = false, consent = false } = value;
|
||||
if (
|
||||
recommended !== true &&
|
||||
recommended !== false &&
|
||||
recommended !== "desktop"
|
||||
) {
|
||||
problems.push(
|
||||
`${id} onboarding.recommended must be true, false or "desktop"`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (typeof consent !== "boolean") {
|
||||
problems.push(`${id} onboarding.consent must be a boolean`);
|
||||
return null;
|
||||
}
|
||||
const { enabledByEnv } = value;
|
||||
if (enabledByEnv === undefined) return { recommended, consent };
|
||||
if (
|
||||
!Array.isArray(enabledByEnv) ||
|
||||
enabledByEnv.length === 0 ||
|
||||
!enabledByEnv.every(
|
||||
(name) => typeof name === "string" && ENV_NAME.test(name),
|
||||
)
|
||||
) {
|
||||
problems.push(
|
||||
`${id} onboarding.enabledByEnv must be a list of environment variable names`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { recommended, consent, enabledByEnv };
|
||||
}
|
||||
|
||||
function parseBundledPlugins(raw) {
|
||||
const problems = [];
|
||||
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
|
||||
@@ -54,12 +108,16 @@ function parseBundledPlugins(raw) {
|
||||
problems.push(`${entry.id} needs a lowercase hex sha256`);
|
||||
continue;
|
||||
}
|
||||
const before = problems.length;
|
||||
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
||||
if (problems.length > before) continue;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
source: "tmxplug",
|
||||
url: hasUrl ? entry.url : null,
|
||||
path: hasPath ? entry.path : null,
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -153,21 +211,39 @@ async function extractArtifact(buffer, outDir, id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the newest registry version of each wanted plugin. `ids` is the list
|
||||
* to pin, or null for every plugin in the index.
|
||||
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
|
||||
* staged plugin, read by the backend when it seeds a fresh install.
|
||||
*/
|
||||
function pinsFromIndex(index, ids) {
|
||||
function buildBundledIndex(plugins, stagedIds) {
|
||||
const staged = new Set(stagedIds);
|
||||
const out = {};
|
||||
for (const plugin of plugins) {
|
||||
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
|
||||
out[plugin.id] = { onboarding: plugin.onboarding };
|
||||
}
|
||||
return { version: 1, plugins: out };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the newest registry version of each wanted plugin. `ids` is the list
|
||||
* to pin, or null for every plugin in the index. `current` is the existing
|
||||
* pin list, so fields the registry does not know (onboarding) carry over.
|
||||
*/
|
||||
function pinsFromIndex(index, ids, current = []) {
|
||||
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
||||
return [...wanted].sort().map((id) => {
|
||||
const latest = byId.get(id)?.versions?.[0];
|
||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||
const onboarding = currentById.get(id)?.onboarding;
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -273,6 +349,7 @@ module.exports = {
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
parseBundledPlugins,
|
||||
buildBundledIndex,
|
||||
pinsFromIndex,
|
||||
loadBundledPlugins,
|
||||
checkSha256,
|
||||
|
||||
@@ -28,8 +28,9 @@ async function main() {
|
||||
}
|
||||
const index = await response.json();
|
||||
|
||||
const ids = all ? null : loadBundledPlugins(root).map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids);
|
||||
const current = loadBundledPlugins(root);
|
||||
const ids = all ? null : current.map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids, current);
|
||||
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
|
||||
console.log(
|
||||
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
|
||||
|
||||
@@ -18,6 +18,10 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { PluginManageError } from "../../plugins/manage.js";
|
||||
import {
|
||||
isPluginChoice,
|
||||
type PluginChoice,
|
||||
} from "../../../types/plugin-onboarding.js";
|
||||
import {
|
||||
findField,
|
||||
getAllSettings,
|
||||
@@ -398,6 +402,131 @@ async function refuseOnLinkedDesktop(res: Response): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/onboarding:
|
||||
* get:
|
||||
* summary: List plugins for the onboarding plugin picker
|
||||
* description: Every installed plugin with its category, hard dependencies, current state and the onboarding defaults from the bundled index (recommended, consent). pending is true on a fresh install until an admin confirms the picker. managedByLinkedServer is true on a desktop linked to a server, where the picker is not shown.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The picker's plugin list.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 500:
|
||||
* description: The list could not be read.
|
||||
*/
|
||||
router.get(
|
||||
"/onboarding",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { describeOnboardingPlugins } =
|
||||
await import("../../plugins/onboarding.js");
|
||||
res.json(await describeOnboardingPlugins());
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to list plugins for onboarding",
|
||||
"plugin_onboarding_list",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/onboarding/apply:
|
||||
* post:
|
||||
* summary: Apply the onboarding plugin picker
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [choices]
|
||||
* properties:
|
||||
* choices:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: string
|
||||
* enum: [enabled, disabled, remove]
|
||||
* dryRun:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What was enabled, disabled, removed or failed.
|
||||
* 400:
|
||||
* description: The choices are not valid.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: Plugins on a linked desktop are managed by the server.
|
||||
* 500:
|
||||
* description: The choices could not be applied.
|
||||
*/
|
||||
router.post(
|
||||
"/onboarding/apply",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const { choices, dryRun } = req.body ?? {};
|
||||
if (
|
||||
!choices ||
|
||||
typeof choices !== "object" ||
|
||||
Array.isArray(choices) ||
|
||||
!Object.values(choices).every(isPluginChoice) ||
|
||||
(dryRun !== undefined && typeof dryRun !== "boolean")
|
||||
) {
|
||||
res.status(400).json({ error: "Invalid plugin choices" });
|
||||
return;
|
||||
}
|
||||
if (!dryRun && (await refuseOnLinkedDesktop(res))) return;
|
||||
|
||||
try {
|
||||
const { applyOnboardingChoices } =
|
||||
await import("../../plugins/onboarding.js");
|
||||
const result = await applyOnboardingChoices(
|
||||
choices as Record<string, PluginChoice>,
|
||||
{ dryRun: dryRun === true },
|
||||
);
|
||||
if (!dryRun) {
|
||||
for (const id of result.enabled) {
|
||||
await auditPluginAction(req, "enable_plugin", id, {
|
||||
via: "onboarding",
|
||||
});
|
||||
}
|
||||
for (const id of result.disabled) {
|
||||
await auditPluginAction(req, "disable_plugin", id, {
|
||||
via: "onboarding",
|
||||
});
|
||||
}
|
||||
for (const id of result.removed) {
|
||||
await auditPluginAction(req, "uninstall_plugin", id, {
|
||||
via: "onboarding",
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to apply plugin choices",
|
||||
"plugin_onboarding_apply",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/state:
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
defaultUiPreferences,
|
||||
sanitizeOnboarding,
|
||||
sanitizeUiPreferences,
|
||||
UI_ONBOARDING_VERSION,
|
||||
type UiPreferences,
|
||||
} from "../../../types/ui-preferences.js";
|
||||
import { LEGACY_SEEN, mergeSeen } from "../../../types/onboarding.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -23,8 +24,8 @@ const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* Existing users must never be ambushed by onboarding. A user who never
|
||||
* finished it and registered more than a day ago predates this feature, so
|
||||
* they are handed a completed onboarding state. This also repairs rows that an
|
||||
* earlier PUT wrote with the not-completed default.
|
||||
* they count as having seen every core step, and the client silently marks
|
||||
* the plugin steps they have now as seen too (baselinePending).
|
||||
*/
|
||||
export function withOnboardingBackfill(
|
||||
preferences: UiPreferences,
|
||||
@@ -35,13 +36,21 @@ export function withOnboardingBackfill(
|
||||
Number.isFinite(registeredMs) &&
|
||||
Date.now() - registeredMs < NEW_ACCOUNT_WINDOW_MS;
|
||||
|
||||
if (isNewAccount || preferences.onboarding.completedAt) return preferences;
|
||||
const { onboarding } = preferences;
|
||||
if (
|
||||
isNewAccount ||
|
||||
onboarding.completedAt ||
|
||||
Object.keys(onboarding.seen).length > 0
|
||||
) {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
onboarding: {
|
||||
...preferences.onboarding,
|
||||
completedVersion: UI_ONBOARDING_VERSION,
|
||||
...onboarding,
|
||||
seen: { ...LEGACY_SEEN },
|
||||
baselinePending: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -51,7 +60,7 @@ export function withOnboardingBackfill(
|
||||
* /ui-preferences:
|
||||
* get:
|
||||
* summary: Get the UI complexity preferences for the current user
|
||||
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state. A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered before this feature existed are returned an already-completed onboarding state so they are never shown the first-run flow.
|
||||
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state (which onboarding steps they have seen, by version). A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered more than a day ago and never finished onboarding are returned as having seen every core step, so they are never shown the first-run flow.
|
||||
* tags:
|
||||
* - UI Preferences
|
||||
* responses:
|
||||
@@ -89,7 +98,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* /ui-preferences:
|
||||
* put:
|
||||
* summary: Update the UI complexity preferences for the current user
|
||||
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them.
|
||||
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them. Onboarding seen versions are merged per step and never go down, so two open tabs cannot undo each other.
|
||||
* tags:
|
||||
* - UI Preferences
|
||||
* requestBody:
|
||||
@@ -164,10 +173,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
// Must come after the body spread, or a raw overrides payload would
|
||||
// clobber the merge above.
|
||||
overrides: mergedOverrides,
|
||||
onboarding: {
|
||||
...base.onboarding,
|
||||
...((body.onboarding as Record<string, unknown>) ?? {}),
|
||||
},
|
||||
onboarding: mergeOnboarding(base, body.onboarding),
|
||||
});
|
||||
|
||||
await repository.upsert(userId, JSON.stringify(merged));
|
||||
@@ -182,4 +188,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
function mergeOnboarding(base: UiPreferences, patch: unknown) {
|
||||
if (!patch || typeof patch !== "object") return base.onboarding;
|
||||
const incoming = sanitizeOnboarding({
|
||||
...base.onboarding,
|
||||
...(patch as Record<string, unknown>),
|
||||
seen: (patch as Record<string, unknown>).seen ?? {},
|
||||
});
|
||||
return {
|
||||
...incoming,
|
||||
seen: mergeSeen(base.onboarding.seen, incoming.seen),
|
||||
completedAt: incoming.completedAt ?? base.onboarding.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The onboarding defaults of the bundled plugins, from
|
||||
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
|
||||
* docker/bundled-plugins.json). Core never names plugins, so which ones a
|
||||
* fresh install keeps lives in that file and not here.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getBundledPluginsDir } from "./paths.js";
|
||||
|
||||
export const BUNDLED_INDEX_FILE = "bundled-index.json";
|
||||
|
||||
export interface OnboardingDefaults {
|
||||
recommended: boolean;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
type Recommended = boolean | "desktop";
|
||||
|
||||
interface BundledIndex {
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
onboarding?: {
|
||||
recommended?: Recommended;
|
||||
consent?: boolean;
|
||||
enabledByEnv?: string[];
|
||||
};
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
let cached: BundledIndex | null | undefined;
|
||||
|
||||
export function readBundledIndex(): BundledIndex | null {
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(getBundledPluginsDir(), BUNDLED_INDEX_FILE),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
cached =
|
||||
raw && typeof raw.plugins === "object" && raw.plugins
|
||||
? { plugins: raw.plugins }
|
||||
: null;
|
||||
} catch {
|
||||
cached = null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function resetBundledIndexCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
function isDesktop(): boolean {
|
||||
return process.env.ELECTRON_EMBEDDED === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Without an index (a build that skipped build-plugins) every plugin counts
|
||||
* as recommended, which is how installs behaved before the picker. A plugin
|
||||
* whose enabledByEnv variables are set counts as recommended too, so a server
|
||||
* set up for it through the environment works from the first boot.
|
||||
*/
|
||||
export function onboardingDefaults(pluginId: string): OnboardingDefaults {
|
||||
const index = readBundledIndex();
|
||||
if (!index) return { recommended: true, consent: false };
|
||||
const entry = index.plugins[pluginId]?.onboarding;
|
||||
const recommended = entry?.recommended;
|
||||
const configured = (entry?.enabledByEnv ?? []).some(
|
||||
(name) => typeof name === "string" && !!process.env[name]?.trim(),
|
||||
);
|
||||
return {
|
||||
recommended:
|
||||
recommended === true ||
|
||||
(recommended === "desktop" && isDesktop()) ||
|
||||
configured,
|
||||
consent: entry?.consent === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a newly seen plugin's row starts in. Bundled plugins start
|
||||
* enabled, except on a fresh install, where only recommended ones do and
|
||||
* consent plugins wait for the admin. A plugin dropped into the data
|
||||
* directory always starts disabled.
|
||||
*/
|
||||
export function initialPluginState(
|
||||
pluginId: string,
|
||||
source: "bundled" | "user",
|
||||
freshInstall: boolean,
|
||||
): "enabled" | "disabled" {
|
||||
if (source !== "bundled") return "disabled";
|
||||
if (!freshInstall) return "enabled";
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent ? "enabled" : "disabled";
|
||||
}
|
||||
@@ -126,11 +126,21 @@ async function handlePluginFailed(plugin: LoadedPlugin): Promise<void> {
|
||||
* `state` is only ever set on insert. Once the row exists whatever the user
|
||||
* chose wins, because re-enabling a plugin they disabled on every restart
|
||||
* would be a bug rather than a default.
|
||||
*
|
||||
* A fresh install (no users, no plugin rows) is different: only the bundled
|
||||
* plugins marked recommended start enabled, and the first admin picks the
|
||||
* rest in onboarding. Consent plugins wait for that choice too, even when
|
||||
* the picker checks them by default.
|
||||
*/
|
||||
async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
|
||||
async function seedPlugins(
|
||||
loaded: LoadedPlugin[],
|
||||
freshInstall: boolean,
|
||||
): Promise<boolean> {
|
||||
const { createCurrentPluginRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const repository = createCurrentPluginRepository();
|
||||
const fresh = freshInstall && (await repository.listAll()).length === 0;
|
||||
const { initialPluginState } = await import("./bundled-index.js");
|
||||
|
||||
for (const plugin of loaded) {
|
||||
const manifestJson = JSON.stringify(plugin.manifest);
|
||||
@@ -155,7 +165,7 @@ async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
|
||||
version: plugin.manifest.version,
|
||||
tier: plugin.source === "bundled" ? "bundled" : "community",
|
||||
source: plugin.source,
|
||||
state: plugin.source === "bundled" ? "enabled" : "disabled",
|
||||
state: initialPluginState(plugin.id, plugin.source, fresh),
|
||||
manifestJson,
|
||||
});
|
||||
|
||||
@@ -163,6 +173,8 @@ async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
|
||||
operation: "plugin_seed",
|
||||
});
|
||||
}
|
||||
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,7 +224,9 @@ async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
|
||||
* Loads every plugin on disk and activates the ones marked enabled, in
|
||||
* dependency order. Called once from the backend start-up sequence.
|
||||
*/
|
||||
export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
||||
export async function initializePlugins(
|
||||
options: { freshInstall?: boolean } = {},
|
||||
): Promise<LoadedPlugin[]> {
|
||||
const { loader: pluginLoader } = getPluginRuntime();
|
||||
|
||||
const { applyStoredPluginChoices } = await import("./manage.js");
|
||||
@@ -221,8 +235,12 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
||||
const loaded = await pluginLoader.loadAll();
|
||||
if (loaded.length === 0) return [];
|
||||
|
||||
await seedPlugins(loaded);
|
||||
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
||||
await syncCapabilityGrants(loaded);
|
||||
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
||||
const { offerPluginSetup } = await import("./onboarding.js");
|
||||
await offerPluginSetup(fresh);
|
||||
}
|
||||
|
||||
const { createCurrentPluginRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
@@ -565,7 +583,7 @@ export async function installPluginArtifact(
|
||||
file,
|
||||
pluginLoader.bundledIds(),
|
||||
);
|
||||
await seedPlugins([plugin]);
|
||||
await seedPlugins([plugin], false);
|
||||
await syncCapabilityGrants([plugin]);
|
||||
return plugin;
|
||||
}
|
||||
@@ -574,7 +592,7 @@ export async function installPluginArtifact(
|
||||
export async function registerLoadedPlugins(
|
||||
loaded: LoadedPlugin[],
|
||||
): Promise<void> {
|
||||
await seedPlugins(loaded);
|
||||
await seedPlugins(loaded, false);
|
||||
await syncCapabilityGrants(loaded);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export class PluginManageError extends Error {
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** One change at a time: dependencies make concurrent changes unsafe. */
|
||||
function serialized<T>(fn: () => Promise<T>): Promise<T> {
|
||||
export function serialized<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = queue.then(fn, fn);
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
@@ -543,41 +543,47 @@ export function setPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<StateChangePlan & { state: string }> {
|
||||
return serialized(async () => {
|
||||
const { loader } = getPluginRuntime();
|
||||
if (!loader.get(pluginId)) {
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const plan = await planStateChange(pluginId, enabled);
|
||||
return serialized(() => setPluginStateUnlocked(pluginId, enabled));
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (plan.missing.length > 0) {
|
||||
throw new PluginManageError(
|
||||
"Some dependencies are not installed",
|
||||
409,
|
||||
"MISSING_DEPENDENCIES",
|
||||
{ missing: plan.missing },
|
||||
/** setPluginState for a caller already inside serialized(). */
|
||||
export async function setPluginStateUnlocked(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<StateChangePlan & { state: string }> {
|
||||
const { loader } = getPluginRuntime();
|
||||
if (!loader.get(pluginId)) {
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const plan = await planStateChange(pluginId, enabled);
|
||||
|
||||
if (enabled) {
|
||||
if (plan.missing.length > 0) {
|
||||
throw new PluginManageError(
|
||||
"Some dependencies are not installed",
|
||||
409,
|
||||
"MISSING_DEPENDENCIES",
|
||||
{ missing: plan.missing },
|
||||
);
|
||||
}
|
||||
for (const id of plan.enable) {
|
||||
if ((await startAndRecord(id)) === "failed") {
|
||||
await recordState(
|
||||
pluginId,
|
||||
"blocked",
|
||||
`requires plugin "${id}", which could not start`,
|
||||
);
|
||||
return { ...plan, state: "blocked" };
|
||||
}
|
||||
for (const id of plan.enable) {
|
||||
if ((await startAndRecord(id)) === "failed") {
|
||||
await recordState(
|
||||
pluginId,
|
||||
"blocked",
|
||||
`requires plugin "${id}", which could not start`,
|
||||
);
|
||||
return { ...plan, state: "blocked" };
|
||||
}
|
||||
}
|
||||
return { ...plan, state: await startAndRecord(pluginId) };
|
||||
}
|
||||
return { ...plan, state: await startAndRecord(pluginId) };
|
||||
}
|
||||
|
||||
for (const id of [...plan.disable, pluginId]) {
|
||||
await recordState(id, "disabled", null);
|
||||
await deactivatePlugin(id);
|
||||
}
|
||||
return { ...plan, state: "stopped" };
|
||||
});
|
||||
for (const id of [...plan.disable, pluginId]) {
|
||||
await recordState(id, "disabled", null);
|
||||
await deactivatePlugin(id);
|
||||
}
|
||||
return { ...plan, state: "stopped" };
|
||||
}
|
||||
|
||||
export interface InstallOptions {
|
||||
@@ -858,68 +864,73 @@ export function updateAllPlugins(options: {
|
||||
export function uninstallPlugin(
|
||||
pluginId: string,
|
||||
): Promise<{ id: string; removed: { tables: string[]; kvKeys: number } }> {
|
||||
return serialized(async () => {
|
||||
if (!ID_PATTERN.test(pluginId)) {
|
||||
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
|
||||
}
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const record = await repository.findById(pluginId);
|
||||
const { loader } = getPluginRuntime();
|
||||
const live = loader.get(pluginId);
|
||||
if (!record && !live) {
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const shipped = loader.bundledIds().has(pluginId);
|
||||
return serialized(() => uninstallPluginUnlocked(pluginId));
|
||||
}
|
||||
|
||||
const dependents = dependentChain(pluginId, await enabledIds());
|
||||
if (dependents.length > 0) {
|
||||
throw new PluginManageError(
|
||||
"Other plugins depend on this one",
|
||||
409,
|
||||
"HAS_DEPENDENTS",
|
||||
{ dependents },
|
||||
);
|
||||
}
|
||||
/** uninstallPlugin for a caller already inside serialized(). */
|
||||
export async function uninstallPluginUnlocked(
|
||||
pluginId: string,
|
||||
): Promise<{ id: string; removed: { tables: string[]; kvKeys: number } }> {
|
||||
if (!ID_PATTERN.test(pluginId)) {
|
||||
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
|
||||
}
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const record = await repository.findById(pluginId);
|
||||
const { loader } = getPluginRuntime();
|
||||
const live = loader.get(pluginId);
|
||||
if (!record && !live) {
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const shipped = loader.bundledIds().has(pluginId);
|
||||
|
||||
if (live) await deactivatePlugin(pluginId);
|
||||
const dependents = dependentChain(pluginId, await enabledIds());
|
||||
if (dependents.length > 0) {
|
||||
throw new PluginManageError(
|
||||
"Other plugins depend on this one",
|
||||
409,
|
||||
"HAS_DEPENDENTS",
|
||||
{ dependents },
|
||||
);
|
||||
}
|
||||
|
||||
const { removePluginData } = await import("./data.js");
|
||||
const removed = await removePluginData(pluginId, {
|
||||
knownPluginIds: loader.list().map((plugin) => plugin.id),
|
||||
});
|
||||
if (live) await deactivatePlugin(pluginId);
|
||||
|
||||
if (live) loader.forget(pluginId);
|
||||
await removeArtifactFiles(pluginId, live?.artifact);
|
||||
await removeUserFolder(pluginId, live);
|
||||
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.promises.rm(getPluginDataDir(pluginId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await repository.delete(pluginId);
|
||||
await applyPin(pluginId, null);
|
||||
|
||||
// Ignored even when the files are gone: a recreated container brings
|
||||
// the image's copy back, and it must not load in place of a reinstall.
|
||||
if (shipped) {
|
||||
const uninstalled = await readUninstalled();
|
||||
uninstalled.add(pluginId);
|
||||
await writeUninstalled(uninstalled);
|
||||
await removeShippedCopy(pluginId);
|
||||
}
|
||||
|
||||
pluginLogger.info(`Uninstalled plugin ${pluginId}`, {
|
||||
operation: "plugin_uninstall",
|
||||
});
|
||||
return {
|
||||
id: pluginId,
|
||||
removed: { tables: removed.tables, kvKeys: removed.kvKeys },
|
||||
};
|
||||
const { removePluginData } = await import("./data.js");
|
||||
const removed = await removePluginData(pluginId, {
|
||||
knownPluginIds: loader.list().map((plugin) => plugin.id),
|
||||
});
|
||||
|
||||
if (live) loader.forget(pluginId);
|
||||
await removeArtifactFiles(pluginId, live?.artifact);
|
||||
await removeUserFolder(pluginId, live);
|
||||
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.promises.rm(getPluginDataDir(pluginId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await repository.delete(pluginId);
|
||||
await applyPin(pluginId, null);
|
||||
|
||||
// Ignored even when the files are gone: a recreated container brings
|
||||
// the image's copy back, and it must not load in place of a reinstall.
|
||||
if (shipped) {
|
||||
const uninstalled = await readUninstalled();
|
||||
uninstalled.add(pluginId);
|
||||
await writeUninstalled(uninstalled);
|
||||
await removeShippedCopy(pluginId);
|
||||
}
|
||||
|
||||
pluginLogger.info(`Uninstalled plugin ${pluginId}`, {
|
||||
operation: "plugin_uninstall",
|
||||
});
|
||||
return {
|
||||
id: pluginId,
|
||||
removed: { tables: removed.tables, kvKeys: removed.kvKeys },
|
||||
};
|
||||
}
|
||||
|
||||
export function setPluginOptions(
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* The onboarding plugin picker: what a fresh install's first admin sees and
|
||||
* how their choices are applied in one go.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { getPluginRuntime } from "./index.js";
|
||||
import { onboardingDefaults } from "./bundled-index.js";
|
||||
import {
|
||||
isLinkedDesktop,
|
||||
serialized,
|
||||
setPluginStateUnlocked,
|
||||
uninstallPluginUnlocked,
|
||||
} from "./manage.js";
|
||||
import {
|
||||
applyOrder,
|
||||
resolvePluginChoices,
|
||||
type ChoiceAdjustment,
|
||||
type ChoicePlugin,
|
||||
type PluginChoice,
|
||||
} from "../../types/plugin-onboarding.js";
|
||||
|
||||
/**
|
||||
* Set until an admin confirms the picker: "fresh" on a new install, "upgrade"
|
||||
* on one that existed before the picker (2.9 and earlier).
|
||||
*/
|
||||
export const PLUGIN_SETUP_PENDING_KEY = "onboarding_plugin_setup_pending";
|
||||
/** Set once the picker has been offered, so it is offered only once. */
|
||||
export const PLUGIN_SETUP_OFFERED_KEY = "onboarding_plugin_setup_offered";
|
||||
|
||||
export type PluginSetupReason = "fresh" | "upgrade";
|
||||
|
||||
export interface OnboardingPluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string | null;
|
||||
category: string;
|
||||
version: string;
|
||||
source: "bundled" | "user";
|
||||
state: string;
|
||||
dependencies: string[];
|
||||
recommended: boolean;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingPluginList {
|
||||
pending: boolean;
|
||||
/** Why the picker is pending. On an upgrade it starts from what is on. */
|
||||
reason: PluginSetupReason | null;
|
||||
managedByLinkedServer: boolean;
|
||||
plugins: OnboardingPluginInfo[];
|
||||
}
|
||||
|
||||
export interface OnboardingApplyResult {
|
||||
resolved: Record<string, PluginChoice>;
|
||||
adjustments: ChoiceAdjustment[];
|
||||
enabled: string[];
|
||||
disabled: string[];
|
||||
removed: string[];
|
||||
failed: { id: string; error: string; code?: string }[];
|
||||
}
|
||||
|
||||
async function repos() {
|
||||
return import("../database/repositories/factory.js");
|
||||
}
|
||||
|
||||
export async function pluginSetupReason(): Promise<PluginSetupReason | null> {
|
||||
const { createCurrentSettingsRepository } = await repos();
|
||||
const value = await createCurrentSettingsRepository().get(
|
||||
PLUGIN_SETUP_PENDING_KEY,
|
||||
);
|
||||
if (value === "fresh" || value === "1") return "fresh";
|
||||
if (value === "upgrade") return "upgrade";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isPluginSetupPending(): Promise<boolean> {
|
||||
return (await pluginSetupReason()) !== null;
|
||||
}
|
||||
|
||||
async function clearPluginSetupPending(): Promise<void> {
|
||||
const { createCurrentSettingsRepository } = await repos();
|
||||
const settings = createCurrentSettingsRepository();
|
||||
await settings.delete(PLUGIN_SETUP_PENDING_KEY);
|
||||
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once at boot. Every install is offered the picker once: a fresh
|
||||
* one on its first admin's first run, an upgraded one (which never saw it)
|
||||
* the next time an admin signs in.
|
||||
*/
|
||||
export async function offerPluginSetup(fresh: boolean): Promise<void> {
|
||||
const { createCurrentSettingsRepository } = await repos();
|
||||
const settings = createCurrentSettingsRepository();
|
||||
if (await settings.get(PLUGIN_SETUP_OFFERED_KEY)) return;
|
||||
await settings.set(PLUGIN_SETUP_PENDING_KEY, fresh ? "fresh" : "upgrade");
|
||||
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
|
||||
}
|
||||
|
||||
function choicePlugins(): ChoicePlugin[] {
|
||||
return getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
dependencies: Object.keys(plugin.manifest.dependencies ?? {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function describeOnboardingPlugins(): Promise<OnboardingPluginList> {
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const states = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record.state,
|
||||
]),
|
||||
);
|
||||
const plugins = getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin): OnboardingPluginInfo => {
|
||||
const defaults =
|
||||
plugin.source === "bundled"
|
||||
? onboardingDefaults(plugin.id)
|
||||
: { recommended: false, consent: false };
|
||||
return {
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
description: plugin.manifest.description,
|
||||
icon: plugin.manifest.icon ?? null,
|
||||
category: plugin.manifest.category,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
state: states.get(plugin.id) ?? "disabled",
|
||||
dependencies: Object.keys(plugin.manifest.dependencies ?? {}),
|
||||
...defaults,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const reason = await pluginSetupReason();
|
||||
return {
|
||||
pending: reason !== null,
|
||||
reason,
|
||||
managedByLinkedServer: await isLinkedDesktop(),
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the picker in one serialized pass: enable (dependencies first),
|
||||
* then disable and remove (dependents first). One plugin failing does not
|
||||
* stop the rest. Ids that are not installed are ignored.
|
||||
*/
|
||||
export function applyOnboardingChoices(
|
||||
choices: Record<string, PluginChoice>,
|
||||
options: { dryRun?: boolean } = {},
|
||||
): Promise<OnboardingApplyResult> {
|
||||
return serialized(async () => {
|
||||
const plugins = choicePlugins();
|
||||
const installed = new Set(plugins.map((plugin) => plugin.id));
|
||||
const wanted: Record<string, PluginChoice> = {};
|
||||
for (const [id, choice] of Object.entries(choices)) {
|
||||
if (installed.has(id)) wanted[id] = choice;
|
||||
}
|
||||
const { choices: resolved, adjustments } = resolvePluginChoices(
|
||||
plugins,
|
||||
wanted,
|
||||
);
|
||||
|
||||
const result: OnboardingApplyResult = {
|
||||
resolved,
|
||||
adjustments,
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
removed: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const states = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record.state,
|
||||
]),
|
||||
);
|
||||
const order = applyOrder(plugins, resolved);
|
||||
const enable = order.enable.filter((id) => states.get(id) !== "enabled");
|
||||
const disable = order.disable.filter((id) => states.get(id) === "enabled");
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
...result,
|
||||
enabled: enable,
|
||||
disabled: disable,
|
||||
removed: order.remove,
|
||||
};
|
||||
}
|
||||
|
||||
const attempt = async (
|
||||
id: string,
|
||||
run: () => Promise<unknown>,
|
||||
done: string[],
|
||||
) => {
|
||||
try {
|
||||
const outcome = (await run()) as { state?: string } | undefined;
|
||||
if (outcome?.state === "failed" || outcome?.state === "blocked") {
|
||||
result.failed.push({ id, error: outcome.state });
|
||||
return;
|
||||
}
|
||||
done.push(id);
|
||||
} catch (error) {
|
||||
result.failed.push({
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
code: (error as { code?: string })?.code,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const id of enable) {
|
||||
await attempt(id, () => setPluginStateUnlocked(id, true), result.enabled);
|
||||
}
|
||||
for (const id of disable) {
|
||||
await attempt(
|
||||
id,
|
||||
() => setPluginStateUnlocked(id, false),
|
||||
result.disabled,
|
||||
);
|
||||
}
|
||||
for (const id of order.remove) {
|
||||
await attempt(id, () => uninstallPluginUnlocked(id), result.removed);
|
||||
}
|
||||
|
||||
await clearPluginSetupPending();
|
||||
pluginLogger.info(
|
||||
`Onboarding plugin choices applied: ${result.enabled.length} enabled, ${result.disabled.length} disabled, ${result.removed.length} removed, ${result.failed.length} failed`,
|
||||
{ operation: "plugin_onboarding" },
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -204,6 +204,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
await import("./hosts/status/host-status-service.js");
|
||||
hostStatusService.start();
|
||||
|
||||
// Counted before the desktop user is provisioned, so a first desktop
|
||||
// run counts as a fresh install too.
|
||||
const { createCurrentUserRepository } =
|
||||
await import("./database/repositories/factory.js");
|
||||
const freshInstall = (await createCurrentUserRepository().countAll()) === 0;
|
||||
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
await provisionLocalDesktopUserIfNeeded();
|
||||
}
|
||||
@@ -238,7 +244,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// fails to load must not stop the backend, so this never rejects.
|
||||
try {
|
||||
const { initializePlugins } = await import("./plugins/index.js");
|
||||
const loaded = await initializePlugins();
|
||||
const loaded = await initializePlugins({ freshInstall });
|
||||
if (loaded.length > 0) {
|
||||
systemLogger.info(`Loaded ${loaded.length} plugin(s)`, {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -192,6 +192,13 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const onboardingMock = vi.hoisted(() => ({
|
||||
describeOnboardingPlugins: vi.fn(),
|
||||
applyOnboardingChoices: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
|
||||
|
||||
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
||||
.default;
|
||||
|
||||
@@ -614,6 +621,87 @@ describe("plugins route", () => {
|
||||
expect(state.grants).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe("onboarding plugin picker", () => {
|
||||
const post = (body: unknown, user = "admin-1") =>
|
||||
fetch(`${baseUrl}/plugins/onboarding/apply`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-test-user-id": user },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
onboardingMock.describeOnboardingPlugins.mockReset();
|
||||
onboardingMock.applyOnboardingChoices.mockReset();
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
auditMock.logAudit.mockClear();
|
||||
});
|
||||
|
||||
it("lists plugins for a manager only", async () => {
|
||||
onboardingMock.describeOnboardingPlugins.mockResolvedValue({
|
||||
pending: true,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
});
|
||||
const ok = await fetch(`${baseUrl}/plugins/onboarding`, {
|
||||
headers: { "x-test-user-id": "admin-1" },
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.json()).toMatchObject({ pending: true });
|
||||
|
||||
const denied = await fetch(`${baseUrl}/plugins/onboarding`, {
|
||||
headers: { "x-test-user-id": "user-2" },
|
||||
});
|
||||
expect(denied.status).toBe(403);
|
||||
});
|
||||
|
||||
it("applies choices and audits each change", async () => {
|
||||
onboardingMock.applyOnboardingChoices.mockResolvedValue({
|
||||
resolved: {},
|
||||
adjustments: [],
|
||||
enabled: ["a"],
|
||||
disabled: ["b"],
|
||||
removed: ["c"],
|
||||
failed: [],
|
||||
});
|
||||
const res = await post({
|
||||
choices: { a: "enabled", b: "disabled", c: "remove" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(onboardingMock.applyOnboardingChoices).toHaveBeenCalledWith(
|
||||
{ a: "enabled", b: "disabled", c: "remove" },
|
||||
{ dryRun: false },
|
||||
);
|
||||
const actions = auditMock.logAudit.mock.calls.map(
|
||||
(call) => (call as unknown as [{ action: string }])[0].action,
|
||||
);
|
||||
expect(actions).toEqual([
|
||||
"enable_plugin",
|
||||
"disable_plugin",
|
||||
"uninstall_plugin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects bad choices", async () => {
|
||||
expect((await post({})).status).toBe(400);
|
||||
expect((await post({ choices: ["a"] })).status).toBe(400);
|
||||
expect((await post({ choices: { a: "delete" } })).status).toBe(400);
|
||||
expect(
|
||||
(await post({ choices: { a: "enabled" }, dryRun: "yes" })).status,
|
||||
).toBe(400);
|
||||
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses on a linked desktop and for non-managers", async () => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
expect((await post({ choices: { a: "enabled" } })).status).toBe(409);
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
expect((await post({ choices: { a: "enabled" } }, "user-2")).status).toBe(
|
||||
403,
|
||||
);
|
||||
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("install, update, uninstall", () => {
|
||||
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { UI_ONBOARDING_VERSION } from "../../../../types/ui-preferences.js";
|
||||
import { LEGACY_SEEN } from "../../../../types/onboarding.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
row: null as { data: string } | null,
|
||||
@@ -80,18 +80,16 @@ afterEach(async () => {
|
||||
|
||||
describe("ui-preferences onboarding backfill", () => {
|
||||
it("treats an old account with no row as onboarded", async () => {
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION,
|
||||
);
|
||||
const onboarding = (await get()).onboarding;
|
||||
expect(onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect(onboarding.baselinePending).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an old account onboarded after its first write", async () => {
|
||||
await put({ overrides: { "plugin:x": { a: 1 } } });
|
||||
const stored = JSON.parse(state.row!.data);
|
||||
expect(stored.onboarding.completedVersion).toBe(UI_ONBOARDING_VERSION);
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION,
|
||||
);
|
||||
expect(stored.onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
});
|
||||
|
||||
it("repairs a row an earlier write stored as not completed", async () => {
|
||||
@@ -100,29 +98,61 @@ describe("ui-preferences onboarding backfill", () => {
|
||||
onboarding: { completedVersion: 0, completedAt: null, skipped: false },
|
||||
}),
|
||||
};
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION,
|
||||
);
|
||||
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
});
|
||||
|
||||
it("migrates the old completedVersion shape", async () => {
|
||||
state.registeredAt = NEW;
|
||||
state.row = {
|
||||
data: JSON.stringify({
|
||||
onboarding: { completedVersion: 2, completedAt: NEW, skipped: false },
|
||||
}),
|
||||
};
|
||||
const onboarding = (await get()).onboarding;
|
||||
expect(onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect(onboarding.baselinePending).toBe(true);
|
||||
});
|
||||
|
||||
it("still shows onboarding to a new account", async () => {
|
||||
state.registeredAt = NEW;
|
||||
await put({ preset: "simple" });
|
||||
expect((await get()).onboarding.completedVersion).toBe(0);
|
||||
expect((await get()).onboarding.seen).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not hide a newer onboarding version from someone who finished an older one", async () => {
|
||||
state.row = {
|
||||
data: JSON.stringify({
|
||||
onboarding: {
|
||||
completedVersion: UI_ONBOARDING_VERSION - 1,
|
||||
completedAt: OLD,
|
||||
skipped: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION - 1,
|
||||
);
|
||||
describe("ui-preferences onboarding seen merge", () => {
|
||||
beforeEach(() => {
|
||||
state.registeredAt = NEW;
|
||||
});
|
||||
|
||||
it("keeps the higher version per step", async () => {
|
||||
await put({ onboarding: { seen: { welcome: 2, "rd:guacd": 1 } } });
|
||||
await put({ onboarding: { seen: { welcome: 1, appearance: 1 } } });
|
||||
expect((await get()).onboarding.seen).toEqual({
|
||||
welcome: 2,
|
||||
"rd:guacd": 1,
|
||||
appearance: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops invalid seen entries", async () => {
|
||||
await put({
|
||||
onboarding: { seen: { ok: 1, zero: 0, text: "1", frac: 1.5 } },
|
||||
});
|
||||
expect((await get()).onboarding.seen).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it("does not clear completedAt when a later write omits it", async () => {
|
||||
await put({ onboarding: { completedAt: NEW, seen: { done: 1 } } });
|
||||
await put({ onboarding: { seen: { "x:y": 1 } } });
|
||||
expect((await get()).onboarding.completedAt).toBe(NEW);
|
||||
});
|
||||
|
||||
it("clears baselinePending when told to", async () => {
|
||||
state.registeredAt = OLD;
|
||||
await put({ onboarding: { baselinePending: false, seen: { "x:y": 1 } } });
|
||||
const onboarding = (await get()).onboarding;
|
||||
expect(onboarding.baselinePending).toBe(false);
|
||||
expect(onboarding.seen["x:y"]).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
initialPluginState,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-index-"));
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = dir;
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
delete process.env.TEST_SSO_CLIENT;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
describe("onboardingDefaults", () => {
|
||||
it("treats everything as recommended without an index", () => {
|
||||
expect(onboardingDefaults("anything")).toEqual({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads recommended and consent from the index", () => {
|
||||
writeIndex({
|
||||
kept: { onboarding: { recommended: true, consent: false } },
|
||||
ask: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
expect(onboardingDefaults("kept")).toEqual({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
});
|
||||
expect(onboardingDefaults("ask").consent).toBe(true);
|
||||
expect(onboardingDefaults("other")).toEqual({
|
||||
recommended: false,
|
||||
consent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("recommends desktop-only plugins in the desktop app only", () => {
|
||||
writeIndex({ desk: { onboarding: { recommended: "desktop" } } });
|
||||
expect(onboardingDefaults("desk").recommended).toBe(false);
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
expect(onboardingDefaults("desk").recommended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enabledByEnv", () => {
|
||||
it("recommends a plugin once its environment config is set", () => {
|
||||
writeIndex({
|
||||
sso: { onboarding: { enabledByEnv: ["TEST_SSO_CLIENT"] } },
|
||||
});
|
||||
expect(onboardingDefaults("sso").recommended).toBe(false);
|
||||
expect(initialPluginState("sso", "bundled", true)).toBe("disabled");
|
||||
process.env.TEST_SSO_CLIENT = " ";
|
||||
expect(onboardingDefaults("sso").recommended).toBe(false);
|
||||
process.env.TEST_SSO_CLIENT = "termix";
|
||||
expect(onboardingDefaults("sso").recommended).toBe(true);
|
||||
expect(initialPluginState("sso", "bundled", true)).toBe("enabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialPluginState", () => {
|
||||
beforeEach(() => {
|
||||
writeIndex({
|
||||
kept: { onboarding: { recommended: true } },
|
||||
ask: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("enables every bundled plugin on an existing install", () => {
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
|
||||
});
|
||||
|
||||
it("enables only recommended, non-consent plugins on a fresh install", () => {
|
||||
expect(initialPluginState("kept", "bundled", true)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", true)).toBe("disabled");
|
||||
expect(initialPluginState("other", "bundled", true)).toBe("disabled");
|
||||
});
|
||||
|
||||
it("never enables a plugin dropped into the data directory", () => {
|
||||
expect(initialPluginState("kept", "user", true)).toBe("disabled");
|
||||
expect(initialPluginState("kept", "user", false)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* The onboarding plugin picker, against a real PluginLoader and fixture
|
||||
* plugins on disk. The database is faked.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
rows: new Map<string, Record<string, unknown>>(),
|
||||
settings: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
listAll: async () => [...db.rows.values()],
|
||||
findById: async (id: string) => db.rows.get(id) ?? null,
|
||||
update: async (id: string, input: Record<string, unknown>) => {
|
||||
const row = db.rows.get(id);
|
||||
if (!row) return null;
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) row[key] = value;
|
||||
}
|
||||
return row;
|
||||
},
|
||||
delete: async (id: string) => db.rows.delete(id),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => void db.settings.set(key, value),
|
||||
delete: async (key: string) => void db.settings.delete(key),
|
||||
}),
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
countKeys: async () => 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/data.js", () => ({
|
||||
migratePlugin: async () => [],
|
||||
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
|
||||
describePluginTables: async () => [],
|
||||
registerTable: () => ({}),
|
||||
forgetTables: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/index.js", async () => {
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const loader = new PluginLoader();
|
||||
return {
|
||||
getPluginRuntime: () => ({ loader }),
|
||||
activatePlugin: (id: string) => loader.activate(id),
|
||||
deactivatePlugin: (id: string) => loader.deactivate(id),
|
||||
registerLoadedPlugins: async (plugins: LoadedPlugin[]) => {
|
||||
for (const plugin of plugins) {
|
||||
db.rows.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
state: "enabled",
|
||||
lastError: null,
|
||||
pinnedVersion: null,
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
});
|
||||
}
|
||||
},
|
||||
unloadPlugin: async () => {},
|
||||
};
|
||||
});
|
||||
|
||||
const { getPluginRuntime, registerLoadedPlugins } =
|
||||
await import("../../plugins/index.js");
|
||||
const manage = await import("../../plugins/manage.js");
|
||||
const onboarding = await import("../../plugins/onboarding.js");
|
||||
const { resetBundledIndexCache } =
|
||||
await import("../../plugins/bundled-index.js");
|
||||
|
||||
const roots: string[] = [];
|
||||
let bundled: string;
|
||||
let data: string;
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
|
||||
async function chain() {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
createFixturePlugin({ id: "extra", root: bundled });
|
||||
const { loader } = getPluginRuntime();
|
||||
await manage.applyStoredPluginChoices(loader);
|
||||
const loaded = await loader.loadAll();
|
||||
await registerLoadedPlugins(loaded);
|
||||
for (const plugin of loaded) await loader.activate(plugin.id);
|
||||
return loader;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bundled = tempRoot("termix-bundled-");
|
||||
data = tempRoot("termix-data-");
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = data;
|
||||
db.rows.clear();
|
||||
db.settings.clear();
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { loader } = getPluginRuntime();
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("offerPluginSetup", () => {
|
||||
it("offers the picker once, as fresh or as an upgrade", async () => {
|
||||
await onboarding.offerPluginSetup(false);
|
||||
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(
|
||||
"upgrade",
|
||||
);
|
||||
expect(await onboarding.pluginSetupReason()).toBe("upgrade");
|
||||
|
||||
db.settings.delete(onboarding.PLUGIN_SETUP_PENDING_KEY);
|
||||
await onboarding.offerPluginSetup(true);
|
||||
expect(await onboarding.isPluginSetupPending()).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a fresh install", async () => {
|
||||
await onboarding.offerPluginSetup(true);
|
||||
expect(await onboarding.pluginSetupReason()).toBe("fresh");
|
||||
});
|
||||
|
||||
it("is not offered again after the picker was applied", async () => {
|
||||
await chain();
|
||||
await onboarding.offerPluginSetup(false);
|
||||
await onboarding.applyOnboardingChoices({});
|
||||
db.settings.delete(onboarding.PLUGIN_SETUP_OFFERED_KEY);
|
||||
await onboarding.applyOnboardingChoices({});
|
||||
await onboarding.offerPluginSetup(false);
|
||||
expect(await onboarding.isPluginSetupPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeOnboardingPlugins", () => {
|
||||
it("lists installed plugins with their defaults and the pending flag", async () => {
|
||||
writeIndex({
|
||||
base: { onboarding: { recommended: true } },
|
||||
extra: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
await chain();
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
|
||||
const list = await onboarding.describeOnboardingPlugins();
|
||||
expect(list.pending).toBe(true);
|
||||
expect(list.reason).toBe("fresh");
|
||||
expect(list.managedByLinkedServer).toBe(false);
|
||||
const byId = Object.fromEntries(list.plugins.map((p) => [p.id, p]));
|
||||
expect(byId.base).toMatchObject({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
source: "bundled",
|
||||
state: "enabled",
|
||||
});
|
||||
expect(byId.addon).toMatchObject({
|
||||
recommended: false,
|
||||
dependencies: ["base"],
|
||||
});
|
||||
expect(byId.extra).toMatchObject({ recommended: true, consent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOnboardingChoices", () => {
|
||||
it("enables, disables and removes, then clears the pending flag", async () => {
|
||||
const loader = await chain();
|
||||
db.rows.get("extra")!.state = "disabled";
|
||||
await loader.deactivate("extra");
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "disabled",
|
||||
addon: "remove",
|
||||
extra: "enabled",
|
||||
});
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.enabled).toEqual(["extra"]);
|
||||
expect(result.disabled).toEqual(["base"]);
|
||||
expect(result.removed).toEqual(["addon"]);
|
||||
expect(loader.get("addon")).toBeUndefined();
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
|
||||
"addon",
|
||||
]);
|
||||
expect(db.settings.has(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a dependency that a kept plugin still needs", async () => {
|
||||
const loader = await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "remove",
|
||||
addon: "disabled",
|
||||
});
|
||||
expect(result.adjustments).toEqual([
|
||||
{ id: "base", from: "remove", to: "disabled", requiredBy: ["addon"] },
|
||||
]);
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(loader.get("addon")?.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("removes dependents before what they depend on", async () => {
|
||||
const loader = await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "remove",
|
||||
addon: "remove",
|
||||
});
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.removed).toEqual(["addon", "base"]);
|
||||
expect(loader.get("base")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a dry run", async () => {
|
||||
const loader = await chain();
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
const result = await onboarding.applyOnboardingChoices(
|
||||
{ extra: "remove", base: "disabled", addon: "disabled" },
|
||||
{ dryRun: true },
|
||||
);
|
||||
expect(result.removed).toEqual(["extra"]);
|
||||
expect(result.disabled).toEqual(["addon", "base"]);
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1");
|
||||
});
|
||||
|
||||
it("ignores ids that are not installed", async () => {
|
||||
await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
ghost: "remove",
|
||||
});
|
||||
expect(result.resolved).toEqual({});
|
||||
expect(result.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for a plugin change already in progress", async () => {
|
||||
const loader = await chain();
|
||||
const first = manage.setPluginState("extra", false);
|
||||
const second = onboarding.applyOnboardingChoices({ extra: "enabled" });
|
||||
await first;
|
||||
const result = await second;
|
||||
expect(result.enabled).toEqual(["extra"]);
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
});
|
||||
});
|
||||
+69
-18
@@ -34,15 +34,25 @@ import { preloadPermissions } from "@/hooks/use-permissions";
|
||||
import { getUserPreferences } from "@/api/open-tabs-api";
|
||||
import { standaloneViewFor } from "@/shell/tab-registry";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
import {
|
||||
resolveStartupOnboarding,
|
||||
type StartupOnboarding,
|
||||
} from "@/onboarding/startup";
|
||||
|
||||
const AppShell = lazy(() =>
|
||||
import("@/AppShell").then((m) => ({ default: m.AppShell })),
|
||||
);
|
||||
const OnboardingStage = lazy(() =>
|
||||
import("@/onboarding/OnboardingStage").then((m) => ({
|
||||
default: m.OnboardingStage,
|
||||
})),
|
||||
);
|
||||
|
||||
type Phase =
|
||||
| "verifying"
|
||||
| "idle-auth"
|
||||
| "loading-app"
|
||||
| "onboarding"
|
||||
| "fading-in"
|
||||
| "idle-app"
|
||||
| "fading-out";
|
||||
@@ -132,6 +142,7 @@ function App() {
|
||||
);
|
||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
|
||||
const [startup, setStartup] = useState<StartupOnboarding | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Dedupes concurrent handleLogout() calls within the same tick -- see
|
||||
// handleLogout for why phase state alone isn't sufficient for this.
|
||||
@@ -178,8 +189,7 @@ function App() {
|
||||
preloadPermissions(),
|
||||
getUserPreferences().catch(() => undefined),
|
||||
]);
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
await enterApp();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Only treat a genuine auth rejection (401/403) as "not logged in".
|
||||
@@ -229,8 +239,26 @@ function App() {
|
||||
}, delay);
|
||||
});
|
||||
});
|
||||
// enterApp only touches state setters and refs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, verifyRetryCount]);
|
||||
|
||||
function fadeIntoApp() {
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
}
|
||||
|
||||
/** Opens on onboarding first when there is something to show. */
|
||||
async function enterApp() {
|
||||
const plan = await resolveStartupOnboarding().catch(() => null);
|
||||
setStartup(plan);
|
||||
if (plan?.mode) {
|
||||
setPhase("onboarding");
|
||||
return;
|
||||
}
|
||||
fadeIntoApp();
|
||||
}
|
||||
|
||||
function handleLogin(u: string) {
|
||||
loggingOutRef.current = false;
|
||||
clearDesktopManualLogout();
|
||||
@@ -244,8 +272,7 @@ function App() {
|
||||
preloadPermissions(),
|
||||
getUserPreferences().catch(() => undefined),
|
||||
]);
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
await enterApp();
|
||||
})();
|
||||
if (isElectron()) {
|
||||
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
||||
@@ -282,8 +309,17 @@ function App() {
|
||||
? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Lax"
|
||||
: "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax";
|
||||
}
|
||||
// The app never mounted, so there is nothing to fade out of.
|
||||
if (phase === "onboarding") {
|
||||
setStartup(null);
|
||||
setAuthUsername("");
|
||||
setPhase("idle-auth");
|
||||
loggingOutRef.current = false;
|
||||
return;
|
||||
}
|
||||
setPhase("fading-out");
|
||||
timerRef.current = setTimeout(() => {
|
||||
setStartup(null);
|
||||
setAuthUsername("");
|
||||
setPhase("idle-auth");
|
||||
loggingOutRef.current = false;
|
||||
@@ -323,20 +359,35 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApp && (
|
||||
<div
|
||||
className="fixed inset-0 z-10 transition-opacity duration-[450ms] ease-in-out"
|
||||
style={{
|
||||
opacity: appOpacity,
|
||||
pointerEvents: phase === "idle-app" ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<UiPreferencesProvider>
|
||||
<AppShell username={authUsername} onLogout={handleLogout} />
|
||||
</UiPreferencesProvider>
|
||||
</Suspense>
|
||||
</div>
|
||||
{(showApp || phase === "onboarding") && (
|
||||
// One provider from onboarding into the app, so nothing chosen in
|
||||
// onboarding is lost or fetched twice on the way.
|
||||
<UiPreferencesProvider initial={startup?.preferences}>
|
||||
{phase === "onboarding" && startup?.mode && (
|
||||
<Suspense fallback={null}>
|
||||
<OnboardingStage
|
||||
mode={startup.mode}
|
||||
initialPlugins={startup.plugins}
|
||||
shellReady={false}
|
||||
onDone={fadeIntoApp}
|
||||
onLogout={() => handleLogout({ manual: true })}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{showApp && (
|
||||
<div
|
||||
className="fixed inset-0 z-10 transition-opacity duration-[450ms] ease-in-out"
|
||||
style={{
|
||||
opacity: appOpacity,
|
||||
pointerEvents: phase === "idle-app" ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<AppShell username={authUsername} onLogout={handleLogout} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</UiPreferencesProvider>
|
||||
)}
|
||||
|
||||
{showAuth && (
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Onboarding step bookkeeping, shared by the frontend and the ui-preferences
|
||||
* route. Dependency-free for the backend's NodeNext build.
|
||||
*
|
||||
* Each step has a key and a version. A user's `seen` map records the highest
|
||||
* version of each step they have been shown, so bumping one step's version
|
||||
* shows that step again (and only that step) to everyone who saw it before.
|
||||
* Plugin steps are keyed `<pluginId>:<id>`.
|
||||
*/
|
||||
|
||||
export const CORE_ONBOARDING_STEPS = {
|
||||
plugins: 1,
|
||||
"desktop-sync": 1,
|
||||
preset: 1,
|
||||
appearance: 1,
|
||||
security: 1,
|
||||
} as const;
|
||||
|
||||
export type CoreOnboardingStepKey = keyof typeof CORE_ONBOARDING_STEPS;
|
||||
|
||||
/**
|
||||
* What a user who finished the old single-version onboarding counts as
|
||||
* having seen. Frozen on purpose: a later bump in CORE_ONBOARDING_STEPS must
|
||||
* still show the bumped step to these users.
|
||||
*/
|
||||
export const LEGACY_SEEN: Readonly<Record<string, number>> = Object.freeze({
|
||||
plugins: 1,
|
||||
"desktop-sync": 1,
|
||||
preset: 1,
|
||||
appearance: 1,
|
||||
security: 1,
|
||||
});
|
||||
|
||||
export const MAX_SEEN_ENTRIES = 500;
|
||||
const MAX_KEY_LENGTH = 128;
|
||||
|
||||
export function pluginStepKey(pluginId: string, id: string): string {
|
||||
return `${pluginId}:${id}`;
|
||||
}
|
||||
|
||||
export function isValidSeenEntry(key: string, version: unknown): boolean {
|
||||
return (
|
||||
typeof key === "string" &&
|
||||
key.length > 0 &&
|
||||
key.length <= MAX_KEY_LENGTH &&
|
||||
typeof version === "number" &&
|
||||
Number.isInteger(version) &&
|
||||
version >= 1 &&
|
||||
version <= 1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeSeen(input: unknown): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) return out;
|
||||
let count = 0;
|
||||
for (const [key, version] of Object.entries(input)) {
|
||||
if (count >= MAX_SEEN_ENTRIES) break;
|
||||
if (!isValidSeenEntry(key, version)) continue;
|
||||
out[key] = version as number;
|
||||
count++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Per-key max, so a stale tab never lowers what another tab recorded. */
|
||||
export function mergeSeen(
|
||||
a: Record<string, number> | undefined,
|
||||
b: Record<string, number> | undefined,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = { ...(a ?? {}) };
|
||||
for (const [key, version] of Object.entries(b ?? {})) {
|
||||
if ((out[key] ?? 0) < version) out[key] = version;
|
||||
}
|
||||
return sanitizeSeen(out);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The onboarding plugin picker: what the admin chose for each plugin, and the
|
||||
* dependency rules that keep the result loadable. Shared by the picker (to
|
||||
* show "kept because X needs it" live) and the backend apply route.
|
||||
*/
|
||||
|
||||
export type PluginChoice = "enabled" | "disabled" | "remove";
|
||||
|
||||
export const PLUGIN_CHOICES: readonly PluginChoice[] = [
|
||||
"enabled",
|
||||
"disabled",
|
||||
"remove",
|
||||
];
|
||||
|
||||
export interface ChoicePlugin {
|
||||
id: string;
|
||||
/** Hard dependencies (plugin ids). */
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
export interface ChoiceAdjustment {
|
||||
id: string;
|
||||
from: PluginChoice;
|
||||
to: PluginChoice;
|
||||
/** The plugins whose choice forced this one up. */
|
||||
requiredBy: string[];
|
||||
}
|
||||
|
||||
const RANK: Record<PluginChoice, number> = {
|
||||
remove: 0,
|
||||
disabled: 1,
|
||||
enabled: 2,
|
||||
};
|
||||
|
||||
export function isPluginChoice(value: unknown): value is PluginChoice {
|
||||
return PLUGIN_CHOICES.includes(value as PluginChoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise choices until every plugin's dependencies can serve it: an enabled
|
||||
* plugin needs its dependencies enabled, and a kept but disabled plugin
|
||||
* needs them at least kept. Ids missing from `choices` stay as they are and
|
||||
* are never adjusted.
|
||||
*/
|
||||
export function resolvePluginChoices(
|
||||
plugins: ChoicePlugin[],
|
||||
choices: Record<string, PluginChoice>,
|
||||
): {
|
||||
choices: Record<string, PluginChoice>;
|
||||
adjustments: ChoiceAdjustment[];
|
||||
} {
|
||||
const resolved: Record<string, PluginChoice> = { ...choices };
|
||||
const requiredBy = new Map<string, Set<string>>();
|
||||
const known = new Set(plugins.map((p) => p.id));
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const plugin of plugins) {
|
||||
const own = resolved[plugin.id];
|
||||
if (!own || own === "remove") continue;
|
||||
for (const dep of plugin.dependencies) {
|
||||
if (!known.has(dep) || !resolved[dep]) continue;
|
||||
if (RANK[resolved[dep]] >= RANK[own]) continue;
|
||||
resolved[dep] = own;
|
||||
if (!requiredBy.has(dep)) requiredBy.set(dep, new Set());
|
||||
requiredBy.get(dep)!.add(plugin.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const adjustments: ChoiceAdjustment[] = [];
|
||||
for (const [id, from] of Object.entries(choices)) {
|
||||
const to = resolved[id];
|
||||
if (to !== from) {
|
||||
adjustments.push({
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
requiredBy: [...(requiredBy.get(id) ?? [])].sort(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { choices: resolved, adjustments };
|
||||
}
|
||||
|
||||
/** Dependencies before the plugins that need them. */
|
||||
function topoOrder(plugins: ChoicePlugin[], ids: Set<string>): string[] {
|
||||
const byId = new Map(plugins.map((p) => [p.id, p]));
|
||||
const out: string[] = [];
|
||||
const visiting = new Set<string>();
|
||||
const done = new Set<string>();
|
||||
const visit = (id: string) => {
|
||||
if (done.has(id) || visiting.has(id)) return;
|
||||
visiting.add(id);
|
||||
for (const dep of byId.get(id)?.dependencies ?? []) {
|
||||
if (ids.has(dep)) visit(dep);
|
||||
}
|
||||
visiting.delete(id);
|
||||
done.add(id);
|
||||
out.push(id);
|
||||
};
|
||||
for (const id of [...ids].sort()) visit(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order to apply resolved choices in: enable dependencies first, and
|
||||
* disable or remove dependents first.
|
||||
*/
|
||||
export function applyOrder(
|
||||
plugins: ChoicePlugin[],
|
||||
resolved: Record<string, PluginChoice>,
|
||||
): { enable: string[]; disable: string[]; remove: string[] } {
|
||||
const pick = (choice: PluginChoice) =>
|
||||
new Set(
|
||||
Object.entries(resolved)
|
||||
.filter(([, c]) => c === choice)
|
||||
.map(([id]) => id),
|
||||
);
|
||||
return {
|
||||
enable: topoOrder(plugins, pick("enabled")),
|
||||
disable: topoOrder(plugins, pick("disabled")).reverse(),
|
||||
remove: topoOrder(plugins, pick("remove")).reverse(),
|
||||
};
|
||||
}
|
||||
+41
-20
@@ -1,3 +1,5 @@
|
||||
import { LEGACY_SEEN, sanitizeSeen } from "./onboarding.js";
|
||||
|
||||
/**
|
||||
* App-wide UI complexity preferences. Shared by the frontend UI preferences
|
||||
* context and the backend preferences endpoint (no framework imports, mirrors
|
||||
@@ -19,9 +21,6 @@
|
||||
/** 2: the docker and host metrics areas moved to their plugins. */
|
||||
export const UI_PREFERENCES_VERSION = 3;
|
||||
|
||||
/** Bump when onboarding gains steps existing users should be shown again. */
|
||||
export const UI_ONBOARDING_VERSION = 2;
|
||||
|
||||
export type UiPreset = "simple" | "balanced" | "advanced" | "custom";
|
||||
|
||||
export type UiAreaKey =
|
||||
@@ -94,10 +93,17 @@ export type UiOverrides = {
|
||||
} & { [key: UiPluginAreaKey]: Record<string, unknown> };
|
||||
|
||||
export interface UiOnboardingState {
|
||||
/** 0 means "never completed". Compared against UI_ONBOARDING_VERSION. */
|
||||
completedVersion: number;
|
||||
/** Step key to the highest version of that step the user was shown. */
|
||||
seen: Record<string, number>;
|
||||
/** When the first full run finished. Null means it never has. */
|
||||
completedAt: string | null;
|
||||
skipped: boolean;
|
||||
/**
|
||||
* Set for users carried over from the old single-version onboarding. The
|
||||
* client marks every plugin step that exists right now as seen without
|
||||
* showing it, then clears this.
|
||||
*/
|
||||
baselinePending: boolean;
|
||||
}
|
||||
|
||||
export interface UiPreferences {
|
||||
@@ -353,27 +359,42 @@ export function sanitizeUiOverrides(input: unknown): UiOverrides {
|
||||
return out as UiOverrides;
|
||||
}
|
||||
|
||||
function sanitizeOnboarding(input: unknown): UiOnboardingState {
|
||||
const defaults: UiOnboardingState = {
|
||||
completedVersion: 0,
|
||||
export function defaultOnboardingState(): UiOnboardingState {
|
||||
return {
|
||||
seen: {},
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeOnboarding(input: unknown): UiOnboardingState {
|
||||
const defaults = defaultOnboardingState();
|
||||
if (!input || typeof input !== "object") return defaults;
|
||||
const obj = input as Record<string, unknown>;
|
||||
const completedAt =
|
||||
typeof obj.completedAt === "string" ? obj.completedAt : null;
|
||||
const skipped = typeof obj.skipped === "boolean" ? obj.skipped : false;
|
||||
|
||||
// The old shape had a single completedVersion and no seen map.
|
||||
if (
|
||||
!("seen" in obj) &&
|
||||
typeof obj.completedVersion === "number" &&
|
||||
obj.completedVersion >= 1
|
||||
) {
|
||||
return {
|
||||
seen: { ...LEGACY_SEEN },
|
||||
completedAt,
|
||||
skipped,
|
||||
baselinePending: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
completedVersion:
|
||||
typeof obj.completedVersion === "number" &&
|
||||
Number.isFinite(obj.completedVersion) &&
|
||||
obj.completedVersion >= 0
|
||||
? Math.round(obj.completedVersion)
|
||||
: defaults.completedVersion,
|
||||
completedAt:
|
||||
typeof obj.completedAt === "string"
|
||||
? obj.completedAt
|
||||
: defaults.completedAt,
|
||||
skipped: typeof obj.skipped === "boolean" ? obj.skipped : defaults.skipped,
|
||||
seen: sanitizeSeen(obj.seen),
|
||||
completedAt,
|
||||
skipped,
|
||||
baselinePending: obj.baselinePending === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -382,7 +403,7 @@ export function defaultUiPreferences(): UiPreferences {
|
||||
version: UI_PREFERENCES_VERSION,
|
||||
preset: "balanced",
|
||||
overrides: {},
|
||||
onboarding: { completedVersion: 0, completedAt: null, skipped: false },
|
||||
onboarding: defaultOnboardingState(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-44
@@ -40,9 +40,7 @@ import {
|
||||
type ConfirmOptions,
|
||||
type EditingWidth,
|
||||
} from "@/components/surface/surface-scope";
|
||||
import { OnboardingDialog } from "@/onboarding/OnboardingDialog";
|
||||
import { UI_ONBOARDING_VERSION } from "@/types/ui-preferences";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { OnboardingHost } from "@/onboarding/OnboardingHost";
|
||||
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
|
||||
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
|
||||
import {
|
||||
@@ -312,42 +310,6 @@ export function AppShell({
|
||||
OpenTabRecord[]
|
||||
>([]);
|
||||
|
||||
// First-run onboarding. The backend hands accounts that predate this feature
|
||||
// an already-completed state, so only genuinely new users are interrupted.
|
||||
const uiPrefs = useUiPreferencesContext();
|
||||
const onboardingPending =
|
||||
!!uiPrefs?.loaded &&
|
||||
uiPrefs.preferences.onboarding.completedVersion < UI_ONBOARDING_VERSION;
|
||||
|
||||
/**
|
||||
* Both onboarding entry points wait for plugins first, since plugins add
|
||||
* steps of their own (the AI step, feature cards).
|
||||
*/
|
||||
const loadOnboardingContext = useCallback(async () => {
|
||||
const { settledPromise } = await import("@/plugin-host/plugin-store");
|
||||
await settledPromise();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username || !onboardingPending) return;
|
||||
let cancelled = false;
|
||||
loadOnboardingContext().finally(() => {
|
||||
if (!cancelled) setShowOnboarding(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [username, onboardingPending, loadOnboardingContext]);
|
||||
|
||||
// "Run setup again" from settings.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
loadOnboardingContext().finally(() => setShowOnboarding(true));
|
||||
};
|
||||
window.addEventListener("termix:open-onboarding", handler);
|
||||
return () => window.removeEventListener("termix:open-onboarding", handler);
|
||||
}, [loadOnboardingContext]);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
|
||||
@@ -3226,11 +3188,7 @@ export function AppShell({
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<OnboardingDialog
|
||||
open={showOnboarding}
|
||||
context={{}}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
/>
|
||||
<OnboardingHost onOpenChange={setShowOnboarding} />
|
||||
<DonationReminderModal
|
||||
open={showDonationModal && !showOnboarding}
|
||||
onDismiss={handleDismissDonationModal}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { rbacApi } from "@/main-axios";
|
||||
import type { ChoiceAdjustment, PluginChoice } from "@/types/plugin-onboarding";
|
||||
|
||||
export type { PluginChoice };
|
||||
|
||||
export interface PluginTabContribution {
|
||||
id: string;
|
||||
@@ -506,3 +509,52 @@ export async function installUploadedPlugin(
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export interface OnboardingPluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string | null;
|
||||
category: string;
|
||||
version: string;
|
||||
source: "bundled" | "user";
|
||||
state: string;
|
||||
dependencies: string[];
|
||||
recommended: boolean;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingPluginList {
|
||||
pending: boolean;
|
||||
/** On an upgrade the picker starts from what is on, not the defaults. */
|
||||
reason: "fresh" | "upgrade" | null;
|
||||
managedByLinkedServer: boolean;
|
||||
plugins: OnboardingPluginInfo[];
|
||||
}
|
||||
|
||||
export interface OnboardingApplyResult {
|
||||
resolved: Record<string, PluginChoice>;
|
||||
adjustments: ChoiceAdjustment[];
|
||||
enabled: string[];
|
||||
disabled: string[];
|
||||
removed: string[];
|
||||
failed: { id: string; error: string; code?: string }[];
|
||||
}
|
||||
|
||||
export async function getOnboardingPlugins(): Promise<OnboardingPluginList> {
|
||||
const response = await rbacApi.get("/plugins/onboarding");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function applyOnboardingPlugins(
|
||||
choices: Record<string, PluginChoice>,
|
||||
): Promise<OnboardingApplyResult> {
|
||||
try {
|
||||
const response = await rbacApi.post("/plugins/onboarding/apply", {
|
||||
choices,
|
||||
});
|
||||
return response.data;
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
+7
-22
@@ -1,4 +1,9 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
isInElectronWebView,
|
||||
isInMobileWebView,
|
||||
type EmbeddedFrameWindow,
|
||||
} from "@/lib/embedded-frame";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -183,26 +188,6 @@ interface AuthProps {
|
||||
onLogin: (username: string, userId?: string, isAdmin?: boolean) => void;
|
||||
}
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
IS_ELECTRON_WEBVIEW?: boolean;
|
||||
ReactNativeWebView?: { postMessage: (msg: string) => void };
|
||||
}
|
||||
|
||||
const isInMobileWebView = () =>
|
||||
/Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
|
||||
!!(window as ExtendedWindow).ReactNativeWebView;
|
||||
|
||||
const isInElectronWebView = () => {
|
||||
if (isInMobileWebView()) return false;
|
||||
if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) return true;
|
||||
try {
|
||||
if (window.self !== window.top) return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -616,7 +601,7 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
// (termix-mobile:-origin callbacks include one), otherwise read it back
|
||||
// from the cookie via /users/me/token before handing it to the app.
|
||||
const postToken = (token: string) => {
|
||||
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
|
||||
(window as EmbeddedFrameWindow).ReactNativeWebView?.postMessage(
|
||||
JSON.stringify({ type: "AUTH_SUCCESS", token }),
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
@@ -762,7 +747,7 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
if (isInMobileWebView()) {
|
||||
// Native-app requests get the JWT in the login response body.
|
||||
const token = res?.token ?? "";
|
||||
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
|
||||
(window as EmbeddedFrameWindow).ReactNativeWebView?.postMessage(
|
||||
JSON.stringify({ type: "AUTH_SUCCESS", token }),
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
resolvePluginArea,
|
||||
type UiPluginPresets,
|
||||
sanitizeUiPreferences,
|
||||
UI_ONBOARDING_VERSION,
|
||||
type UiAreaKey,
|
||||
type UiAreaPreferences,
|
||||
type UiOverrides,
|
||||
type UiPreferences,
|
||||
type UiPreset,
|
||||
} from "@/types/ui-preferences";
|
||||
import { mergeSeen } from "@/types/onboarding";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
const LS_KEY = "uiPreferences";
|
||||
@@ -62,7 +62,21 @@ interface UiPreferencesContextValue {
|
||||
) => void;
|
||||
clearArea: (area: UiAreaKey) => void;
|
||||
clearAllOverrides: () => void;
|
||||
completeOnboarding: (skipped: boolean) => void;
|
||||
/**
|
||||
* Records onboarding steps as seen (per-step max, never lowered).
|
||||
* `completed` stamps completedAt the first time a run finishes, and
|
||||
* `baselineDone` clears the carried-over-user flag.
|
||||
*/
|
||||
markOnboardingSeen: (
|
||||
seen: Record<string, number>,
|
||||
options?: {
|
||||
completed?: boolean;
|
||||
skipped?: boolean;
|
||||
baselineDone?: boolean;
|
||||
},
|
||||
) => void;
|
||||
/** Sends any queued change now instead of after the debounce. */
|
||||
flushNow: () => Promise<void>;
|
||||
/** A plugin's own area, from the presets it declared. */
|
||||
resolvePlugin: (
|
||||
pluginId: string,
|
||||
@@ -85,15 +99,26 @@ const UiPreferencesContext = createContext<UiPreferencesContextValue | null>(
|
||||
* deep and treats null as "clear this", so handing a knob back to the preset is
|
||||
* a single PUT rather than a read-modify-write of the whole document.
|
||||
*/
|
||||
export function UiPreferencesProvider({ children }: { children: ReactNode }) {
|
||||
export function UiPreferencesProvider({
|
||||
children,
|
||||
initial,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** Already fetched (by the onboarding gate), so no second request. */
|
||||
initial?: UiPreferences | null;
|
||||
}) {
|
||||
const [preferences, setPreferences] = useState<UiPreferences>(
|
||||
() => readCache() ?? defaultUiPreferences(),
|
||||
() => initial ?? readCache() ?? defaultUiPreferences(),
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loaded, setLoaded] = useState(!!initial);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingPatch = useRef<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (initial) {
|
||||
writeCache(initial);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
getUiPreferences()
|
||||
@@ -112,49 +137,74 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// initial only matters on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const flush = useCallback((): Promise<void> => {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
const body = pendingPatch.current;
|
||||
pendingPatch.current = {};
|
||||
if (Object.keys(body).length === 0) return Promise.resolve();
|
||||
return getUserPreferences()
|
||||
.then((prefs) => {
|
||||
if (prefs.storageMode === "cloud") return saveUiPreferences(body);
|
||||
// The server copy replaces the cache on load, so onboarding has to
|
||||
// reach it even in local mode or it reruns every time.
|
||||
if (body.onboarding)
|
||||
return saveUiPreferences({
|
||||
onboarding: body.onboarding as UiPreferences["onboarding"],
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort; cache already holds it */
|
||||
});
|
||||
}, []);
|
||||
|
||||
// A pending edit must not be lost when the provider goes away.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
void flush();
|
||||
};
|
||||
}, []);
|
||||
}, [flush]);
|
||||
|
||||
const queueSave = useCallback((patch: Record<string, unknown>) => {
|
||||
// Overrides accumulate key by key so a burst of edits still sends every
|
||||
// change; anything else is last-write-wins.
|
||||
const merged = { ...pendingPatch.current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (key === "overrides" && value && typeof value === "object") {
|
||||
merged.overrides = {
|
||||
...((merged.overrides as Record<string, unknown>) ?? {}),
|
||||
...(value as Record<string, unknown>),
|
||||
};
|
||||
} else {
|
||||
merged[key] = value;
|
||||
const queueSave = useCallback(
|
||||
(patch: Record<string, unknown>) => {
|
||||
// Overrides accumulate key by key so a burst of edits still sends every
|
||||
// change, and onboarding seen maps merge; anything else is
|
||||
// last-write-wins.
|
||||
const merged = { ...pendingPatch.current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (key === "overrides" && value && typeof value === "object") {
|
||||
merged.overrides = {
|
||||
...((merged.overrides as Record<string, unknown>) ?? {}),
|
||||
...(value as Record<string, unknown>),
|
||||
};
|
||||
} else if (key === "onboarding" && value && typeof value === "object") {
|
||||
const prev = (merged.onboarding ?? {}) as Record<string, unknown>;
|
||||
const next = value as Record<string, unknown>;
|
||||
merged.onboarding = {
|
||||
...prev,
|
||||
...next,
|
||||
seen: mergeSeen(
|
||||
prev.seen as Record<string, number>,
|
||||
next.seen as Record<string, number>,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingPatch.current = merged;
|
||||
pendingPatch.current = merged;
|
||||
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
const body = pendingPatch.current;
|
||||
pendingPatch.current = {};
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
if (prefs.storageMode === "cloud") return saveUiPreferences(body);
|
||||
// The server copy replaces the cache on load, so onboarding has to
|
||||
// reach it even in local mode or it reruns every time.
|
||||
if (body.onboarding)
|
||||
return saveUiPreferences({
|
||||
onboarding: body.onboarding as UiPreferences["onboarding"],
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort; cache already holds it */
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => void flush(), SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[flush],
|
||||
);
|
||||
|
||||
/** Applies the local mirror of a patch, then queues the same patch remotely. */
|
||||
const applyLocal = useCallback(
|
||||
@@ -227,14 +277,34 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
|
||||
applyLocal((prev) => ({ ...prev, overrides: {} }), { overrides: null });
|
||||
}, [applyLocal]);
|
||||
|
||||
const completeOnboarding = useCallback(
|
||||
(skipped: boolean) => {
|
||||
const onboarding = {
|
||||
completedVersion: UI_ONBOARDING_VERSION,
|
||||
completedAt: new Date().toISOString(),
|
||||
skipped,
|
||||
};
|
||||
applyLocal((prev) => ({ ...prev, onboarding }), { onboarding });
|
||||
const markOnboardingSeen = useCallback(
|
||||
(
|
||||
seen: Record<string, number>,
|
||||
options: {
|
||||
completed?: boolean;
|
||||
skipped?: boolean;
|
||||
baselineDone?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const patch: Record<string, unknown> = { seen };
|
||||
if (options.completed) patch.completedAt = new Date().toISOString();
|
||||
if (options.skipped !== undefined) patch.skipped = options.skipped;
|
||||
if (options.baselineDone) patch.baselinePending = false;
|
||||
applyLocal(
|
||||
(prev) => ({
|
||||
...prev,
|
||||
onboarding: {
|
||||
...prev.onboarding,
|
||||
...patch,
|
||||
completedAt:
|
||||
prev.onboarding.completedAt ??
|
||||
(patch.completedAt as string | undefined) ??
|
||||
null,
|
||||
seen: mergeSeen(prev.onboarding.seen, seen),
|
||||
},
|
||||
}),
|
||||
{ onboarding: patch },
|
||||
);
|
||||
},
|
||||
[applyLocal],
|
||||
);
|
||||
@@ -248,7 +318,8 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
|
||||
setOverride,
|
||||
clearArea,
|
||||
clearAllOverrides,
|
||||
completeOnboarding,
|
||||
markOnboardingSeen,
|
||||
flushNow: flush,
|
||||
resolvePlugin: (pluginId, presets) =>
|
||||
resolvePluginArea(preferences, pluginId, presets),
|
||||
setPluginOverride: (pluginId, key, value) =>
|
||||
@@ -265,7 +336,8 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
|
||||
setOverride,
|
||||
clearArea,
|
||||
clearAllOverrides,
|
||||
completeOnboarding,
|
||||
markOnboardingSeen,
|
||||
flush,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -56,6 +56,14 @@ export async function hasPermission(permission: string): Promise<boolean> {
|
||||
return matchesPermission(permissions, isAdmin, permission);
|
||||
}
|
||||
|
||||
/** A sync has() over the current grants, for code outside a component. */
|
||||
export async function loadPermissionChecker(): Promise<
|
||||
(permission: string) => boolean
|
||||
> {
|
||||
const { permissions, isAdmin } = await load();
|
||||
return (permission) => matchesPermission(permissions, isAdmin, permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warms the permissions cache before the shell mounts, so permission-gated
|
||||
* rail items are already known on first paint instead of popping in once
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The two places this web UI runs only to sign someone in for another app:
|
||||
* the Termix mobile app's WebView, and the iframe a desktop app shows a
|
||||
* server's login in while linking. Neither should ever show onboarding.
|
||||
*/
|
||||
|
||||
export interface EmbeddedFrameWindow extends Window {
|
||||
IS_ELECTRON_WEBVIEW?: boolean;
|
||||
ReactNativeWebView?: { postMessage: (msg: string) => void };
|
||||
}
|
||||
|
||||
export function isInMobileWebView(): boolean {
|
||||
return (
|
||||
/Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
|
||||
!!(window as EmbeddedFrameWindow).ReactNativeWebView
|
||||
);
|
||||
}
|
||||
|
||||
export function isInElectronWebView(): boolean {
|
||||
if (isInMobileWebView()) return false;
|
||||
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
|
||||
try {
|
||||
if (window.self !== window.top) return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isEmbeddedAuthFrame(): boolean {
|
||||
return isInMobileWebView() || isInElectronWebView();
|
||||
}
|
||||
+29
-52
@@ -212,63 +212,40 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Step {{current}} of {{total}}",
|
||||
"stageLabel": "Termix setup",
|
||||
"setupHeading": "Set up your workspace",
|
||||
"partialHeading": "A few new things to set up",
|
||||
"signOut": "Sign out",
|
||||
"skipPartial": "Not now",
|
||||
"skip": "Skip setup",
|
||||
"next": "Next",
|
||||
"finish": "Get started",
|
||||
"welcomeTitle": "Welcome to Termix",
|
||||
"welcomeIntro": "Termix manages your servers from one place. This setup takes about a minute and nothing here is permanent.",
|
||||
"welcome_hosts": "Save your servers",
|
||||
"welcome_hosts_desc": "Keep every machine you manage in one list.",
|
||||
"welcome_terminal": "Connect instantly",
|
||||
"welcome_terminal_desc": "Open a terminal in a tab, no separate SSH client.",
|
||||
"welcome_files": "Browse and edit files",
|
||||
"welcome_files_desc": "Upload, download and edit remote files.",
|
||||
"finish": "Done",
|
||||
"pluginsTitle": "Choose your features",
|
||||
"pluginsIntro": "Pick the features to keep. The recommended ones cover what most people need.",
|
||||
"pluginsUpgradeNote": "Everything you use now is kept as it is. Turn off or remove what you do not need.",
|
||||
"pluginsRemoveNote": "Plugins set to Remove are deleted from this server, with their data, when you continue. You can install any of them again at any time from the Plugins tab (this needs an internet connection). Pick Off to keep a plugin installed but turned off.",
|
||||
"pluginsUseRecommended": "Recommended",
|
||||
"pluginsEnableAll": "Turn all on",
|
||||
"pluginsDisableAll": "Turn all off",
|
||||
"pluginsCounts": "{{enabled}} on, {{disabled}} off, {{removed}} to remove",
|
||||
"pluginsRecommended": "Recommended",
|
||||
"pluginsKeptBecause": "Kept because {{names}} needs it",
|
||||
"pluginsChoice_enabled": "On",
|
||||
"pluginsChoice_disabled": "Off",
|
||||
"pluginsChoice_remove": "Remove",
|
||||
"pluginsConsentTitle": "Help improve Termix",
|
||||
"pluginsConsentNote": "You can turn this off at any time from the Plugins tab.",
|
||||
"pluginsApplying": "Applying your choices",
|
||||
"pluginsSomeFailed": "Some plugins could not be changed. You can fix them later from the Plugins tab. Press Next to continue.",
|
||||
"pluginsApplyFailed": "Your choices could not be saved. Try again, or continue and change them later from the Plugins tab.",
|
||||
"pluginsLoadFailed": "The plugin list could not be loaded. You can choose plugins later from the Plugins tab.",
|
||||
"presetTitle": "How much do you want on screen?",
|
||||
"presetIntro": "Termix can be as simple or as detailed as you like. Pick a starting point.",
|
||||
"presetIntro": "Choose how much shows on screen.",
|
||||
"presetChangeLater": "You can switch presets or change any single setting later in Settings, under Interface.",
|
||||
"appearanceTitle": "Make it yours",
|
||||
"appearanceIntro": "Pick a theme, an accent color and your language. All three can be changed later in Settings.",
|
||||
"featuresTitle": "What else Termix does",
|
||||
"featuresIntro": "Every server you save can be opened in more than a terminal. Here is what is waiting behind the icons on the left.",
|
||||
"featuresRailHint": "Do not need one of these? Hide any icon from the sidebar in Settings, under Navigation.",
|
||||
"workflowTitle": "Move around faster",
|
||||
"workflowIntro": "A few habits that save the most time once you have a couple of servers.",
|
||||
"workflow_palette": "Command palette",
|
||||
"workflow_palette_desc": "Jump to any server or action by typing. Tap Shift twice to open it.",
|
||||
"workflow_split": "Split screen",
|
||||
"workflow_split_desc": "Show several tabs side by side, for example to watch logs while you work.",
|
||||
"workflow_dock": "Side panel",
|
||||
"workflow_dock_desc": "Middle click or right click a sidebar icon to open it as a tab or pin it to the right.",
|
||||
"securityTitle": "Keeping your logins safe",
|
||||
"securityIntro": "Termix stores credentials for you, so it is worth a minute to see how that works.",
|
||||
"security_credentials": "Reusable credentials",
|
||||
"security_credentials_desc": "Save a key or password once and attach it to as many servers as you like.",
|
||||
"security_twofa": "Two factor and passkeys",
|
||||
"security_twofa_desc": "Add TOTP or a passkey to your account in Settings, under Security.",
|
||||
"security_identity": "Termix ID",
|
||||
"security_identity_desc": "Issue short lived SSH certificates instead of copying keys to every server.",
|
||||
"security_sharing": "Sharing",
|
||||
"security_sharing_desc": "Give other users access to a server without handing over the password.",
|
||||
"securityEncryptionNote": "Credentials are encrypted before they are stored, and only decrypted when a connection needs them.",
|
||||
"doneTitle": "You are all set",
|
||||
"doneHeading": "Setup complete",
|
||||
"doneDesc": "Pick a server from the sidebar to open a terminal, browse its files or see its metrics.",
|
||||
"done_settings": "Change anything later",
|
||||
"done_settings_desc": "Themes, layout density and which icons show up all live in Settings.",
|
||||
"done_rerun": "Want this again?",
|
||||
"done_rerun_desc": "Open Settings, go to Interface and choose Run setup to walk through these steps again.",
|
||||
"done_desktop": "Desktop app",
|
||||
"done_desktop_desc": "Termix runs as a desktop app on Windows, macOS and Linux.",
|
||||
"done_desktop_link": "Get the desktop app",
|
||||
"done_mobile": "Mobile app",
|
||||
"done_mobile_desc": "Take your servers with you on iOS and Android.",
|
||||
"done_mobile_link": "Get the mobile app",
|
||||
"done_docs": "Full documentation",
|
||||
"done_docs_desc": "Guides for every feature, including ones not covered here.",
|
||||
"done_docs_link": "Open the docs",
|
||||
"done_cli": "Command line tool",
|
||||
"done_cli_desc": "Manage your servers from a terminal, and use Termix in your own scripts.",
|
||||
"done_cli_link": "Open the CLI docs",
|
||||
"appearanceIntro": "Pick a theme, an accent color, a font and your language. Changes show right away, and you can change them later in Settings.",
|
||||
"securityTitle": "Secure your account",
|
||||
"securityIntro": "Add a passkey or an authenticator app to your account. You can also do this later in Settings, under Security.",
|
||||
"desktopTitle": "How do you want to use Termix?",
|
||||
"desktopBody": "You can change this any time from Sync in the sidebar.",
|
||||
"desktopLocalTitle": "On this device only",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
|
||||
import { settledPromise } from "@/plugin-host/plugin-store";
|
||||
import { subscribeToActionRegistry } from "@/shell/action-registry";
|
||||
import { onboardingSteps, type OnboardingMode } from "./onboarding-registry";
|
||||
import {
|
||||
baselineSeen,
|
||||
computeFlow,
|
||||
needsFullFlow,
|
||||
ONBOARDING_ADMIN_PERMISSION,
|
||||
} from "./onboarding-flow";
|
||||
import { canEnrollSecondFactor, currentStepDefs } from "./use-onboarding-steps";
|
||||
|
||||
const OnboardingStage = lazy(() =>
|
||||
import("./OnboardingStage").then((m) => ({ default: m.OnboardingStage })),
|
||||
);
|
||||
|
||||
/** Fired by "Run setup again" in settings. */
|
||||
export const OPEN_ONBOARDING_EVENT = "termix:open-onboarding";
|
||||
|
||||
const RECHECK_DELAY_MS = 750;
|
||||
|
||||
/**
|
||||
* Onboarding once the app is up: the rerun from settings, and a partial run
|
||||
* whenever steps appear that the user has not seen (a plugin installed here
|
||||
* or elsewhere, a step whose version went up). Both draw full screen over
|
||||
* the shell without unmounting it.
|
||||
*/
|
||||
export function OnboardingHost({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const prefs = useUiPreferencesContext();
|
||||
const { has, loaded } = usePermissions();
|
||||
const [mode, setMode] = useState<OnboardingMode | null>(null);
|
||||
const [settled, setSettled] = useState(false);
|
||||
const modeRef = useRef(mode);
|
||||
modeRef.current = mode;
|
||||
const embedded = isEmbeddedAuthFrame();
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChange?.(mode !== null);
|
||||
}, [mode, onOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void settledPromise().then(() => !cancelled && setSettled(true));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (embedded) return;
|
||||
const open = () => {
|
||||
void settledPromise().then(() => setMode("rerun"));
|
||||
};
|
||||
window.addEventListener(OPEN_ONBOARDING_EVENT, open);
|
||||
return () => window.removeEventListener(OPEN_ONBOARDING_EVENT, open);
|
||||
}, [embedded]);
|
||||
|
||||
const onboarding = prefs?.preferences.onboarding;
|
||||
const ready = !embedded && settled && loaded && !!prefs?.loaded;
|
||||
|
||||
// Carried over from the old onboarding: plugin steps that already exist
|
||||
// count as seen, without showing any of them.
|
||||
useEffect(() => {
|
||||
if (!ready || !onboarding?.baselinePending) return;
|
||||
prefs?.markOnboardingSeen(baselineSeen(currentStepDefs(has)), {
|
||||
baselineDone: true,
|
||||
});
|
||||
}, [ready, onboarding?.baselinePending, prefs, has]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !onboarding || onboarding.baselinePending) return;
|
||||
if (needsFullFlow(onboarding)) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const check = () => {
|
||||
if (modeRef.current !== null) return;
|
||||
if (document.visibilityState === "hidden") return;
|
||||
const isAdmin = has(ONBOARDING_ADMIN_PERMISSION);
|
||||
const pending = computeFlow(currentStepDefs(has), onboarding.seen, {
|
||||
mode: "partial",
|
||||
isAdmin,
|
||||
isDesktop: isElectron(),
|
||||
has,
|
||||
pluginSetupPending: false,
|
||||
pluginsManagedElsewhere: false,
|
||||
canEnrollSecondFactor: canEnrollSecondFactor(),
|
||||
});
|
||||
if (pending.length > 0) setMode("partial");
|
||||
};
|
||||
const schedule = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(check, RECHECK_DELAY_MS);
|
||||
};
|
||||
schedule();
|
||||
const offSteps = onboardingSteps.subscribe(schedule);
|
||||
const offSlots = subscribeToActionRegistry(schedule);
|
||||
document.addEventListener("visibilitychange", schedule);
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
offSteps();
|
||||
offSlots();
|
||||
document.removeEventListener("visibilitychange", schedule);
|
||||
};
|
||||
}, [ready, onboarding, has]);
|
||||
|
||||
if (!mode || embedded) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<OnboardingStage
|
||||
key={mode}
|
||||
mode={mode}
|
||||
shellReady
|
||||
onDone={() => setMode(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowRight, Loader2, LogOut } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
getOnboardingPlugins,
|
||||
type OnboardingPluginList,
|
||||
} from "@/api/plugins-api";
|
||||
import {
|
||||
sortSteps,
|
||||
type OnboardingCtx,
|
||||
type OnboardingMode,
|
||||
type OnboardingStepDef,
|
||||
} from "./onboarding-registry";
|
||||
import {
|
||||
computeFlow,
|
||||
ONBOARDING_ADMIN_PERMISSION,
|
||||
seenFor,
|
||||
} from "./onboarding-flow";
|
||||
import { OnboardingStageContext } from "./onboarding-stage-context";
|
||||
import {
|
||||
useCanEnrollSecondFactor,
|
||||
useOnboardingStepDefs,
|
||||
} from "./use-onboarding-steps";
|
||||
|
||||
type BeforeNext = () => boolean | Promise<boolean>;
|
||||
|
||||
export interface OnboardingStageProps {
|
||||
mode: OnboardingMode;
|
||||
/** The picker's plugin list, when the boot gate already fetched it. */
|
||||
initialPlugins?: OnboardingPluginList | null;
|
||||
/** False on a first run, which shows before the app shell exists. */
|
||||
shellReady: boolean;
|
||||
onDone: () => void;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding as a page of its own, laid out like the settings pages: one
|
||||
* centred column with the step in a card and Back / Skip / Next under it.
|
||||
* The same stage runs a new user's first run, a partial run of steps
|
||||
* someone has not seen yet, and a rerun from settings.
|
||||
*/
|
||||
export function OnboardingStage({
|
||||
mode,
|
||||
initialPlugins = null,
|
||||
shellReady,
|
||||
onDone,
|
||||
onLogout,
|
||||
}: OnboardingStageProps) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useUiPreferencesContext();
|
||||
const { has, loaded: permissionsLoaded } = usePermissions();
|
||||
const isAdmin = has(ONBOARDING_ADMIN_PERMISSION);
|
||||
const isDesktop = isElectron();
|
||||
const canEnroll = useCanEnrollSecondFactor();
|
||||
|
||||
const [plugins, setPlugins] = useState<OnboardingPluginList | null>(
|
||||
initialPlugins,
|
||||
);
|
||||
|
||||
// A rerun or a partial run opened inside the app has no list yet, and
|
||||
// whether the picker applies depends on it.
|
||||
useEffect(() => {
|
||||
if (plugins || !isAdmin) return;
|
||||
let cancelled = false;
|
||||
getOnboardingPlugins()
|
||||
.then((list) => !cancelled && setPlugins(list))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [plugins, isAdmin]);
|
||||
|
||||
const allSteps = useOnboardingStepDefs();
|
||||
|
||||
const ctx = useMemo<OnboardingCtx>(
|
||||
() => ({
|
||||
mode,
|
||||
isAdmin,
|
||||
isDesktop,
|
||||
has,
|
||||
pluginSetupPending: !!plugins?.pending,
|
||||
pluginsManagedElsewhere: !!plugins?.managedByLinkedServer,
|
||||
canEnrollSecondFactor: canEnroll,
|
||||
}),
|
||||
[mode, isAdmin, isDesktop, has, plugins, canEnroll],
|
||||
);
|
||||
|
||||
// What was seen when the run started. Marking steps as seen happens at the
|
||||
// end, but the run itself must not shift under the user if it changes.
|
||||
const seenAtStart = useRef(prefs?.preferences.onboarding.seen ?? {});
|
||||
// Someone carried over from an older version gets their plugins' steps
|
||||
// marked seen by the shell, not shown, so only core steps run for them.
|
||||
const coreOnly = useRef(
|
||||
mode === "partial" && !!prefs?.preferences.onboarding.baselinePending,
|
||||
);
|
||||
const sticky = useRef(new Set<string>());
|
||||
|
||||
const flow = useMemo(() => {
|
||||
const candidates = coreOnly.current
|
||||
? allSteps.filter((step) => !step.pluginId)
|
||||
: allSteps;
|
||||
const live = computeFlow(candidates, seenAtStart.current, ctx);
|
||||
for (const step of live) sticky.current.add(step.id);
|
||||
// A step that was in the run stays in it (the picker stops being
|
||||
// pending once applied), unless its plugin went away.
|
||||
const kept = allSteps.filter(
|
||||
(step) =>
|
||||
sticky.current.has(step.id) && !live.some((s) => s.id === step.id),
|
||||
);
|
||||
return sortSteps([...live, ...kept]);
|
||||
}, [allSteps, ctx]);
|
||||
|
||||
const [currentId, setCurrentId] = useState<string | null>(null);
|
||||
const lastPosition = useRef(0);
|
||||
const [confirmed, setConfirmed] = useState<Set<string>>(() => new Set());
|
||||
const [canContinue, setCanContinueMap] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const beforeNext = useRef<Record<string, BeforeNext | null>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const finishing = useRef(false);
|
||||
|
||||
// Track the current step by key: a step disappearing (its plugin was
|
||||
// removed) moves on to whatever now sits in its place.
|
||||
let index = flow.findIndex((step) => step.id === currentId);
|
||||
if (index < 0) {
|
||||
index = flow.findIndex((step) => step.position >= lastPosition.current);
|
||||
if (index < 0) index = flow.length - 1;
|
||||
}
|
||||
const step: OnboardingStepDef | undefined = flow[index];
|
||||
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
lastPosition.current = step.position;
|
||||
if (step.id !== currentId) setCurrentId(step.id);
|
||||
}, [step, currentId]);
|
||||
|
||||
const finish = useCallback(
|
||||
async (skipped: boolean) => {
|
||||
if (finishing.current) return;
|
||||
finishing.current = true;
|
||||
prefs?.markOnboardingSeen(seenFor(flow), {
|
||||
completed: true,
|
||||
skipped,
|
||||
});
|
||||
await prefs?.flushNow().catch(() => {});
|
||||
onDone();
|
||||
},
|
||||
[prefs, flow, onDone],
|
||||
);
|
||||
|
||||
// Nothing to show (a partial run whose step vanished).
|
||||
useEffect(() => {
|
||||
if (permissionsLoaded && flow.length === 0) void finish(false);
|
||||
}, [permissionsLoaded, flow.length, finish]);
|
||||
|
||||
const canSkip = !flow.some((s) => s.required && !confirmed.has(s.id));
|
||||
const isLast = index === flow.length - 1;
|
||||
const stepId = step?.id ?? "";
|
||||
const stepCanContinue = canContinue[stepId] !== false;
|
||||
|
||||
const goNext = useCallback(async () => {
|
||||
if (!step || busy) return;
|
||||
const hook = beforeNext.current[step.id];
|
||||
if (hook) {
|
||||
setBusy(true);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await hook();
|
||||
} catch {
|
||||
ok = false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
if (!ok) return;
|
||||
}
|
||||
if (step.required) {
|
||||
setConfirmed((prev) => new Set(prev).add(step.id));
|
||||
}
|
||||
if (isLast) {
|
||||
void finish(false);
|
||||
return;
|
||||
}
|
||||
setCurrentId(flow[index + 1].id);
|
||||
}, [step, busy, isLast, finish, flow, index]);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
if (canSkip) void finish(true);
|
||||
}, [canSkip, finish]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || mode === "full") return;
|
||||
if (event.defaultPrevented) return;
|
||||
skip();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [mode, skip]);
|
||||
|
||||
const setCanContinue = useCallback(
|
||||
(ok: boolean) =>
|
||||
setCanContinueMap((prev) =>
|
||||
prev[stepId] === ok ? prev : { ...prev, [stepId]: ok },
|
||||
),
|
||||
[stepId],
|
||||
);
|
||||
const setBeforeNext = useCallback(
|
||||
(fn: BeforeNext | null) => {
|
||||
beforeNext.current[stepId] = fn;
|
||||
},
|
||||
[stepId],
|
||||
);
|
||||
|
||||
const stageValue = useMemo(() => ({ plugins, setPlugins }), [plugins]);
|
||||
|
||||
if (!permissionsLoaded || !step) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StepComponent = step.Component;
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<OnboardingStageContext.Provider value={stageValue}>
|
||||
<div
|
||||
className="fixed inset-0 z-[60] overflow-y-auto bg-background text-foreground"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("onboarding.stageLabel")}
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col gap-4 px-4 py-8 sm:py-14">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{mode === "partial"
|
||||
? t("onboarding.partialHeading")
|
||||
: t("onboarding.setupHeading")}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("onboarding.stepCounter", {
|
||||
current: index + 1,
|
||||
total: flow.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{onLogout && mode === "full" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] text-muted-foreground"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut size={12} />
|
||||
{t("onboarding.signOut")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{flow.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`h-0.5 flex-1 ${
|
||||
i <= index ? "bg-accent-brand" : "bg-muted-foreground/20"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SurfaceScope
|
||||
kind="tab"
|
||||
className="relative flex flex-col border border-border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
{Icon && <Icon size={14} className="text-muted-foreground" />}
|
||||
<span className="flex-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t(step.titleKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 px-3 py-4 md:px-4">
|
||||
{step.descriptionKey && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(step.descriptionKey)}
|
||||
</p>
|
||||
)}
|
||||
<StepComponent
|
||||
key={step.id}
|
||||
mode={mode}
|
||||
isAdmin={isAdmin}
|
||||
isDesktop={isDesktop}
|
||||
shellReady={shellReady}
|
||||
setCanContinue={setCanContinue}
|
||||
setBeforeNext={setBeforeNext}
|
||||
/>
|
||||
</div>
|
||||
</SurfaceScope>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
{canSkip && !isLast && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={skip}
|
||||
>
|
||||
{mode === "partial"
|
||||
? t("onboarding.skipPartial")
|
||||
: t("onboarding.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => setCurrentId(flow[index - 1].id)}
|
||||
>
|
||||
<ArrowLeft size={13} />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-accent-brand/40 text-xs text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={busy || !stepCanContinue}
|
||||
onClick={() => void goNext()}
|
||||
>
|
||||
{busy && <Loader2 size={13} className="animate-spin" />}
|
||||
{isLast ? t("onboarding.finish") : t("onboarding.next")}
|
||||
{!isLast && !busy && <ArrowRight size={13} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingStageContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { UiOnboardingState } from "@/types/ui-preferences";
|
||||
import type { OnboardingCtx, OnboardingStepDef } from "./onboarding-registry";
|
||||
|
||||
/** The permission that makes someone an admin as far as onboarding goes. */
|
||||
export const ONBOARDING_ADMIN_PERMISSION = "admin.plugins.manage";
|
||||
|
||||
export function isStepRelevant(
|
||||
step: OnboardingStepDef,
|
||||
ctx: OnboardingCtx,
|
||||
): boolean {
|
||||
if (step.audience === "admin" && !ctx.isAdmin) return false;
|
||||
if (step.permission && !ctx.has(step.permission)) return false;
|
||||
if (step.isRelevant) {
|
||||
try {
|
||||
if (!step.isRelevant(ctx)) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isStepPending(
|
||||
step: OnboardingStepDef,
|
||||
seen: Record<string, number>,
|
||||
ctx: OnboardingCtx,
|
||||
): boolean {
|
||||
if ((seen[step.id] ?? 0) < step.version) return true;
|
||||
try {
|
||||
return step.forcePending?.(ctx) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The steps a run shows, in order. A full run or a rerun shows every step
|
||||
* that applies to the user; a partial run only the ones they have not seen
|
||||
* at their current version.
|
||||
*/
|
||||
export function computeFlow(
|
||||
steps: OnboardingStepDef[],
|
||||
seen: Record<string, number>,
|
||||
ctx: OnboardingCtx,
|
||||
): OnboardingStepDef[] {
|
||||
const relevant = steps.filter((step) => isStepRelevant(step, ctx));
|
||||
if (ctx.mode !== "partial") return relevant;
|
||||
return relevant.filter((step) => isStepPending(step, seen, ctx));
|
||||
}
|
||||
|
||||
/** A user who has never finished or skipped a run gets the full one. */
|
||||
export function needsFullFlow(state: UiOnboardingState): boolean {
|
||||
return Object.keys(state.seen).length === 0 && !state.completedAt;
|
||||
}
|
||||
|
||||
/** What to record once a run ends, finished or skipped. */
|
||||
export function seenFor(steps: OnboardingStepDef[]): Record<string, number> {
|
||||
return Object.fromEntries(steps.map((step) => [step.id, step.version]));
|
||||
}
|
||||
|
||||
/**
|
||||
* For a user carried over from the old onboarding: every plugin step that
|
||||
* exists right now counts as seen, so the upgrade does not show them a pile
|
||||
* of steps for plugins they have been using all along.
|
||||
*/
|
||||
export function baselineSeen(
|
||||
steps: OnboardingStepDef[],
|
||||
): Record<string, number> {
|
||||
return seenFor(steps.filter((step) => step.pluginId));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type {
|
||||
OnboardingMode,
|
||||
OnboardingSection,
|
||||
OnboardingStepProps,
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
import type { SlotContribution } from "@/shell/action-registry";
|
||||
import { pluginStepKey } from "@/types/onboarding";
|
||||
|
||||
export type { OnboardingMode, OnboardingSection, OnboardingStepProps };
|
||||
|
||||
export interface OnboardingCtx {
|
||||
mode: OnboardingMode;
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
has: (permission: string) => boolean;
|
||||
/** True on a fresh install until an admin confirms the plugin picker. */
|
||||
pluginSetupPending: boolean;
|
||||
/** A linked desktop runs what its server runs. */
|
||||
pluginsManagedElsewhere: boolean;
|
||||
/** Whether a sign in plugin offers a passkey or authenticator to set up. */
|
||||
canEnrollSecondFactor?: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStepDef {
|
||||
/** The key stored in the seen map: a core id, or `<pluginId>:<id>`. */
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
version: number;
|
||||
titleKey: string;
|
||||
descriptionKey?: string;
|
||||
icon?: LucideIcon | ComponentType<{ size?: number; className?: string }>;
|
||||
Component: ComponentType<OnboardingStepProps>;
|
||||
audience: "all" | "admin";
|
||||
permission?: string;
|
||||
/** Position in the flow; plugin steps get theirs from their section. */
|
||||
position: number;
|
||||
isRelevant?: (ctx: OnboardingCtx) => boolean;
|
||||
/** Shown in a partial run even when already seen. */
|
||||
forcePending?: (ctx: OnboardingCtx) => boolean;
|
||||
/** Skip stays hidden until this step has been confirmed. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Where each section of plugin steps sits among the core steps. */
|
||||
export const SECTION_POSITION: Record<OnboardingSection, number> = {
|
||||
setup: 50,
|
||||
explore: 80,
|
||||
security: 95,
|
||||
};
|
||||
|
||||
export const onboardingSteps = createRegistry<OnboardingStepDef>();
|
||||
|
||||
/** Plugin steps sort inside their section by order, then key. */
|
||||
export function sectionPosition(
|
||||
section: OnboardingSection | undefined,
|
||||
order = 0,
|
||||
): number {
|
||||
const base = SECTION_POSITION[section ?? "explore"];
|
||||
return base + Math.max(-1, Math.min(1, order / 1000)) * 0.4;
|
||||
}
|
||||
|
||||
export function sortSteps(steps: OnboardingStepDef[]): OnboardingStepDef[] {
|
||||
return [...steps].sort(
|
||||
(a, b) => a.position - b.position || a.id.localeCompare(b.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Steps still on the deprecated onboarding.steps slot, read as version 1. */
|
||||
export function legacyStepDefs(
|
||||
contributions: SlotContribution[],
|
||||
): OnboardingStepDef[] {
|
||||
return contributions
|
||||
.filter((c) => c.component && c.pluginId)
|
||||
.map((c) => ({
|
||||
id: pluginStepKey(c.pluginId!, c.actionId),
|
||||
pluginId: c.pluginId,
|
||||
version: 1,
|
||||
titleKey: c.titleKey,
|
||||
descriptionKey: c.descriptionKey,
|
||||
icon: c.icon as OnboardingStepDef["icon"],
|
||||
Component: c.component as unknown as ComponentType<OnboardingStepProps>,
|
||||
audience: "all" as const,
|
||||
position: sectionPosition("explore", c.order),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Core, registered and legacy steps in flow order, registered winning ties. */
|
||||
export function mergeStepDefs(
|
||||
core: OnboardingStepDef[],
|
||||
registered: OnboardingStepDef[],
|
||||
legacy: OnboardingStepDef[],
|
||||
): OnboardingStepDef[] {
|
||||
const keys = new Set([...core, ...registered].map((step) => step.id));
|
||||
return sortSteps([
|
||||
...core,
|
||||
...registered,
|
||||
...legacy.filter((step) => !keys.has(step.id)),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { OnboardingPluginList } from "@/api/plugins-api";
|
||||
|
||||
export interface OnboardingStageValue {
|
||||
/** Fetched by the boot gate for admins; null means fetch on demand. */
|
||||
plugins: OnboardingPluginList | null;
|
||||
setPlugins: (list: OnboardingPluginList) => void;
|
||||
}
|
||||
|
||||
export const OnboardingStageContext =
|
||||
createContext<OnboardingStageValue | null>(null);
|
||||
|
||||
export function useOnboardingStage(): OnboardingStageValue | null {
|
||||
return useContext(OnboardingStageContext);
|
||||
}
|
||||
@@ -1,72 +1,69 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { WelcomeStep } from "./steps/WelcomeStep";
|
||||
import { LayoutTemplate, Monitor, Palette, Puzzle, Shield } from "lucide-react";
|
||||
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
|
||||
import { PluginsStep } from "./steps/PluginsStep";
|
||||
import { PresetStep } from "./steps/PresetStep";
|
||||
import { AppearanceStep } from "./steps/AppearanceStep";
|
||||
import { FeaturesStep } from "./steps/FeaturesStep";
|
||||
import { WorkflowStep } from "./steps/WorkflowStep";
|
||||
import { SecurityStep } from "./steps/SecurityStep";
|
||||
import { DoneStep } from "./steps/DoneStep";
|
||||
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface OnboardingContext {}
|
||||
|
||||
export interface OnboardingStepProps {
|
||||
context: OnboardingContext;
|
||||
}
|
||||
|
||||
export interface OnboardingStep {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
Component: ComponentType<OnboardingStepProps>;
|
||||
/** Steps that do not apply to this account are skipped entirely. */
|
||||
isRelevant?: (context: OnboardingContext) => boolean;
|
||||
}
|
||||
import type { OnboardingStepDef } from "./onboarding-registry";
|
||||
|
||||
/**
|
||||
* Onboarding as data rather than hardcoded JSX, so steps can be added,
|
||||
* reordered or made conditional without touching the dialog shell. Plugins
|
||||
* add steps through the "onboarding.steps" slot; they land before security.
|
||||
* Core onboarding as data: only choices, no tour. Plugin steps slot in
|
||||
* between these by section (see SECTION_POSITION). To show a changed step
|
||||
* again to people who saw it, bump its version in CORE_ONBOARDING_STEPS;
|
||||
* only that step reruns.
|
||||
*/
|
||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
export const CORE_STEPS: OnboardingStepDef[] = [
|
||||
{
|
||||
id: "welcome",
|
||||
titleKey: "onboarding.welcomeTitle",
|
||||
Component: WelcomeStep,
|
||||
id: "plugins",
|
||||
version: CORE_ONBOARDING_STEPS.plugins,
|
||||
titleKey: "onboarding.pluginsTitle",
|
||||
icon: Puzzle,
|
||||
Component: PluginsStep,
|
||||
audience: "admin",
|
||||
position: 10,
|
||||
required: true,
|
||||
// Once applied there is nothing to redo here; the Plugins tab is where
|
||||
// plugins change after that, so a rerun leaves it out.
|
||||
isRelevant: (ctx) => !ctx.pluginsManagedElsewhere && ctx.pluginSetupPending,
|
||||
forcePending: (ctx) => ctx.pluginSetupPending,
|
||||
},
|
||||
{ id: "preset", titleKey: "onboarding.presetTitle", Component: PresetStep },
|
||||
{
|
||||
id: "desktop-sync",
|
||||
version: CORE_ONBOARDING_STEPS["desktop-sync"],
|
||||
titleKey: "onboarding.desktopTitle",
|
||||
icon: Monitor,
|
||||
Component: DesktopSyncStep,
|
||||
isRelevant: () => isElectron(),
|
||||
audience: "all",
|
||||
position: 20,
|
||||
isRelevant: (ctx) => ctx.isDesktop,
|
||||
},
|
||||
{
|
||||
id: "preset",
|
||||
version: CORE_ONBOARDING_STEPS.preset,
|
||||
titleKey: "onboarding.presetTitle",
|
||||
icon: LayoutTemplate,
|
||||
Component: PresetStep,
|
||||
audience: "all",
|
||||
position: 30,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
version: CORE_ONBOARDING_STEPS.appearance,
|
||||
titleKey: "onboarding.appearanceTitle",
|
||||
icon: Palette,
|
||||
Component: AppearanceStep,
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
titleKey: "onboarding.featuresTitle",
|
||||
Component: FeaturesStep,
|
||||
},
|
||||
{
|
||||
id: "workflow",
|
||||
titleKey: "onboarding.workflowTitle",
|
||||
Component: WorkflowStep,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
version: CORE_ONBOARDING_STEPS.security,
|
||||
titleKey: "onboarding.securityTitle",
|
||||
icon: Shield,
|
||||
Component: SecurityStep,
|
||||
audience: "all",
|
||||
position: 90,
|
||||
isRelevant: (ctx) => ctx.canEnrollSecondFactor === true,
|
||||
},
|
||||
{ id: "done", titleKey: "onboarding.doneTitle", Component: DoneStep },
|
||||
];
|
||||
|
||||
export function relevantSteps(context: OnboardingContext): OnboardingStep[] {
|
||||
return ONBOARDING_STEPS.filter(
|
||||
(step) => !step.isRelevant || step.isRelevant(context),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
OnboardingPluginInfo,
|
||||
OnboardingPluginList,
|
||||
PluginChoice,
|
||||
} from "@/api/plugins-api";
|
||||
|
||||
export function currentChoice(plugin: OnboardingPluginInfo): PluginChoice {
|
||||
return plugin.state === "enabled" ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
/**
|
||||
* What the picker starts on. On a fresh install recommended plugins are on
|
||||
* and the rest are set to be removed; consent ones start on. Later runs
|
||||
* start from how things are now.
|
||||
*/
|
||||
export function defaultChoices(
|
||||
list: OnboardingPluginList,
|
||||
): Record<string, PluginChoice> {
|
||||
return Object.fromEntries(
|
||||
list.plugins.map((plugin) => {
|
||||
if (
|
||||
!list.pending ||
|
||||
list.reason !== "fresh" ||
|
||||
plugin.source !== "bundled"
|
||||
) {
|
||||
return [plugin.id, currentChoice(plugin)];
|
||||
}
|
||||
if (plugin.consent) return [plugin.id, "enabled"];
|
||||
return [plugin.id, plugin.recommended ? "enabled" : "remove"];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function recommendedChoices(
|
||||
list: OnboardingPluginList,
|
||||
): Record<string, PluginChoice> {
|
||||
return Object.fromEntries(
|
||||
list.plugins.map((plugin) => [
|
||||
plugin.id,
|
||||
plugin.recommended || plugin.consent ? "enabled" : "remove",
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getUiPreferences } from "@/api/ui-preferences-api";
|
||||
import {
|
||||
getOnboardingPlugins,
|
||||
type OnboardingPluginList,
|
||||
} from "@/api/plugins-api";
|
||||
import { loadPermissionChecker } from "@/hooks/use-permissions";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
|
||||
import type { UiPreferences } from "@/types/ui-preferences";
|
||||
import {
|
||||
computeFlow,
|
||||
needsFullFlow,
|
||||
ONBOARDING_ADMIN_PERMISSION,
|
||||
} from "./onboarding-flow";
|
||||
import type { OnboardingCtx, OnboardingMode } from "./onboarding-registry";
|
||||
import { canEnrollSecondFactor, currentStepDefs } from "./use-onboarding-steps";
|
||||
|
||||
export interface StartupOnboarding {
|
||||
preferences: UiPreferences | null;
|
||||
/** Null when there is nothing to show. */
|
||||
mode: Exclude<OnboardingMode, "rerun"> | null;
|
||||
plugins: OnboardingPluginList | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides, after sign in and once plugins have loaded, whether the app opens
|
||||
* on onboarding: the full run for a new user, or just the steps someone has
|
||||
* not seen yet. Never inside the mobile app or a desktop's link window,
|
||||
* which only borrow this page to sign in.
|
||||
*/
|
||||
export async function resolveStartupOnboarding(): Promise<StartupOnboarding> {
|
||||
const none: StartupOnboarding = {
|
||||
preferences: null,
|
||||
mode: null,
|
||||
plugins: null,
|
||||
};
|
||||
if (isEmbeddedAuthFrame()) return none;
|
||||
if (new URLSearchParams(window.location.search).has("view")) return none;
|
||||
|
||||
let preferences: UiPreferences;
|
||||
try {
|
||||
preferences = await getUiPreferences();
|
||||
} catch {
|
||||
return none;
|
||||
}
|
||||
|
||||
const has = await loadPermissionChecker();
|
||||
const isAdmin = has(ONBOARDING_ADMIN_PERMISSION);
|
||||
const plugins = isAdmin
|
||||
? await getOnboardingPlugins().catch(() => null)
|
||||
: null;
|
||||
|
||||
const result: StartupOnboarding = { preferences, mode: null, plugins };
|
||||
const { onboarding } = preferences;
|
||||
|
||||
if (needsFullFlow(onboarding)) return { ...result, mode: "full" };
|
||||
|
||||
const ctx: OnboardingCtx = {
|
||||
mode: "partial",
|
||||
isAdmin,
|
||||
isDesktop: isElectron(),
|
||||
has,
|
||||
pluginSetupPending: !!plugins?.pending,
|
||||
pluginsManagedElsewhere: !!plugins?.managedByLinkedServer,
|
||||
canEnrollSecondFactor: canEnrollSecondFactor(),
|
||||
};
|
||||
const steps = currentStepDefs(has);
|
||||
// Someone carried over from the old onboarding has their plugin steps
|
||||
// marked seen by the shell first, so only core steps count for them here.
|
||||
const candidates = onboarding.baselinePending
|
||||
? steps.filter((step) => !step.pluginId)
|
||||
: steps;
|
||||
const pending = computeFlow(candidates, onboarding.seen, ctx);
|
||||
return { ...result, mode: pending.length > 0 ? "partial" : null };
|
||||
}
|
||||
@@ -1,101 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { LanguageSwitcher } from "@/user/LanguageSwitcher";
|
||||
import { ACCENT_PRESET_COLORS, applyAccentColor } from "@/lib/theme";
|
||||
import type { ThemeId } from "@/types/ui-types";
|
||||
import {
|
||||
AccentPicker,
|
||||
FontSizePicker,
|
||||
LanguageSelect,
|
||||
ThemeGrid,
|
||||
UiFontSelect,
|
||||
useAppearanceSettings,
|
||||
} from "@/settings/appearance/appearance-controls";
|
||||
|
||||
const THEME_CHOICES: { id: ThemeId; swatch: string; labelKey: string }[] = [
|
||||
{
|
||||
id: "system",
|
||||
swatch: "linear-gradient(135deg,#ffffff 50%,#1a1c22 50%)",
|
||||
labelKey: "newUi.sidebar.userProfile.themeSystem",
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
swatch: "#ffffff",
|
||||
labelKey: "newUi.sidebar.userProfile.themeLight",
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
swatch: "#1a1c22",
|
||||
labelKey: "newUi.sidebar.userProfile.themeDark",
|
||||
},
|
||||
];
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint && (
|
||||
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every change applies right away, so the page itself is the preview. */
|
||||
export function AppearanceStep() {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [accent, setAccent] = useState(
|
||||
() =>
|
||||
localStorage.getItem("termix-accent") ?? ACCENT_PRESET_COLORS[0].value,
|
||||
);
|
||||
const appearance = useAppearanceSettings();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.appearanceIntro")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.themeLabel")}
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{THEME_CHOICES.map((choice) => (
|
||||
<button
|
||||
key={choice.id}
|
||||
type="button"
|
||||
onClick={() => setTheme(choice.id)}
|
||||
className={`flex items-center gap-2 border p-2 text-xs transition-colors ${
|
||||
theme === choice.id
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="size-4 shrink-0 border border-border"
|
||||
style={{ background: choice.swatch }}
|
||||
/>
|
||||
{t(choice.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label={t("newUi.sidebar.userProfile.themeLabel")}>
|
||||
<ThemeGrid value={appearance.theme} onChange={appearance.setTheme} />
|
||||
</Field>
|
||||
|
||||
<Field label={t("newUi.sidebar.userProfile.accentColorLabel")}>
|
||||
<AccentPicker
|
||||
value={appearance.accent}
|
||||
onChange={appearance.setAccent}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("newUi.sidebar.userProfile.interfaceFontLabel")}
|
||||
hint={t("newUi.sidebar.userProfile.interfaceFontDescription")}
|
||||
>
|
||||
<UiFontSelect
|
||||
value={appearance.uiFont}
|
||||
onChange={appearance.setUiFont}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("newUi.sidebar.userProfile.fontSizeLabel")}>
|
||||
<FontSizePicker
|
||||
value={appearance.fontSize}
|
||||
onChange={appearance.setFontSize}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.accentColorLabel")}
|
||||
</span>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{ACCENT_PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
title={color.label}
|
||||
onClick={() => {
|
||||
setAccent(color.value);
|
||||
localStorage.setItem("termix-accent", color.value);
|
||||
applyAccentColor(color.value);
|
||||
}}
|
||||
className="flex h-7 items-center justify-center border border-border"
|
||||
style={{ background: color.value }}
|
||||
>
|
||||
{accent === color.value && (
|
||||
<Check size={12} className="text-white drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.languageLabel")}
|
||||
</span>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Field label={t("newUi.sidebar.userProfile.languageLabel")}>
|
||||
<LanguageSelect
|
||||
value={appearance.language}
|
||||
onChange={appearance.setLanguage}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||
import type { OnboardingStepProps } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button";
|
||||
import { PluginIconBox } from "@/plugins/plugin-bits";
|
||||
import {
|
||||
applyOnboardingPlugins,
|
||||
getOnboardingPlugins,
|
||||
type OnboardingPluginInfo,
|
||||
type OnboardingPluginList,
|
||||
type PluginChoice,
|
||||
} from "@/api/plugins-api";
|
||||
import { syncPlugins } from "@/plugin-host/loader";
|
||||
import { resolvePluginChoices } from "@/types/plugin-onboarding";
|
||||
import { useOnboardingStage } from "../onboarding-stage-context";
|
||||
import {
|
||||
currentChoice,
|
||||
defaultChoices,
|
||||
recommendedChoices,
|
||||
} from "../plugin-choices";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"Terminal",
|
||||
"Files & Transfer",
|
||||
"Infrastructure",
|
||||
"Monitoring",
|
||||
"Networking",
|
||||
"Access & Security",
|
||||
"Productivity",
|
||||
];
|
||||
|
||||
const CHOICES: PluginChoice[] = ["enabled", "disabled", "remove"];
|
||||
const RANK: Record<PluginChoice, number> = {
|
||||
remove: 0,
|
||||
disabled: 1,
|
||||
enabled: 2,
|
||||
};
|
||||
|
||||
export function PluginsStep({
|
||||
setCanContinue,
|
||||
setBeforeNext,
|
||||
}: OnboardingStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const stage = useOnboardingStage();
|
||||
const [list, setList] = useState<OnboardingPluginList | null>(
|
||||
stage?.plugins ?? null,
|
||||
);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [choices, setChoices] = useState<Record<string, PluginChoice>>(() =>
|
||||
stage?.plugins ? defaultChoices(stage.plugins) : {},
|
||||
);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [failed, setFailed] = useState<{ id: string; error: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (list) return;
|
||||
let cancelled = false;
|
||||
getOnboardingPlugins()
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setList(next);
|
||||
setChoices(defaultChoices(next));
|
||||
stage?.setPlugins(next);
|
||||
})
|
||||
.catch(() => !cancelled && setLoadError(true));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [list, stage]);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
if (!list) return null;
|
||||
return resolvePluginChoices(
|
||||
list.plugins.map((p) => ({ id: p.id, dependencies: p.dependencies })),
|
||||
choices,
|
||||
);
|
||||
}, [list, choices]);
|
||||
|
||||
const names = useMemo(
|
||||
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []),
|
||||
[list],
|
||||
);
|
||||
|
||||
const changed = useMemo(() => {
|
||||
if (!list || !resolved) return false;
|
||||
if (list.pending) return true;
|
||||
return list.plugins.some(
|
||||
(plugin) => resolved.choices[plugin.id] !== currentChoice(plugin),
|
||||
);
|
||||
}, [list, resolved]);
|
||||
|
||||
const apply = useCallback(async (): Promise<boolean> => {
|
||||
if (!list || !resolved) return !!loadError;
|
||||
if (!changed) return true;
|
||||
setApplying(true);
|
||||
setFailed([]);
|
||||
try {
|
||||
const result = await applyOnboardingPlugins(resolved.choices);
|
||||
await syncPlugins().catch(() => {});
|
||||
// Start again from how things are now, so going back and on again
|
||||
// does not apply twice, and a failure only stops Next once.
|
||||
const fresh = await getOnboardingPlugins().catch(() => null);
|
||||
const next = fresh ?? { ...list, pending: false };
|
||||
setList(next);
|
||||
setChoices(defaultChoices(next));
|
||||
stage?.setPlugins(next);
|
||||
if (result.failed.length > 0) {
|
||||
setFailed(result.failed);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
setFailed([{ id: "", error: t("onboarding.pluginsApplyFailed") }]);
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, [list, resolved, changed, loadError, stage, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setBeforeNext(apply);
|
||||
return () => setBeforeNext(null);
|
||||
}, [apply, setBeforeNext]);
|
||||
|
||||
useEffect(() => {
|
||||
setCanContinue(!!list || loadError);
|
||||
}, [list, loadError, setCanContinue]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.pluginsLoadFailed")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!list || !resolved) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const regular = list.plugins.filter((p) => !p.consent);
|
||||
const consent = list.plugins.filter((p) => p.consent);
|
||||
const groups = new Map<string, OnboardingPluginInfo[]>();
|
||||
for (const plugin of regular) {
|
||||
const group = groups.get(plugin.category) ?? [];
|
||||
group.push(plugin);
|
||||
groups.set(plugin.category, group);
|
||||
}
|
||||
const orderedGroups = [...groups.entries()].sort(
|
||||
([a], [b]) =>
|
||||
(CATEGORY_ORDER.indexOf(a) + 1 || 99) -
|
||||
(CATEGORY_ORDER.indexOf(b) + 1 || 99),
|
||||
);
|
||||
|
||||
const counts = { enabled: 0, disabled: 0, remove: 0 };
|
||||
for (const plugin of list.plugins) counts[resolved.choices[plugin.id]]++;
|
||||
const forcedBy = new Map(
|
||||
resolved.adjustments.map((a) => [
|
||||
a.id,
|
||||
a.requiredBy.map((id) => names.get(id) ?? id).join(", "),
|
||||
]),
|
||||
);
|
||||
|
||||
const setChoice = (id: string, choice: PluginChoice) =>
|
||||
setChoices((prev) => ({ ...prev, [id]: choice }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.pluginsIntro")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-start gap-2 border border-border bg-muted/30 p-2.5 text-[11px] leading-snug text-muted-foreground">
|
||||
<Info size={13} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
<span>
|
||||
{list.reason === "upgrade" && (
|
||||
<>{t("onboarding.pluginsUpgradeNote")} </>
|
||||
)}
|
||||
{t("onboarding.pluginsRemoveNote")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={() => setChoices(recommendedChoices(list))}
|
||||
>
|
||||
{t("onboarding.pluginsUseRecommended")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={() =>
|
||||
setChoices(
|
||||
Object.fromEntries(list.plugins.map((p) => [p.id, "enabled"])),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("onboarding.pluginsEnableAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={() =>
|
||||
setChoices(
|
||||
Object.fromEntries(
|
||||
list.plugins.map((p) => [
|
||||
p.id,
|
||||
p.consent ? choices[p.id] : "disabled",
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("onboarding.pluginsDisableAll")}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("onboarding.pluginsCounts", {
|
||||
enabled: counts.enabled,
|
||||
disabled: counts.disabled,
|
||||
removed: counts.remove,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{orderedGroups.map(([category, plugins]) => (
|
||||
<div key={category} className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{category}
|
||||
</span>
|
||||
{plugins.map((plugin) => {
|
||||
const choice = resolved.choices[plugin.id];
|
||||
const floor = forcedBy.has(plugin.id) ? RANK[choice] : 0;
|
||||
return (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="flex flex-col gap-2 border border-border bg-card p-2.5 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2.5">
|
||||
<PluginIconBox
|
||||
name={plugin.icon ?? undefined}
|
||||
size="sm"
|
||||
muted={choice === "remove"}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
{plugin.name}
|
||||
{plugin.recommended && (
|
||||
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
|
||||
{t("onboarding.pluginsRecommended")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{plugin.description}
|
||||
</span>
|
||||
{forcedBy.has(plugin.id) && (
|
||||
<span className="text-[10px] text-accent-brand">
|
||||
{t("onboarding.pluginsKeptBecause", {
|
||||
names: forcedBy.get(plugin.id),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={plugin.name}
|
||||
className="flex shrink-0 border border-border"
|
||||
>
|
||||
{CHOICES.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={choice === option}
|
||||
disabled={RANK[option] < floor}
|
||||
onClick={() => setChoice(plugin.id, option)}
|
||||
className={`px-2.5 py-1 text-[11px] transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
choice === option
|
||||
? option === "remove"
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-accent-brand/15 text-accent-brand"
|
||||
: "text-muted-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{t(`onboarding.pluginsChoice_${option}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{consent.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("onboarding.pluginsConsentTitle")}
|
||||
</span>
|
||||
{consent.map((plugin) => {
|
||||
const on = resolved.choices[plugin.id] === "enabled";
|
||||
return (
|
||||
<label
|
||||
key={plugin.id}
|
||||
className="flex cursor-pointer items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-[var(--accent-brand)]"
|
||||
checked={on}
|
||||
disabled={forcedBy.has(plugin.id)}
|
||||
onChange={(e) =>
|
||||
setChoice(
|
||||
plugin.id,
|
||||
e.target.checked ? "enabled" : "disabled",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{plugin.name}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{plugin.description}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
{t("onboarding.pluginsConsentNote")}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applying && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t("onboarding.pluginsApplying")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failed.length > 0 && (
|
||||
<div className="flex flex-col gap-1 border border-destructive/40 bg-destructive/10 p-2.5 text-[11px]">
|
||||
<span className="flex items-center gap-1.5 font-medium text-destructive">
|
||||
<AlertTriangle size={13} />
|
||||
{t("onboarding.pluginsSomeFailed")}
|
||||
</span>
|
||||
{failed.map((f) => (
|
||||
<span key={`${f.id}:${f.error}`} className="text-muted-foreground">
|
||||
{f.id ? `${names.get(f.id) ?? f.id}: ${f.error}` : f.error}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Fingerprint, KeyRound, ShieldCheck, Users } from "lucide-react";
|
||||
import { AuthEnrollmentSections } from "@/sidebar/AuthEnrollmentSections";
|
||||
|
||||
const ITEMS = [
|
||||
{ icon: KeyRound, key: "credentials" },
|
||||
{ icon: ShieldCheck, key: "twofa" },
|
||||
{ icon: Fingerprint, key: "identity" },
|
||||
{ icon: Users, key: "sharing" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Credential reuse and 2FA are the two things worth knowing before someone
|
||||
* types a password into a host form, so they get their own step rather than a
|
||||
* line buried in the feature list.
|
||||
*/
|
||||
/** A passkey or authenticator, set up right here when a plugin offers one. */
|
||||
export function SecurityStep() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.securityIntro")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{ITEMS.map(({ icon: Icon, key }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t(`onboarding.security_${key}`)}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t(`onboarding.security_${key}_desc`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{t("onboarding.securityEncryptionNote")}
|
||||
</p>
|
||||
<AuthEnrollmentSections />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import {
|
||||
getActionPermission,
|
||||
getSlotContributions,
|
||||
} from "@/shell/action-registry";
|
||||
import {
|
||||
legacyStepDefs,
|
||||
mergeStepDefs,
|
||||
onboardingSteps,
|
||||
type OnboardingStepDef,
|
||||
} from "./onboarding-registry";
|
||||
import { CORE_STEPS } from "./onboarding-steps";
|
||||
import {
|
||||
listLoginMethodUIs,
|
||||
listSecondFactorUIs,
|
||||
useLoginMethods,
|
||||
useSecondFactors,
|
||||
} from "@/plugin-host/auth-registry";
|
||||
|
||||
/** Every onboarding step that exists right now, in flow order. */
|
||||
export function useOnboardingStepDefs(): OnboardingStepDef[] {
|
||||
const registered = onboardingSteps.useList();
|
||||
const legacy = useActionSlot("onboarding.steps");
|
||||
return useMemo(
|
||||
() => mergeStepDefs(CORE_STEPS, registered, legacyStepDefs(legacy)),
|
||||
[registered, legacy],
|
||||
);
|
||||
}
|
||||
|
||||
/** The same list outside React, for the boot gate and the baseline pass. */
|
||||
export function currentStepDefs(
|
||||
has: (permission: string) => boolean,
|
||||
): OnboardingStepDef[] {
|
||||
const legacy = getSlotContributions("onboarding.steps").filter((c) => {
|
||||
const permission = getActionPermission(c.actionId);
|
||||
return !permission || has(permission);
|
||||
});
|
||||
return mergeStepDefs(
|
||||
CORE_STEPS,
|
||||
onboardingSteps.list(),
|
||||
legacyStepDefs(legacy),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a sign in plugin offers something to set up for the account. */
|
||||
export function canEnrollSecondFactor(): boolean {
|
||||
return [...listLoginMethodUIs(), ...listSecondFactorUIs()].some(
|
||||
(entry) => entry.enrollment,
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanEnrollSecondFactor(): boolean {
|
||||
const methods = useLoginMethods();
|
||||
const factors = useSecondFactors();
|
||||
return [...methods, ...factors].some((entry) => entry.enrollment);
|
||||
}
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
} from "@/shell/action-registry";
|
||||
import { pluginApiFor, pluginFetch, pluginWsUrl } from "@/lib/plugin-transport";
|
||||
import { registerPluginComponent } from "./component-registry";
|
||||
import {
|
||||
onboardingSteps,
|
||||
sectionPosition,
|
||||
} from "@/onboarding/onboarding-registry";
|
||||
import { pluginStepKey } from "@/types/onboarding";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
@@ -70,6 +75,8 @@ import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { hasPermission } from "@/hooks/use-permissions";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const ONBOARDING_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export interface PluginAppHandle {
|
||||
app: TermixApp;
|
||||
/** Runs every disposer, newest first, isolating failures. */
|
||||
@@ -122,6 +129,12 @@ export function createPluginApp(
|
||||
fn: F | undefined,
|
||||
): F | undefined => scopeHostCallback(fn, pluginId);
|
||||
|
||||
const requireStepId = (id: string, what: string) => {
|
||||
if (typeof id !== "string" || !ONBOARDING_ID.test(id)) {
|
||||
throw new Error(`${pluginId}: ${what} id "${id}" is not valid`);
|
||||
}
|
||||
};
|
||||
|
||||
const requireDeclared = (kind: ViewKind, id: string, what: string) => {
|
||||
if (!manifestDeclares(contributes, kind, id)) {
|
||||
const field =
|
||||
@@ -463,6 +476,45 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
registerOnboardingStep(step) {
|
||||
requireStepId(step.id, "onboarding step");
|
||||
const version = step.version ?? 1;
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
throw new Error(
|
||||
`${pluginId}: onboarding step "${step.id}" needs a whole version of 1 or more`,
|
||||
);
|
||||
}
|
||||
const when = guardCallback(pluginId, step.when, false);
|
||||
return track(
|
||||
onboardingSteps.register({
|
||||
id: pluginStepKey(pluginId, step.id),
|
||||
pluginId,
|
||||
version,
|
||||
titleKey: key(step.titleKey),
|
||||
descriptionKey: step.descriptionKey
|
||||
? key(step.descriptionKey)
|
||||
: undefined,
|
||||
icon: step.icon
|
||||
? (withIconBoundary(pluginId, step.icon as never) as never)
|
||||
: undefined,
|
||||
Component: scoped(step.component),
|
||||
audience: step.audience ?? "all",
|
||||
permission: step.permission
|
||||
? resolvePluginPermission(pluginId, step.permission)
|
||||
: undefined,
|
||||
position: sectionPosition(step.section, step.order),
|
||||
isRelevant: when
|
||||
? (ctx) =>
|
||||
when({
|
||||
isAdmin: ctx.isAdmin,
|
||||
isDesktop: ctx.isDesktop,
|
||||
mode: ctx.mode,
|
||||
}) === true
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
invokeAction: (id, ...args) => invokeAction(id, ...args),
|
||||
|
||||
registerComponent(id, component) {
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
|
||||
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
|
||||
import { getExtension, listExtensions } from "./extension-registry";
|
||||
import { onboardingSteps } from "@/onboarding/onboarding-registry";
|
||||
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||
|
||||
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
||||
@@ -201,6 +202,8 @@ export async function renderPlugin(
|
||||
|
||||
const mine = <T extends { pluginId?: string }>(items: T[]) =>
|
||||
items.filter((item) => item.pluginId === pluginId);
|
||||
const unprefixed = (items: { id: string; pluginId?: string }[]) =>
|
||||
mine(items).map((item) => item.id.slice(pluginId.length + 1));
|
||||
|
||||
const tabRecord = (type: string, host?: Host): Tab => ({
|
||||
id: `${type}-test`,
|
||||
@@ -257,6 +260,7 @@ export async function renderPlugin(
|
||||
mine(listKeybindingActions()).map((action) => action.id),
|
||||
keybindingDefaults: () =>
|
||||
mine(listKeybindingDefaults()).map((binding) => binding.id),
|
||||
onboardingSteps: () => unprefixed(onboardingSteps.list()),
|
||||
},
|
||||
|
||||
async loadPaletteGroup(id) {
|
||||
@@ -332,6 +336,35 @@ export async function renderPlugin(
|
||||
return wrap(<Component {...props} />);
|
||||
},
|
||||
|
||||
renderOnboardingStep(id, props = {}) {
|
||||
const step =
|
||||
onboardingSteps.get(`${pluginId}:${id}`) ??
|
||||
missing("an onboarding step", id);
|
||||
const Component = step.Component;
|
||||
let canContinue = true;
|
||||
let beforeNext: (() => boolean | Promise<boolean>) | null = null;
|
||||
const element = wrap(
|
||||
<Component
|
||||
mode="full"
|
||||
isAdmin
|
||||
isDesktop={false}
|
||||
shellReady={false}
|
||||
setCanContinue={(ok) => {
|
||||
canContinue = ok;
|
||||
}}
|
||||
setBeforeNext={(fn) => {
|
||||
beforeNext = fn;
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
element,
|
||||
canContinue: () => canContinue,
|
||||
next: async () => (beforeNext ? await beforeNext() : true),
|
||||
};
|
||||
},
|
||||
|
||||
renderDashboardCard(id) {
|
||||
const card = getRegisteredDashboardCard(id) ?? missing("a card", id);
|
||||
const Component = card.component;
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Input } from "@/components/input";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
ACCENT_PRESET_COLORS,
|
||||
applyAccentColor,
|
||||
applyFontSize,
|
||||
applyUiFont,
|
||||
FONT_SIZES,
|
||||
UI_FONTS,
|
||||
} from "@/lib/theme";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
||||
|
||||
export const DEFAULT_ACCENT = "#f59145";
|
||||
|
||||
export const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
{ id: "system", preview: "auto" },
|
||||
{ id: "light", preview: "#ffffff" },
|
||||
{ id: "dark", preview: "#1a1c22" },
|
||||
{ id: "dracula", preview: "#282a36" },
|
||||
{ id: "catppuccin", preview: "#1e1e2e" },
|
||||
{ id: "nord", preview: "#2e3440" },
|
||||
{ id: "solarized", preview: "#002b36" },
|
||||
{ id: "tokyo-night", preview: "#1a1b26" },
|
||||
{ id: "one-dark", preview: "#282c34" },
|
||||
{ id: "gruvbox", preview: "#282828" },
|
||||
];
|
||||
|
||||
/** Background, panel and text colours for the little theme previews. */
|
||||
const THEME_PALETTES: Record<
|
||||
Exclude<ThemeId, "system">,
|
||||
{ bg: string; panel: string; text: string }
|
||||
> = {
|
||||
light: { bg: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
|
||||
dark: { bg: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
|
||||
dracula: { bg: "#282a36", panel: "#44475a", text: "#f8f8f2" },
|
||||
catppuccin: { bg: "#1e1e2e", panel: "#313244", text: "#cdd6f4" },
|
||||
nord: { bg: "#2e3440", panel: "#3b4252", text: "#eceff4" },
|
||||
solarized: { bg: "#002b36", panel: "#073642", text: "#93a1a1" },
|
||||
"tokyo-night": { bg: "#1a1b26", panel: "#24283b", text: "#c0caf5" },
|
||||
"one-dark": { bg: "#282c34", panel: "#21252b", text: "#abb2bf" },
|
||||
gruvbox: { bg: "#282828", panel: "#3c3836", text: "#ebdbb2" },
|
||||
};
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "af", label: "Afrikaans" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
{ code: "bn", label: "বাংলা" },
|
||||
{ code: "bg", label: "Български" },
|
||||
{ code: "ca", label: "Català" },
|
||||
{ code: "zh-CN", label: "中文 (简体)" },
|
||||
{ code: "zh-TW", label: "中文 (繁體)" },
|
||||
{ code: "cs", label: "Čeština" },
|
||||
{ code: "da", label: "Dansk" },
|
||||
{ code: "nl", label: "Nederlands" },
|
||||
{ code: "fi", label: "Suomi" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "el", label: "Ελληνικά" },
|
||||
{ code: "he", label: "עברית" },
|
||||
{ code: "hi", label: "हिन्दी" },
|
||||
{ code: "hu", label: "Magyar" },
|
||||
{ code: "id", label: "Indonesia" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "ko", label: "한국어" },
|
||||
{ code: "no", label: "Norsk" },
|
||||
{ code: "pl", label: "Polski" },
|
||||
{ code: "pt-PT", label: "Português (PT)" },
|
||||
{ code: "pt-BR", label: "Português (BR)" },
|
||||
{ code: "ro", label: "Română" },
|
||||
{ code: "ru", label: "Русский" },
|
||||
{ code: "sr", label: "Српски" },
|
||||
{ code: "es-ES", label: "Español" },
|
||||
{ code: "sv-SE", label: "Svenska" },
|
||||
{ code: "th", label: "ไทย" },
|
||||
{ code: "tr", label: "Türkçe" },
|
||||
{ code: "uk", label: "Українська" },
|
||||
{ code: "vi", label: "Tiếng Việt" },
|
||||
];
|
||||
|
||||
export const THEME_LABEL_KEYS: Record<ThemeId, string> = {
|
||||
system: "newUi.sidebar.userProfile.themeSystem",
|
||||
light: "newUi.sidebar.userProfile.themeLight",
|
||||
dark: "newUi.sidebar.userProfile.themeDark",
|
||||
dracula: "newUi.sidebar.userProfile.themeDracula",
|
||||
catppuccin: "newUi.sidebar.userProfile.themeCatppuccin",
|
||||
nord: "newUi.sidebar.userProfile.themeNord",
|
||||
solarized: "newUi.sidebar.userProfile.themeSolarized",
|
||||
"tokyo-night": "newUi.sidebar.userProfile.themeTokyoNight",
|
||||
"one-dark": "newUi.sidebar.userProfile.themeOneDark",
|
||||
gruvbox: "newUi.sidebar.userProfile.themeGruvbox",
|
||||
};
|
||||
|
||||
export function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{3}$/.test(value);
|
||||
}
|
||||
|
||||
type CloudPrefs = Parameters<typeof saveUserPreferences>[0];
|
||||
|
||||
/** Saves to the account only for users who sync settings to the server. */
|
||||
async function saveIfCloud(prefs: CloudPrefs): Promise<void> {
|
||||
try {
|
||||
const current = await getUserPreferences();
|
||||
if (current?.storageMode === "cloud") await saveUserPreferences(prefs);
|
||||
} catch {
|
||||
// The local copy already holds the change.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme, accent, font and language, applied right away and stored the same
|
||||
* way the profile settings store them.
|
||||
*/
|
||||
export function useAppearanceSettings() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [accent, setAccentState] = useState(
|
||||
() => localStorage.getItem("termix-accent") ?? DEFAULT_ACCENT,
|
||||
);
|
||||
const [fontSize, setFontSizeState] = useState<FontSizeId>(
|
||||
() => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md",
|
||||
);
|
||||
const [uiFont, setUiFontState] = useState<UiFontId>(
|
||||
() =>
|
||||
(localStorage.getItem("termix-ui-font") as UiFontId) ?? "jetbrains-mono",
|
||||
);
|
||||
const [language, setLanguageState] = useState(() =>
|
||||
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||
);
|
||||
|
||||
return {
|
||||
theme,
|
||||
accent,
|
||||
fontSize,
|
||||
uiFont,
|
||||
language,
|
||||
setTheme(id: ThemeId) {
|
||||
setTheme(id);
|
||||
void saveIfCloud({ theme: id });
|
||||
},
|
||||
setAccent(value: string) {
|
||||
setAccentState(value);
|
||||
localStorage.setItem("termix-accent", value);
|
||||
applyAccentColor(value);
|
||||
void saveIfCloud({ accentColor: value });
|
||||
},
|
||||
setFontSize(id: FontSizeId) {
|
||||
setFontSizeState(id);
|
||||
applyFontSize(id);
|
||||
void saveIfCloud({ fontSize: id });
|
||||
},
|
||||
setUiFont(id: UiFontId) {
|
||||
setUiFontState(id);
|
||||
applyUiFont(id);
|
||||
},
|
||||
setLanguage(code: string) {
|
||||
void changeAppLanguage(code)
|
||||
.then((next) => {
|
||||
setLanguageState(next);
|
||||
void saveIfCloud({ language: next });
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ThemePreview({ id }: { id: ThemeId }) {
|
||||
if (id === "system") {
|
||||
return (
|
||||
<div className="flex h-14 w-full">
|
||||
<div className="flex-1">
|
||||
<MiniWindow {...THEME_PALETTES.light} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<MiniWindow {...THEME_PALETTES.dark} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="h-14 w-full">
|
||||
<MiniWindow {...THEME_PALETTES[id]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniWindow({
|
||||
bg,
|
||||
panel,
|
||||
text,
|
||||
}: {
|
||||
bg: string;
|
||||
panel: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full" style={{ background: bg }}>
|
||||
<div
|
||||
className="flex w-1/4 flex-col gap-1 p-1"
|
||||
style={{ background: panel }}
|
||||
>
|
||||
<div className="h-1 w-3/4" style={{ background: text, opacity: 0.6 }} />
|
||||
<div
|
||||
className="h-1 w-1/2"
|
||||
style={{ background: text, opacity: 0.35 }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 w-2/3"
|
||||
style={{ background: text, opacity: 0.35 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 p-1.5">
|
||||
<div className="h-1 w-1/2 bg-accent-brand" />
|
||||
<div
|
||||
className="h-1 w-5/6"
|
||||
style={{ background: text, opacity: 0.45 }}
|
||||
/>
|
||||
<div className="h-1 w-2/3" style={{ background: text, opacity: 0.3 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeGrid({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ThemeId;
|
||||
onChange: (id: ThemeId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{THEMES.map(({ id }) => {
|
||||
const active = value === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(id)}
|
||||
className={`flex flex-col overflow-hidden border text-left transition-colors ${
|
||||
active
|
||||
? "border-accent-brand ring-1 ring-accent-brand"
|
||||
: "border-border hover:border-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<ThemePreview id={id} />
|
||||
<span className="flex items-center justify-between gap-1 border-t border-border bg-card px-2 py-1.5 text-[11px]">
|
||||
{t(THEME_LABEL_KEYS[id])}
|
||||
{active && <Check size={11} className="text-accent-brand" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccentPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [lastValue, setLastValue] = useState(value);
|
||||
if (value !== lastValue) {
|
||||
setLastValue(value);
|
||||
setDraft(value);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
const next = draft.trim();
|
||||
if (isHexColor(next)) onChange(next);
|
||||
else setDraft(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-12">
|
||||
{ACCENT_PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
title={color.label}
|
||||
aria-label={color.label}
|
||||
aria-pressed={value === color.value}
|
||||
onClick={() => onChange(color.value)}
|
||||
className="flex h-7 items-center justify-center border border-border"
|
||||
style={{ background: color.value }}
|
||||
>
|
||||
{value === color.value && (
|
||||
<Check size={12} className="text-white drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className="size-5 shrink-0 cursor-pointer border border-border/60"
|
||||
style={{ background: value }}
|
||||
title={t("newUi.sidebar.userProfile.colorPickerTooltip")}
|
||||
/>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={
|
||||
value.startsWith("#") && value.length === 7 ? value : "#f97316"
|
||||
}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
}}
|
||||
placeholder="#f97316"
|
||||
className="h-6 min-w-0 flex-1 border-0 bg-transparent p-0 font-mono text-[11px] focus-visible:ring-0"
|
||||
/>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">hex</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FontSizePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FontSizeId;
|
||||
onChange: (id: FontSizeId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{FONT_SIZES.map((size) => (
|
||||
<button
|
||||
key={size.id}
|
||||
type="button"
|
||||
aria-pressed={value === size.id}
|
||||
onClick={() => onChange(size.id)}
|
||||
className={`flex-1 border py-1.5 text-[10px] font-bold transition-colors ${
|
||||
value === size.id
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{size.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UiFontSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: UiFontId;
|
||||
onChange: (id: UiFontId) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select2
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as UiFontId)}
|
||||
className="w-full border border-border bg-background px-2.5 py-1.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{UI_FONTS.map((font) => (
|
||||
<option key={font.id} value={font.id}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguageSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select2
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full border border-border bg-background px-2.5 py-1.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,7 @@ import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
||||
import { toast } from "sonner";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls";
|
||||
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
|
||||
import {
|
||||
FeatureSettingsSection,
|
||||
@@ -83,57 +84,6 @@ type UserProfileSection =
|
||||
| "data"
|
||||
| FeatureSectionId;
|
||||
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
{ id: "system", preview: "auto" },
|
||||
{ id: "light", preview: "#ffffff" },
|
||||
{ id: "dark", preview: "#1a1c22" },
|
||||
{ id: "dracula", preview: "#282a36" },
|
||||
{ id: "catppuccin", preview: "#1e1e2e" },
|
||||
{ id: "nord", preview: "#2e3440" },
|
||||
{ id: "solarized", preview: "#002b36" },
|
||||
{ id: "tokyo-night", preview: "#1a1b26" },
|
||||
{ id: "one-dark", preview: "#282c34" },
|
||||
{ id: "gruvbox", preview: "#282828" },
|
||||
];
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "af", label: "Afrikaans" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
{ code: "bn", label: "বাংলা" },
|
||||
{ code: "bg", label: "Български" },
|
||||
{ code: "ca", label: "Català" },
|
||||
{ code: "zh-CN", label: "中文 (简体)" },
|
||||
{ code: "zh-TW", label: "中文 (繁體)" },
|
||||
{ code: "cs", label: "Čeština" },
|
||||
{ code: "da", label: "Dansk" },
|
||||
{ code: "nl", label: "Nederlands" },
|
||||
{ code: "fi", label: "Suomi" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "el", label: "Ελληνικά" },
|
||||
{ code: "he", label: "עברית" },
|
||||
{ code: "hi", label: "हिन्दी" },
|
||||
{ code: "hu", label: "Magyar" },
|
||||
{ code: "id", label: "Indonesia" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "ko", label: "한국어" },
|
||||
{ code: "no", label: "Norsk" },
|
||||
{ code: "pl", label: "Polski" },
|
||||
{ code: "pt-PT", label: "Português (PT)" },
|
||||
{ code: "pt-BR", label: "Português (BR)" },
|
||||
{ code: "ro", label: "Română" },
|
||||
{ code: "ru", label: "Русский" },
|
||||
{ code: "sr", label: "Српски" },
|
||||
{ code: "es-ES", label: "Español" },
|
||||
{ code: "sv-SE", label: "Svenska" },
|
||||
{ code: "th", label: "ไทย" },
|
||||
{ code: "tr", label: "Türkçe" },
|
||||
{ code: "uk", label: "Українська" },
|
||||
{ code: "vi", label: "Tiếng Việt" },
|
||||
];
|
||||
|
||||
export function AccordionSection({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
saveUiPreferences: vi.fn(async () => {}),
|
||||
saveUiPreferences: vi.fn(async (_body: unknown) => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
|
||||
@@ -199,3 +199,64 @@ describe("UiPreferencesProvider", () => {
|
||||
expect(result.current?.preferences.overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding state", () => {
|
||||
it("uses preferences handed in by the boot gate without fetching", () => {
|
||||
const initial = { ...defaultUiPreferences(), preset: "advanced" as const };
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<UiPreferencesProvider initial={initial}>
|
||||
{children}
|
||||
</UiPreferencesProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current?.loaded).toBe(true);
|
||||
expect(result.current?.preferences.preset).toBe("advanced");
|
||||
expect(api.getUiPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges seen steps and sends them right away on flushNow, even in local mode", async () => {
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current?.markOnboardingSeen({ welcome: 1, "rd:guacd": 2 });
|
||||
result.current?.markOnboardingSeen(
|
||||
{ "rd:guacd": 1, done: 1 },
|
||||
{ completed: true, skipped: false },
|
||||
);
|
||||
});
|
||||
expect(result.current?.preferences.onboarding.seen).toEqual({
|
||||
welcome: 1,
|
||||
"rd:guacd": 2,
|
||||
done: 1,
|
||||
});
|
||||
expect(result.current?.preferences.onboarding.completedAt).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
await result.current?.flushNow();
|
||||
});
|
||||
expect(api.saveUiPreferences).toHaveBeenCalledTimes(1);
|
||||
const body = api.saveUiPreferences.mock.calls[0][0] as {
|
||||
onboarding: { seen: Record<string, number>; skipped: boolean };
|
||||
};
|
||||
expect(body.onboarding.seen).toEqual({
|
||||
welcome: 1,
|
||||
"rd:guacd": 2,
|
||||
done: 1,
|
||||
});
|
||||
expect(body.onboarding.skipped).toBe(false);
|
||||
});
|
||||
|
||||
it("sends a queued change when the provider unmounts", async () => {
|
||||
const { result, unmount } = renderHook(() => useUiPreferencesContext(), {
|
||||
wrapper,
|
||||
});
|
||||
await waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
act(() => {
|
||||
result.current?.markOnboardingSeen({ appearance: 1 });
|
||||
});
|
||||
unmount();
|
||||
await waitFor(() => expect(api.saveUiPreferences).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isEmbeddedAuthFrame,
|
||||
isInElectronWebView,
|
||||
isInMobileWebView,
|
||||
} from "@/lib/embedded-frame";
|
||||
|
||||
type TestWindow = Window & {
|
||||
ReactNativeWebView?: unknown;
|
||||
IS_ELECTRON_WEBVIEW?: boolean;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (window as TestWindow).ReactNativeWebView;
|
||||
delete (window as TestWindow).IS_ELECTRON_WEBVIEW;
|
||||
});
|
||||
|
||||
describe("embedded frame detection", () => {
|
||||
it("is false for a normal browser tab", () => {
|
||||
expect(isInMobileWebView()).toBe(false);
|
||||
expect(isInElectronWebView()).toBe(false);
|
||||
expect(isEmbeddedAuthFrame()).toBe(false);
|
||||
});
|
||||
|
||||
it("spots the mobile app by user agent or bridge", () => {
|
||||
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
||||
"Mozilla/5.0 Termix-Mobile/Android",
|
||||
);
|
||||
expect(isInMobileWebView()).toBe(true);
|
||||
vi.restoreAllMocks();
|
||||
(window as TestWindow).ReactNativeWebView = { postMessage() {} };
|
||||
expect(isInMobileWebView()).toBe(true);
|
||||
expect(isInElectronWebView()).toBe(false);
|
||||
expect(isEmbeddedAuthFrame()).toBe(true);
|
||||
});
|
||||
|
||||
it("spots the desktop link window by flag", () => {
|
||||
(window as TestWindow).IS_ELECTRON_WEBVIEW = true;
|
||||
expect(isInElectronWebView()).toBe(true);
|
||||
expect(isEmbeddedAuthFrame()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
saveUiPreferences: vi.fn(async () => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
const frame = vi.hoisted(() => ({ embedded: false }));
|
||||
vi.mock("@/lib/embedded-frame", () => ({
|
||||
isEmbeddedAuthFrame: () => frame.embedded,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugin-host/plugin-store", () => ({
|
||||
settledPromise: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("@/onboarding/OnboardingStage", () => ({
|
||||
OnboardingStage: ({ mode }: { mode: string }) => <p>stage {mode}</p>,
|
||||
}));
|
||||
|
||||
vi.mock("@/onboarding/onboarding-steps", () => ({
|
||||
CORE_STEPS: [
|
||||
{
|
||||
id: "appearance",
|
||||
version: 1,
|
||||
titleKey: "a",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import {
|
||||
OnboardingHost,
|
||||
OPEN_ONBOARDING_EVENT,
|
||||
} from "@/onboarding/OnboardingHost";
|
||||
import { onboardingSteps } from "@/onboarding/onboarding-registry";
|
||||
import {
|
||||
UiPreferencesProvider,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
import { setPermissionsForTesting } from "@/hooks/use-permissions";
|
||||
import { defaultUiPreferences } from "@/types/ui-preferences";
|
||||
|
||||
function prefs(extra = {}) {
|
||||
return {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen: { appearance: 1 },
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let seen: Record<string, number> = {};
|
||||
function SeenProbe() {
|
||||
seen = useUiPreferencesContext()?.preferences.onboarding.seen ?? {};
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderHost(preferences = prefs()) {
|
||||
render(
|
||||
<UiPreferencesProvider initial={preferences}>
|
||||
<OnboardingHost />
|
||||
<SeenProbe />
|
||||
</UiPreferencesProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const guacd = {
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "g",
|
||||
Component: () => null,
|
||||
audience: "all" as const,
|
||||
position: 50,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
frame.embedded = false;
|
||||
setPermissionsForTesting([]);
|
||||
onboardingSteps.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("OnboardingHost", () => {
|
||||
it("opens a rerun when settings asks for one", async () => {
|
||||
renderHost();
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(OPEN_ONBOARDING_EVENT));
|
||||
});
|
||||
expect(await screen.findByText("stage rerun")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a partial run when a plugin adds a step mid-session", async () => {
|
||||
renderHost();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.queryByText(/stage/)).toBeNull();
|
||||
act(() => {
|
||||
onboardingSteps.register(guacd);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("stage partial", {}, { timeout: 2000 }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks existing plugin steps seen for a carried over user without showing them", async () => {
|
||||
onboardingSteps.register(guacd);
|
||||
renderHost(prefs({ baselinePending: true }));
|
||||
await waitFor(() => expect(seen["rd:guacd"]).toBe(1));
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(screen.queryByText(/stage/)).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing inside an embedded sign in frame", async () => {
|
||||
frame.embedded = true;
|
||||
onboardingSteps.register(guacd);
|
||||
renderHost();
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(OPEN_ONBOARDING_EVENT));
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(screen.queryByText(/stage/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import type { OnboardingStepProps } from "@termix-ssh/plugin-sdk/frontend";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
saveUiPreferences: vi.fn(async (_body: unknown) => {}),
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
vi.mock("@/api/plugins-api", () => ({
|
||||
getOnboardingPlugins: vi.fn(async () => ({
|
||||
pending: false,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const gate = vi.hoisted(() => ({ allow: true }));
|
||||
|
||||
function Plain({ label }: { label: string }) {
|
||||
return <p>{label} body</p>;
|
||||
}
|
||||
|
||||
function Gated({ setBeforeNext }: OnboardingStepProps) {
|
||||
useEffect(() => {
|
||||
setBeforeNext(() => gate.allow);
|
||||
return () => setBeforeNext(null);
|
||||
}, [setBeforeNext]);
|
||||
return <p>picker body</p>;
|
||||
}
|
||||
|
||||
vi.mock("@/onboarding/onboarding-steps", () => ({
|
||||
CORE_STEPS: [
|
||||
{
|
||||
id: "preset",
|
||||
version: 1,
|
||||
titleKey: "preset",
|
||||
Component: () => <Plain label="preset" />,
|
||||
audience: "all",
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
version: 1,
|
||||
titleKey: "picker",
|
||||
Component: Gated,
|
||||
audience: "all",
|
||||
position: 10,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
version: 2,
|
||||
titleKey: "appearance",
|
||||
Component: () => <Plain label="appearance" />,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
version: 1,
|
||||
titleKey: "security",
|
||||
Component: () => <Plain label="security" />,
|
||||
audience: "all",
|
||||
position: 100,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { OnboardingStage } from "@/onboarding/OnboardingStage";
|
||||
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
|
||||
import { setPermissionsForTesting } from "@/hooks/use-permissions";
|
||||
import { onboardingSteps } from "@/onboarding/onboarding-registry";
|
||||
import {
|
||||
defaultUiPreferences,
|
||||
type UiPreferences,
|
||||
} from "@/types/ui-preferences";
|
||||
|
||||
function renderStage(
|
||||
mode: "full" | "partial" | "rerun",
|
||||
preferences: UiPreferences = defaultUiPreferences(),
|
||||
) {
|
||||
const onDone = vi.fn();
|
||||
render(
|
||||
<UiPreferencesProvider initial={preferences}>
|
||||
<OnboardingStage mode={mode} shellReady={false} onDone={onDone} />
|
||||
</UiPreferencesProvider>,
|
||||
);
|
||||
return onDone;
|
||||
}
|
||||
|
||||
const next = () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /onboarding.next|Next/ }));
|
||||
|
||||
beforeEach(() => {
|
||||
gate.allow = true;
|
||||
api.saveUiPreferences.mockClear();
|
||||
setPermissionsForTesting([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("OnboardingStage", () => {
|
||||
it("hides Skip until the required step is confirmed, then Skip finishes", async () => {
|
||||
const onDone = renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
expect(screen.queryByRole("button", { name: /skip/i })).toBeNull();
|
||||
|
||||
next();
|
||||
await screen.findByText("picker body");
|
||||
expect(screen.queryByRole("button", { name: /skip/i })).toBeNull();
|
||||
|
||||
await act(async () => next());
|
||||
await screen.findByText("appearance body");
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /skip/i })),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
const body = api.saveUiPreferences.mock.calls.at(-1)?.[0] as {
|
||||
onboarding: { skipped: boolean };
|
||||
};
|
||||
expect(body.onboarding.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("stays on a step whose beforeNext says no", async () => {
|
||||
gate.allow = false;
|
||||
renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
next();
|
||||
await screen.findByText("picker body");
|
||||
await act(async () => next());
|
||||
expect(screen.getByText("picker body")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks every step in the run as seen when it finishes", async () => {
|
||||
const onDone = renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
next();
|
||||
await screen.findByText("picker body");
|
||||
await act(async () => next());
|
||||
await screen.findByText("appearance body");
|
||||
next();
|
||||
await screen.findByText("security body");
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /finish|Done/ })),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
const body = api.saveUiPreferences.mock.calls.at(-1)?.[0] as {
|
||||
onboarding: { seen: Record<string, number> };
|
||||
};
|
||||
expect(body.onboarding.seen).toEqual({
|
||||
preset: 1,
|
||||
plugins: 1,
|
||||
appearance: 2,
|
||||
security: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("has no sidebar, just the step and the buttons", async () => {
|
||||
renderStage("full");
|
||||
await screen.findByText("preset body");
|
||||
expect(screen.queryByRole("navigation")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves plugin steps out for someone carried over from an older version", async () => {
|
||||
const off = onboardingSteps.register({
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "guacd",
|
||||
Component: () => <Plain label="guacd" />,
|
||||
audience: "all",
|
||||
position: 50,
|
||||
});
|
||||
const preferences = {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen: { preset: 1, plugins: 1, appearance: 1, security: 1 },
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
baselinePending: true,
|
||||
},
|
||||
};
|
||||
renderStage("partial", preferences);
|
||||
await screen.findByText("appearance body");
|
||||
expect(screen.queryByText("guacd body")).toBeNull();
|
||||
off();
|
||||
});
|
||||
|
||||
it("shows only what someone has not seen on a partial run", async () => {
|
||||
const preferences = {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen: { preset: 1, plugins: 1, appearance: 1, security: 1 },
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
},
|
||||
};
|
||||
const onDone = renderStage("partial", preferences);
|
||||
await screen.findByText("appearance body");
|
||||
expect(screen.queryByText("preset body")).toBeNull();
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: /finish|Done/ })),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
baselineSeen,
|
||||
computeFlow,
|
||||
needsFullFlow,
|
||||
seenFor,
|
||||
} from "@/onboarding/onboarding-flow";
|
||||
import {
|
||||
mergeStepDefs,
|
||||
sectionPosition,
|
||||
type OnboardingCtx,
|
||||
type OnboardingStepDef,
|
||||
} from "@/onboarding/onboarding-registry";
|
||||
import { defaultOnboardingState } from "@/types/ui-preferences";
|
||||
|
||||
const Noop = () => null;
|
||||
|
||||
function step(
|
||||
id: string,
|
||||
position: number,
|
||||
extra: Partial<OnboardingStepDef> = {},
|
||||
): OnboardingStepDef {
|
||||
return {
|
||||
id,
|
||||
version: 1,
|
||||
titleKey: id,
|
||||
Component: Noop,
|
||||
audience: "all",
|
||||
position,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(overrides: Partial<OnboardingCtx> = {}): OnboardingCtx {
|
||||
return {
|
||||
mode: "partial",
|
||||
isAdmin: false,
|
||||
isDesktop: false,
|
||||
has: () => false,
|
||||
pluginSetupPending: false,
|
||||
pluginsManagedElsewhere: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const steps = [
|
||||
step("welcome", 0),
|
||||
step("appearance", 40, { version: 2 }),
|
||||
step("rd:guacd", sectionPosition("setup"), {
|
||||
pluginId: "rd",
|
||||
audience: "admin",
|
||||
}),
|
||||
step("ai:assistant", sectionPosition("explore"), {
|
||||
pluginId: "ai",
|
||||
permission: "ai.use",
|
||||
}),
|
||||
step("done", 100),
|
||||
];
|
||||
|
||||
const ids = (list: OnboardingStepDef[]) => list.map((s) => s.id);
|
||||
|
||||
describe("computeFlow", () => {
|
||||
it("shows every relevant step on a full run", () => {
|
||||
expect(ids(computeFlow(steps, {}, ctx({ mode: "full" })))).toEqual([
|
||||
"welcome",
|
||||
"appearance",
|
||||
"done",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters admin and permission steps", () => {
|
||||
const flow = computeFlow(
|
||||
steps,
|
||||
{},
|
||||
ctx({ mode: "rerun", isAdmin: true, has: (p) => p === "ai.use" }),
|
||||
);
|
||||
expect(ids(flow)).toEqual([
|
||||
"welcome",
|
||||
"appearance",
|
||||
"rd:guacd",
|
||||
"ai:assistant",
|
||||
"done",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows only unseen or bumped steps on a partial run", () => {
|
||||
const flow = computeFlow(
|
||||
steps,
|
||||
{ welcome: 1, appearance: 1, "rd:guacd": 1, done: 1 },
|
||||
ctx({ isAdmin: true, has: () => true }),
|
||||
);
|
||||
expect(ids(flow)).toEqual(["appearance", "ai:assistant"]);
|
||||
});
|
||||
|
||||
it("is empty when everything is seen", () => {
|
||||
expect(
|
||||
computeFlow(
|
||||
steps,
|
||||
{ welcome: 1, appearance: 2, "rd:guacd": 1, done: 1 },
|
||||
ctx({ isAdmin: true }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes a seen step that forces itself pending", () => {
|
||||
const picker = step("plugins", 10, {
|
||||
forcePending: (c) => c.pluginSetupPending,
|
||||
});
|
||||
expect(
|
||||
ids(
|
||||
computeFlow(
|
||||
[picker],
|
||||
{ plugins: 1 },
|
||||
ctx({ pluginSetupPending: true }),
|
||||
),
|
||||
),
|
||||
).toEqual(["plugins"]);
|
||||
expect(computeFlow([picker], { plugins: 1 }, ctx())).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a step whose relevance check throws", () => {
|
||||
const broken = step("x:y", 50, {
|
||||
isRelevant: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
expect(computeFlow([broken], {}, ctx())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seen bookkeeping", () => {
|
||||
it("records every step in a run at its version", () => {
|
||||
expect(seenFor(steps.slice(0, 2))).toEqual({ welcome: 1, appearance: 2 });
|
||||
});
|
||||
|
||||
it("baselines only plugin steps", () => {
|
||||
expect(baselineSeen(steps)).toEqual({ "rd:guacd": 1, "ai:assistant": 1 });
|
||||
});
|
||||
|
||||
it("needs the full run only for someone who never finished one", () => {
|
||||
expect(needsFullFlow(defaultOnboardingState())).toBe(true);
|
||||
expect(
|
||||
needsFullFlow({ ...defaultOnboardingState(), seen: { welcome: 1 } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
needsFullFlow({ ...defaultOnboardingState(), completedAt: "x" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStepDefs", () => {
|
||||
it("orders by position and lets a registered step win over a legacy one", () => {
|
||||
const legacy = step("ai:assistant", 80, { titleKey: "old" });
|
||||
const registered = step("ai:assistant", 50, { titleKey: "new" });
|
||||
const merged = mergeStepDefs(
|
||||
[step("welcome", 0), step("done", 100)],
|
||||
[registered],
|
||||
[legacy, step("x:legacy", 80)],
|
||||
);
|
||||
expect(merged.map((s) => [s.id, s.titleKey])).toEqual([
|
||||
["welcome", "welcome"],
|
||||
["ai:assistant", "new"],
|
||||
["x:legacy", "x:legacy"],
|
||||
["done", "done"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("places plugin sections between the core steps", () => {
|
||||
expect(sectionPosition("setup")).toBeGreaterThan(40);
|
||||
expect(sectionPosition("setup")).toBeLessThan(60);
|
||||
expect(sectionPosition("explore", 1000)).toBeLessThan(90);
|
||||
expect(sectionPosition("security")).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ONBOARDING_STEPS, relevantSteps } from "@/onboarding/onboarding-steps";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CORE_STEPS } from "@/onboarding/onboarding-steps";
|
||||
import { computeFlow } from "@/onboarding/onboarding-flow";
|
||||
import type { OnboardingCtx } from "@/onboarding/onboarding-registry";
|
||||
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
|
||||
import { UI_AREA_KEYS, PRESETS } from "@/types/ui-preferences";
|
||||
import en from "@/locales/en.json";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => false));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
function lookup(key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
@@ -18,83 +18,125 @@ function lookup(key: string): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
describe("ONBOARDING_STEPS", () => {
|
||||
it("has unique ids and real title translations", () => {
|
||||
const ids = ONBOARDING_STEPS.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const step of ONBOARDING_STEPS) {
|
||||
function ctx(overrides: Partial<OnboardingCtx> = {}): OnboardingCtx {
|
||||
return {
|
||||
mode: "full",
|
||||
isAdmin: false,
|
||||
isDesktop: false,
|
||||
has: () => false,
|
||||
pluginSetupPending: false,
|
||||
pluginsManagedElsewhere: false,
|
||||
canEnrollSecondFactor: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (c: OnboardingCtx) =>
|
||||
computeFlow(CORE_STEPS, {}, c).map((s) => s.id);
|
||||
|
||||
describe("CORE_STEPS", () => {
|
||||
it("has unique ids, real titles and the versions in CORE_ONBOARDING_STEPS", () => {
|
||||
const stepIds = CORE_STEPS.map((s) => s.id);
|
||||
expect(new Set(stepIds).size).toBe(stepIds.length);
|
||||
expect(stepIds.sort()).toEqual(Object.keys(CORE_ONBOARDING_STEPS).sort());
|
||||
for (const step of CORE_STEPS) {
|
||||
expect(typeof lookup(step.titleKey), step.titleKey).toBe("string");
|
||||
expect(step.version).toBe(
|
||||
CORE_ONBOARDING_STEPS[step.id as keyof typeof CORE_ONBOARDING_STEPS],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens with the preset picker right after the welcome", () => {
|
||||
expect(ONBOARDING_STEPS[0].id).toBe("welcome");
|
||||
expect(ONBOARDING_STEPS[1].id).toBe("preset");
|
||||
it("is only choices: preset and appearance for everyone", () => {
|
||||
expect(ids(ctx())).toEqual(["preset", "appearance"]);
|
||||
});
|
||||
|
||||
it("has no add-a-host step, which used to close onboarding mid-flow", () => {
|
||||
const ids = relevantSteps({}).map((s) => s.id);
|
||||
expect(ids).not.toContain("first-host");
|
||||
it("opens on the plugin picker for an admin on a fresh install", () => {
|
||||
expect(ids(ctx({ isAdmin: true, pluginSetupPending: true }))).toEqual([
|
||||
"plugins",
|
||||
"preset",
|
||||
"appearance",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never shows the plugin picker to someone who is not an admin", () => {
|
||||
expect(ids(ctx({ pluginSetupPending: true }))).not.toContain("plugins");
|
||||
expect(ids(ctx({ mode: "rerun" }))).not.toContain("plugins");
|
||||
});
|
||||
|
||||
it("leaves the picker out once it was applied, even on a rerun", () => {
|
||||
expect(ids(ctx({ isAdmin: true, mode: "rerun" }))).not.toContain("plugins");
|
||||
expect(ids(ctx({ isAdmin: true }))).not.toContain("plugins");
|
||||
});
|
||||
|
||||
it("never shows the picker on a linked desktop", () => {
|
||||
expect(
|
||||
ids(
|
||||
ctx({
|
||||
isAdmin: true,
|
||||
pluginSetupPending: true,
|
||||
pluginsManagedElsewhere: true,
|
||||
}),
|
||||
),
|
||||
).not.toContain("plugins");
|
||||
});
|
||||
|
||||
it("makes the plugin picker required", () => {
|
||||
expect(CORE_STEPS.find((s) => s.id === "plugins")?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("asks how the desktop app is used only on the desktop", () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
expect(relevantSteps({}).map((s) => s.id)).not.toContain("desktop-sync");
|
||||
isElectron.mockReturnValue(true);
|
||||
expect(relevantSteps({}).map((s) => s.id)).toContain("desktop-sync");
|
||||
isElectron.mockReturnValue(false);
|
||||
expect(ids(ctx({ isDesktop: true }))).toEqual([
|
||||
"desktop-sync",
|
||||
"preset",
|
||||
"appearance",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends on the done step", () => {
|
||||
const ids = relevantSteps({}).map((s) => s.id);
|
||||
expect(ids[ids.length - 1]).toBe("done");
|
||||
});
|
||||
|
||||
it("tours the feature set after the setup choices", () => {
|
||||
const ids = ONBOARDING_STEPS.map((s) => s.id);
|
||||
for (const id of ["features", "workflow", "security"]) {
|
||||
expect(ids).toContain(id);
|
||||
expect(ids.indexOf(id)).toBeGreaterThan(ids.indexOf("appearance"));
|
||||
}
|
||||
it("offers account security only when there is something to set up", () => {
|
||||
expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([
|
||||
"preset",
|
||||
"appearance",
|
||||
"security",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding step body translations", () => {
|
||||
const KEYS: Record<string, string[]> = {
|
||||
welcome: ["hosts", "terminal", "files"],
|
||||
workflow: ["palette", "split", "dock"],
|
||||
security: ["credentials", "twofa", "identity", "sharing"],
|
||||
done: ["settings", "rerun", "docs"],
|
||||
};
|
||||
|
||||
it("has a title and description for every card the steps render", () => {
|
||||
for (const [prefix, keys] of Object.entries(KEYS)) {
|
||||
for (const key of keys) {
|
||||
expect(
|
||||
lookup(`onboarding.${prefix}_${key}`),
|
||||
`${prefix}_${key}`,
|
||||
).toBeTypeOf("string");
|
||||
expect(
|
||||
lookup(`onboarding.${prefix}_${key}_desc`),
|
||||
`${prefix}_${key}_desc`,
|
||||
).toBeTypeOf("string");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has the intro copy each step shows above its cards", () => {
|
||||
describe("onboarding translations", () => {
|
||||
it("has the copy the stage and the steps show", () => {
|
||||
for (const key of [
|
||||
"welcomeIntro",
|
||||
"presetIntro",
|
||||
"appearanceIntro",
|
||||
"featuresIntro",
|
||||
"workflowIntro",
|
||||
"securityIntro",
|
||||
"doneDesc",
|
||||
"pluginsIntro",
|
||||
"pluginsRemoveNote",
|
||||
"pluginsChoice_enabled",
|
||||
"pluginsChoice_disabled",
|
||||
"pluginsChoice_remove",
|
||||
"pluginsConsentTitle",
|
||||
"partialHeading",
|
||||
"setupHeading",
|
||||
"skipPartial",
|
||||
"next",
|
||||
"finish",
|
||||
]) {
|
||||
expect(lookup(`onboarding.${key}`), key).toBeTypeOf("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("no longer carries the feature tour", () => {
|
||||
const keys = Object.keys(
|
||||
(en as Record<string, Record<string, unknown>>).onboarding,
|
||||
);
|
||||
expect(
|
||||
keys.filter((k) => /^(welcome|features|workflow|done)/.test(k)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses no em dashes", () => {
|
||||
const text = JSON.stringify((en as Record<string, unknown>).onboarding);
|
||||
expect(text).not.toContain("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("interface settings translations", () => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultUiPreferences } from "@/types/ui-preferences";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getUiPreferences: vi.fn(),
|
||||
getOnboardingPlugins: vi.fn(),
|
||||
has: vi.fn((_p: string) => false),
|
||||
embedded: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/ui-preferences-api", () => ({
|
||||
getUiPreferences: mocks.getUiPreferences,
|
||||
}));
|
||||
vi.mock("@/api/plugins-api", () => ({
|
||||
getOnboardingPlugins: mocks.getOnboardingPlugins,
|
||||
}));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
loadPermissionChecker: async () => mocks.has,
|
||||
}));
|
||||
vi.mock("@/lib/embedded-frame", () => ({
|
||||
isEmbeddedAuthFrame: () => mocks.embedded,
|
||||
}));
|
||||
vi.mock("@/onboarding/onboarding-steps", () => ({
|
||||
CORE_STEPS: [
|
||||
{
|
||||
id: "welcome",
|
||||
version: 1,
|
||||
titleKey: "w",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
version: 1,
|
||||
titleKey: "p",
|
||||
Component: () => null,
|
||||
audience: "admin",
|
||||
position: 10,
|
||||
isRelevant: (ctx: { pluginSetupPending: boolean }) =>
|
||||
ctx.pluginSetupPending,
|
||||
forcePending: (ctx: { pluginSetupPending: boolean }) =>
|
||||
ctx.pluginSetupPending,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
version: 2,
|
||||
titleKey: "a",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 40,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { resolveStartupOnboarding } from "@/onboarding/startup";
|
||||
import { onboardingSteps } from "@/onboarding/onboarding-registry";
|
||||
|
||||
function prefs(seen: Record<string, number>, extra = {}) {
|
||||
return {
|
||||
...defaultUiPreferences(),
|
||||
onboarding: {
|
||||
seen,
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.embedded = false;
|
||||
mocks.has.mockReset();
|
||||
mocks.has.mockReturnValue(false);
|
||||
mocks.getUiPreferences.mockReset();
|
||||
mocks.getOnboardingPlugins.mockReset();
|
||||
mocks.getOnboardingPlugins.mockResolvedValue({
|
||||
pending: false,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
});
|
||||
onboardingSteps.reset();
|
||||
});
|
||||
|
||||
describe("resolveStartupOnboarding", () => {
|
||||
it("never runs inside the mobile app or a desktop link window", async () => {
|
||||
mocks.embedded = true;
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
expect(mocks.getUiPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the full flow for a new user", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(defaultUiPreferences());
|
||||
const plan = await resolveStartupOnboarding();
|
||||
expect(plan.mode).toBe("full");
|
||||
expect(plan.preferences).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not ask for the plugin list as a non-admin", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(defaultUiPreferences());
|
||||
await resolveStartupOnboarding();
|
||||
expect(mocks.getOnboardingPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows nothing to someone who has seen everything", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a partial flow for a bumped core step", async () => {
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, appearance: 1 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBe("partial");
|
||||
});
|
||||
|
||||
it("brings an admin back to the picker while it is still pending", async () => {
|
||||
mocks.has.mockReturnValue(true);
|
||||
mocks.getOnboardingPlugins.mockResolvedValue({
|
||||
pending: true,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
});
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
|
||||
);
|
||||
const plan = await resolveStartupOnboarding();
|
||||
expect(plan.mode).toBe("partial");
|
||||
expect(plan.plugins?.pending).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a failed plugin list as not pending", async () => {
|
||||
mocks.has.mockReturnValue(true);
|
||||
mocks.getOnboardingPlugins.mockRejectedValue(new Error("403"));
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a partial flow for a new plugin step", async () => {
|
||||
onboardingSteps.register({
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "g",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 50,
|
||||
});
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, appearance: 2 }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBe("partial");
|
||||
});
|
||||
|
||||
it("leaves plugin steps to the baseline pass for carried over users", async () => {
|
||||
onboardingSteps.register({
|
||||
id: "rd:guacd",
|
||||
pluginId: "rd",
|
||||
version: 1,
|
||||
titleKey: "g",
|
||||
Component: () => null,
|
||||
audience: "all",
|
||||
position: 50,
|
||||
});
|
||||
mocks.getUiPreferences.mockResolvedValue(
|
||||
prefs({ welcome: 1, appearance: 2 }, { baselinePending: true }),
|
||||
);
|
||||
expect((await resolveStartupOnboarding()).mode).toBeNull();
|
||||
});
|
||||
|
||||
it("gives up quietly when preferences cannot load", async () => {
|
||||
mocks.getUiPreferences.mockRejectedValue(new Error("offline"));
|
||||
expect(await resolveStartupOnboarding()).toEqual({
|
||||
preferences: null,
|
||||
mode: null,
|
||||
plugins: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { OnboardingPluginList } from "@/api/plugins-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getOnboardingPlugins: vi.fn(),
|
||||
applyOnboardingPlugins: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/plugins-api", () => api);
|
||||
|
||||
const loader = vi.hoisted(() => ({ syncPlugins: vi.fn(async () => {}) }));
|
||||
vi.mock("@/plugin-host/loader", () => loader);
|
||||
|
||||
import { PluginsStep } from "@/onboarding/steps/PluginsStep";
|
||||
import { defaultChoices } from "@/onboarding/plugin-choices";
|
||||
|
||||
function plugin(
|
||||
id: string,
|
||||
extra: Partial<OnboardingPluginList["plugins"][number]> = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
description: `${id} plugin`,
|
||||
icon: null,
|
||||
category: "Terminal",
|
||||
version: "1.0.0",
|
||||
source: "bundled" as const,
|
||||
state: "enabled",
|
||||
dependencies: [],
|
||||
recommended: false,
|
||||
consent: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const LIST: OnboardingPluginList = {
|
||||
pending: true,
|
||||
reason: "fresh",
|
||||
managedByLinkedServer: false,
|
||||
plugins: [
|
||||
plugin("term", { recommended: true }),
|
||||
plugin("base", { category: "Infrastructure" }),
|
||||
plugin("addon", { category: "Infrastructure", dependencies: ["base"] }),
|
||||
plugin("stats", { recommended: true, consent: true, state: "disabled" }),
|
||||
],
|
||||
};
|
||||
|
||||
describe("defaultChoices", () => {
|
||||
it("keeps recommended plugins and removes the rest on a fresh install", () => {
|
||||
expect(defaultChoices(LIST)).toEqual({
|
||||
term: "enabled",
|
||||
base: "remove",
|
||||
addon: "remove",
|
||||
stats: "enabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts from how things are now on an upgraded install", () => {
|
||||
expect(defaultChoices({ ...LIST, reason: "upgrade" })).toEqual({
|
||||
term: "enabled",
|
||||
base: "enabled",
|
||||
addon: "enabled",
|
||||
stats: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts from how things are now on a later run", () => {
|
||||
expect(defaultChoices({ ...LIST, pending: false })).toEqual({
|
||||
term: "enabled",
|
||||
base: "enabled",
|
||||
addon: "enabled",
|
||||
stats: "disabled",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginsStep", () => {
|
||||
let beforeNext: (() => boolean | Promise<boolean>) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeNext = null;
|
||||
api.getOnboardingPlugins.mockResolvedValue(LIST);
|
||||
api.applyOnboardingPlugins.mockReset();
|
||||
loader.syncPlugins.mockClear();
|
||||
});
|
||||
|
||||
async function renderStep() {
|
||||
render(
|
||||
<PluginsStep
|
||||
mode="full"
|
||||
isAdmin
|
||||
isDesktop={false}
|
||||
shellReady={false}
|
||||
setCanContinue={() => {}}
|
||||
setBeforeNext={(fn) => {
|
||||
beforeNext = fn;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("TERM");
|
||||
}
|
||||
|
||||
it("groups plugins by category and puts consent plugins in their own section", async () => {
|
||||
await renderStep();
|
||||
expect(screen.getByText("Terminal")).toBeTruthy();
|
||||
expect(screen.getByText("Infrastructure")).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox")).toHaveProperty("checked", true);
|
||||
});
|
||||
|
||||
it("tells an upgrading admin that nothing changes unless they say so", async () => {
|
||||
api.getOnboardingPlugins.mockResolvedValue({ ...LIST, reason: "upgrade" });
|
||||
await renderStep();
|
||||
expect(screen.getByText(/onboarding.pluginsUpgradeNote/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps a dependency the admin kept something for, and says why", async () => {
|
||||
await renderStep();
|
||||
const addon = screen.getByRole("radiogroup", { name: "ADDON" });
|
||||
fireEvent.click(within(addon).getByRole("radio", { name: /enabled|On/ }));
|
||||
expect(
|
||||
await screen.findByText("onboarding.pluginsKeptBecause"),
|
||||
).toBeTruthy();
|
||||
const base = screen.getByRole("radiogroup", { name: "BASE" });
|
||||
expect(
|
||||
within(base)
|
||||
.getByRole("radio", { name: /enabled|On/ })
|
||||
.getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("applies the choices on Next and resyncs the plugin runtime", async () => {
|
||||
api.applyOnboardingPlugins.mockResolvedValue({
|
||||
resolved: {},
|
||||
adjustments: [],
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
removed: ["base", "addon"],
|
||||
failed: [],
|
||||
});
|
||||
api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({
|
||||
...LIST,
|
||||
pending: false,
|
||||
});
|
||||
await renderStep();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await beforeNext!();
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(api.applyOnboardingPlugins).toHaveBeenCalledWith({
|
||||
term: "enabled",
|
||||
base: "remove",
|
||||
addon: "remove",
|
||||
stats: "enabled",
|
||||
});
|
||||
expect(loader.syncPlugins).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops once to show failures, then lets the admin move on", async () => {
|
||||
api.applyOnboardingPlugins.mockResolvedValue({
|
||||
resolved: {},
|
||||
adjustments: [],
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
removed: [],
|
||||
failed: [{ id: "base", error: "busy" }],
|
||||
});
|
||||
api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({
|
||||
...LIST,
|
||||
pending: false,
|
||||
});
|
||||
await renderStep();
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await beforeNext!();
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(screen.getByText("BASE: busy")).toBeTruthy();
|
||||
await act(async () => {
|
||||
ok = await beforeNext!();
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix-ssh/plugin-sdk/testing";
|
||||
import { resetActionRegistry } from "@/shell/action-registry";
|
||||
import { resetPluginStore } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
onboardingSteps,
|
||||
SECTION_POSITION,
|
||||
} from "@/onboarding/onboarding-registry";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
|
||||
const mounted: RenderedPluginApp[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (mounted.length > 0) await mounted.pop()!.deactivate();
|
||||
resetActionRegistry();
|
||||
resetPluginStore();
|
||||
onboardingSteps.reset();
|
||||
});
|
||||
|
||||
const Step = () => <span>step</span>;
|
||||
|
||||
async function mount(activate: (app: TermixApp) => void) {
|
||||
const rendered = await renderWithApp(
|
||||
{ activate: async (app: TermixApp) => activate(app) },
|
||||
{ manifest: { id: "guac" } },
|
||||
);
|
||||
mounted.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
describe("app.registerOnboardingStep", () => {
|
||||
it("keys the step by plugin, namespaces its text and places it by section", async () => {
|
||||
await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "setup",
|
||||
version: 2,
|
||||
titleKey: "onboarding.title",
|
||||
descriptionKey: "onboarding.desc",
|
||||
component: Step,
|
||||
audience: "admin",
|
||||
section: "setup",
|
||||
permission: "manage",
|
||||
});
|
||||
});
|
||||
const [step] = onboardingSteps.list();
|
||||
expect(step).toMatchObject({
|
||||
id: "guac:setup",
|
||||
pluginId: "guac",
|
||||
version: 2,
|
||||
audience: "admin",
|
||||
titleKey: pluginKey("guac", "onboarding.title"),
|
||||
descriptionKey: pluginKey("guac", "onboarding.desc"),
|
||||
permission: "guac.manage",
|
||||
});
|
||||
expect(Math.floor(step.position)).toBe(SECTION_POSITION.setup);
|
||||
});
|
||||
|
||||
it("defaults to version 1, everyone, and the explore section", async () => {
|
||||
await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "tour",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
});
|
||||
});
|
||||
const [step] = onboardingSteps.list();
|
||||
expect(step.version).toBe(1);
|
||||
expect(step.audience).toBe("all");
|
||||
expect(Math.round(step.position)).toBe(SECTION_POSITION.explore);
|
||||
});
|
||||
|
||||
it("passes only the documented context to when, and survives it throwing", async () => {
|
||||
let seen: unknown;
|
||||
await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "a",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
when: (context) => {
|
||||
seen = context;
|
||||
return context.isDesktop;
|
||||
},
|
||||
});
|
||||
app.registerOnboardingStep({
|
||||
id: "b",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
when: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
});
|
||||
const ctx = {
|
||||
mode: "full" as const,
|
||||
isAdmin: true,
|
||||
isDesktop: true,
|
||||
has: () => true,
|
||||
pluginSetupPending: true,
|
||||
pluginsManagedElsewhere: false,
|
||||
};
|
||||
const [a, b] = onboardingSteps.list();
|
||||
expect(a.isRelevant?.(ctx)).toBe(true);
|
||||
expect(seen).toEqual({ isAdmin: true, isDesktop: true, mode: "full" });
|
||||
expect(b.isRelevant?.(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a bad id or version", async () => {
|
||||
await expect(
|
||||
mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "Bad Id",
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/not valid/);
|
||||
await expect(
|
||||
mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "ok",
|
||||
version: 0,
|
||||
titleKey: "t",
|
||||
component: Step,
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/version/);
|
||||
});
|
||||
|
||||
it("removes everything when the plugin is turned off", async () => {
|
||||
const rendered = await mount((app) => {
|
||||
app.registerOnboardingStep({ id: "s", titleKey: "t", component: Step });
|
||||
});
|
||||
expect(onboardingSteps.list()).toHaveLength(1);
|
||||
|
||||
mounted.splice(mounted.indexOf(rendered), 1);
|
||||
await rendered.deactivate();
|
||||
expect(onboardingSteps.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderWithApp onboarding helpers", () => {
|
||||
it("lists and renders a plugin's onboarding step", async () => {
|
||||
const rendered = await mount((app) => {
|
||||
app.registerOnboardingStep({
|
||||
id: "check",
|
||||
titleKey: "t",
|
||||
component: ({ setCanContinue, setBeforeNext }) => {
|
||||
setCanContinue(false);
|
||||
setBeforeNext(() => false);
|
||||
return <span>checking</span>;
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(rendered.registered.onboardingSteps()).toEqual(["check"]);
|
||||
const step = rendered.renderOnboardingStep("check");
|
||||
expect(step.element.textContent).toContain("checking");
|
||||
expect(step.canContinue()).toBe(false);
|
||||
expect(await step.next()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
|
||||
const prefsApi = vi.hoisted(() => ({
|
||||
getUserPreferences: vi.fn(),
|
||||
saveUserPreferences: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/api/open-tabs-api", () => prefsApi);
|
||||
|
||||
const theme = vi.hoisted(() => ({ value: "dark", setTheme: vi.fn() }));
|
||||
vi.mock("@/components/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: theme.value, setTheme: theme.setTheme }),
|
||||
}));
|
||||
|
||||
import {
|
||||
AccentPicker,
|
||||
isHexColor,
|
||||
ThemeGrid,
|
||||
THEMES,
|
||||
useAppearanceSettings,
|
||||
} from "@/settings/appearance/appearance-controls";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
prefsApi.getUserPreferences.mockReset();
|
||||
prefsApi.saveUserPreferences.mockClear();
|
||||
theme.setTheme.mockClear();
|
||||
});
|
||||
|
||||
describe("isHexColor", () => {
|
||||
it("accepts 3 and 6 digit hex only", () => {
|
||||
expect(isHexColor("#fff")).toBe(true);
|
||||
expect(isHexColor("#f59145")).toBe(true);
|
||||
expect(isHexColor("f59145")).toBe(false);
|
||||
expect(isHexColor("#f5914")).toBe(false);
|
||||
expect(isHexColor("red")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAppearanceSettings", () => {
|
||||
it("saves to the account only in cloud mode", async () => {
|
||||
prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "local" });
|
||||
const { result } = renderHook(() => useAppearanceSettings());
|
||||
await act(async () => {
|
||||
result.current.setAccent("#123456");
|
||||
});
|
||||
expect(localStorage.getItem("termix-accent")).toBe("#123456");
|
||||
expect(prefsApi.saveUserPreferences).not.toHaveBeenCalled();
|
||||
|
||||
prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
|
||||
await act(async () => {
|
||||
result.current.setTheme("nord");
|
||||
result.current.setFontSize("lg");
|
||||
});
|
||||
expect(theme.setTheme).toHaveBeenCalledWith("nord");
|
||||
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
|
||||
theme: "nord",
|
||||
});
|
||||
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
|
||||
fontSize: "lg",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThemeGrid", () => {
|
||||
it("offers every theme and reports the pick", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ThemeGrid value="dark" onChange={onChange} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(THEMES.length);
|
||||
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "gruvbox")]);
|
||||
expect(onChange).toHaveBeenCalledWith("gruvbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccentPicker", () => {
|
||||
it("takes a typed hex on Enter and ignores anything else", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AccentPicker value="#f59145" onChange={onChange} />);
|
||||
const input = screen.getByPlaceholderText("#f97316");
|
||||
fireEvent.change(input, { target: { value: "nope" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { value: "#abcdef" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith("#abcdef");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_ONBOARDING_STEPS,
|
||||
LEGACY_SEEN,
|
||||
MAX_SEEN_ENTRIES,
|
||||
mergeSeen,
|
||||
pluginStepKey,
|
||||
sanitizeSeen,
|
||||
} from "@/types/onboarding";
|
||||
|
||||
describe("onboarding seen map", () => {
|
||||
it("keys plugin steps by plugin id", () => {
|
||||
expect(pluginStepKey("remote-desktop", "guacd")).toBe(
|
||||
"remote-desktop:guacd",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges per key keeping the higher version", () => {
|
||||
expect(mergeSeen({ a: 2, b: 1 }, { a: 1, b: 3, c: 1 })).toEqual({
|
||||
a: 2,
|
||||
b: 3,
|
||||
c: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops invalid entries", () => {
|
||||
expect(
|
||||
sanitizeSeen({ ok: 1, zero: 0, neg: -1, frac: 1.2, str: "1", "": 1 }),
|
||||
).toEqual({ ok: 1 });
|
||||
expect(sanitizeSeen(["a"])).toEqual({});
|
||||
expect(sanitizeSeen(null)).toEqual({});
|
||||
});
|
||||
|
||||
it("caps the number of entries", () => {
|
||||
const big: Record<string, number> = {};
|
||||
for (let i = 0; i < MAX_SEEN_ENTRIES + 20; i++) big[`k${i}`] = 1;
|
||||
expect(Object.keys(sanitizeSeen(big))).toHaveLength(MAX_SEEN_ENTRIES);
|
||||
});
|
||||
|
||||
it("legacy seen covers every core step at launch", () => {
|
||||
expect(Object.keys(LEGACY_SEEN).sort()).toEqual(
|
||||
Object.keys(CORE_ONBOARDING_STEPS).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyOrder,
|
||||
isPluginChoice,
|
||||
resolvePluginChoices,
|
||||
type ChoicePlugin,
|
||||
} from "@/types/plugin-onboarding";
|
||||
|
||||
const plugins: ChoicePlugin[] = [
|
||||
{ id: "base", dependencies: [] },
|
||||
{ id: "mid", dependencies: ["base"] },
|
||||
{ id: "top", dependencies: ["mid"] },
|
||||
{ id: "solo", dependencies: [] },
|
||||
];
|
||||
|
||||
describe("resolvePluginChoices", () => {
|
||||
it("leaves consistent choices alone", () => {
|
||||
const choices = {
|
||||
base: "enabled",
|
||||
mid: "enabled",
|
||||
top: "disabled",
|
||||
solo: "remove",
|
||||
} as const;
|
||||
const result = resolvePluginChoices(plugins, choices);
|
||||
expect(result.choices).toEqual(choices);
|
||||
expect(result.adjustments).toEqual([]);
|
||||
});
|
||||
|
||||
it("enables the whole chain under an enabled plugin", () => {
|
||||
const result = resolvePluginChoices(plugins, {
|
||||
base: "remove",
|
||||
mid: "disabled",
|
||||
top: "enabled",
|
||||
});
|
||||
expect(result.choices).toEqual({
|
||||
base: "enabled",
|
||||
mid: "enabled",
|
||||
top: "enabled",
|
||||
});
|
||||
expect(result.adjustments).toEqual([
|
||||
{ id: "base", from: "remove", to: "enabled", requiredBy: ["mid"] },
|
||||
{ id: "mid", from: "disabled", to: "enabled", requiredBy: ["top"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a dependency of a disabled plugin instead of removing it", () => {
|
||||
const result = resolvePluginChoices(plugins, {
|
||||
base: "remove",
|
||||
mid: "disabled",
|
||||
});
|
||||
expect(result.choices.base).toBe("disabled");
|
||||
});
|
||||
|
||||
it("lets a removed plugin take nothing with it", () => {
|
||||
const result = resolvePluginChoices(plugins, {
|
||||
base: "remove",
|
||||
mid: "remove",
|
||||
top: "remove",
|
||||
});
|
||||
expect(result.adjustments).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores unknown dependencies and survives cycles", () => {
|
||||
const cyclic: ChoicePlugin[] = [
|
||||
{ id: "a", dependencies: ["b", "ghost"] },
|
||||
{ id: "b", dependencies: ["a"] },
|
||||
];
|
||||
const result = resolvePluginChoices(cyclic, { a: "enabled", b: "remove" });
|
||||
expect(result.choices).toEqual({ a: "enabled", b: "enabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOrder", () => {
|
||||
it("enables dependencies first and removes dependents first", () => {
|
||||
const order = applyOrder(plugins, {
|
||||
base: "enabled",
|
||||
mid: "enabled",
|
||||
top: "enabled",
|
||||
});
|
||||
expect(order.enable).toEqual(["base", "mid", "top"]);
|
||||
|
||||
const removal = applyOrder(plugins, {
|
||||
base: "remove",
|
||||
mid: "remove",
|
||||
top: "remove",
|
||||
solo: "disabled",
|
||||
});
|
||||
expect(removal.remove).toEqual(["top", "mid", "base"]);
|
||||
expect(removal.disable).toEqual(["solo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginChoice", () => {
|
||||
it("accepts only the three choices", () => {
|
||||
expect(isPluginChoice("enabled")).toBe(true);
|
||||
expect(isPluginChoice("remove")).toBe(true);
|
||||
expect(isPluginChoice("delete")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sanitizeUiPreferences,
|
||||
UI_PREFERENCES_VERSION,
|
||||
} from "@/types/ui-preferences";
|
||||
import { LEGACY_SEEN } from "@/types/onboarding";
|
||||
|
||||
describe("defaultUiPreferences", () => {
|
||||
it("starts every user on balanced with no overrides", () => {
|
||||
@@ -16,9 +17,10 @@ describe("defaultUiPreferences", () => {
|
||||
expect(defaults.preset).toBe("balanced");
|
||||
expect(defaults.overrides).toEqual({});
|
||||
expect(defaults.onboarding).toEqual({
|
||||
completedVersion: 0,
|
||||
seen: {},
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,9 +38,10 @@ describe("sanitizeUiPreferences", () => {
|
||||
preset: "simple" as const,
|
||||
overrides: { hostList: { density: "compact" as const, showTags: true } },
|
||||
onboarding: {
|
||||
completedVersion: 1,
|
||||
seen: { welcome: 1, "remote-desktop:guacd": 2 },
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
},
|
||||
};
|
||||
expect(sanitizeUiPreferences(valid)).toEqual(valid);
|
||||
@@ -56,11 +59,28 @@ describe("sanitizeUiPreferences", () => {
|
||||
|
||||
it("ignores a malformed onboarding block", () => {
|
||||
expect(sanitizeUiPreferences({ onboarding: "done" }).onboarding).toEqual({
|
||||
completedVersion: 0,
|
||||
seen: {},
|
||||
completedAt: null,
|
||||
skipped: false,
|
||||
baselinePending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates the old completedVersion shape to the legacy seen map", () => {
|
||||
const onboarding = sanitizeUiPreferences({
|
||||
onboarding: { completedVersion: 2, completedAt: null, skipped: true },
|
||||
}).onboarding;
|
||||
expect(onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect(onboarding.baselinePending).toBe(true);
|
||||
expect(onboarding.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an old never-completed shape as new", () => {
|
||||
expect(
|
||||
sanitizeUiPreferences({ onboarding: { completedVersion: 0 } }).onboarding
|
||||
.seen,
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeUiOverrides", () => {
|
||||
|
||||
Reference in new issue
Block a user