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
+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