mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -18,6 +18,10 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { PluginManageError } from "../../plugins/manage.js";
|
||||
import {
|
||||
isPluginChoice,
|
||||
type PluginChoice,
|
||||
} from "../../../types/plugin-onboarding.js";
|
||||
import {
|
||||
findField,
|
||||
getAllSettings,
|
||||
@@ -398,6 +402,131 @@ async function refuseOnLinkedDesktop(res: Response): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/onboarding:
|
||||
* get:
|
||||
* summary: List plugins for the onboarding plugin picker
|
||||
* description: Every installed plugin with its category, hard dependencies, current state and the onboarding defaults from the bundled index (recommended, consent). pending is true on a fresh install until an admin confirms the picker. managedByLinkedServer is true on a desktop linked to a server, where the picker is not shown.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The picker's plugin list.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 500:
|
||||
* description: The list could not be read.
|
||||
*/
|
||||
router.get(
|
||||
"/onboarding",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { describeOnboardingPlugins } =
|
||||
await import("../../plugins/onboarding.js");
|
||||
res.json(await describeOnboardingPlugins());
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to list plugins for onboarding",
|
||||
"plugin_onboarding_list",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/onboarding/apply:
|
||||
* post:
|
||||
* summary: Apply the onboarding plugin picker
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [choices]
|
||||
* properties:
|
||||
* choices:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: string
|
||||
* enum: [enabled, disabled, remove]
|
||||
* dryRun:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What was enabled, disabled, removed or failed.
|
||||
* 400:
|
||||
* description: The choices are not valid.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: Plugins on a linked desktop are managed by the server.
|
||||
* 500:
|
||||
* description: The choices could not be applied.
|
||||
*/
|
||||
router.post(
|
||||
"/onboarding/apply",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const { choices, dryRun } = req.body ?? {};
|
||||
if (
|
||||
!choices ||
|
||||
typeof choices !== "object" ||
|
||||
Array.isArray(choices) ||
|
||||
!Object.values(choices).every(isPluginChoice) ||
|
||||
(dryRun !== undefined && typeof dryRun !== "boolean")
|
||||
) {
|
||||
res.status(400).json({ error: "Invalid plugin choices" });
|
||||
return;
|
||||
}
|
||||
if (!dryRun && (await refuseOnLinkedDesktop(res))) return;
|
||||
|
||||
try {
|
||||
const { applyOnboardingChoices } =
|
||||
await import("../../plugins/onboarding.js");
|
||||
const result = await applyOnboardingChoices(
|
||||
choices as Record<string, PluginChoice>,
|
||||
{ dryRun: dryRun === true },
|
||||
);
|
||||
if (!dryRun) {
|
||||
for (const id of result.enabled) {
|
||||
await auditPluginAction(req, "enable_plugin", id, {
|
||||
via: "onboarding",
|
||||
});
|
||||
}
|
||||
for (const id of result.disabled) {
|
||||
await auditPluginAction(req, "disable_plugin", id, {
|
||||
via: "onboarding",
|
||||
});
|
||||
}
|
||||
for (const id of result.removed) {
|
||||
await auditPluginAction(req, "uninstall_plugin", id, {
|
||||
via: "onboarding",
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to apply plugin choices",
|
||||
"plugin_onboarding_apply",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/state:
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
defaultUiPreferences,
|
||||
sanitizeOnboarding,
|
||||
sanitizeUiPreferences,
|
||||
UI_ONBOARDING_VERSION,
|
||||
type UiPreferences,
|
||||
} from "../../../types/ui-preferences.js";
|
||||
import { LEGACY_SEEN, mergeSeen } from "../../../types/onboarding.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -23,8 +24,8 @@ const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* Existing users must never be ambushed by onboarding. A user who never
|
||||
* finished it and registered more than a day ago predates this feature, so
|
||||
* they are handed a completed onboarding state. This also repairs rows that an
|
||||
* earlier PUT wrote with the not-completed default.
|
||||
* they count as having seen every core step, and the client silently marks
|
||||
* the plugin steps they have now as seen too (baselinePending).
|
||||
*/
|
||||
export function withOnboardingBackfill(
|
||||
preferences: UiPreferences,
|
||||
@@ -35,13 +36,21 @@ export function withOnboardingBackfill(
|
||||
Number.isFinite(registeredMs) &&
|
||||
Date.now() - registeredMs < NEW_ACCOUNT_WINDOW_MS;
|
||||
|
||||
if (isNewAccount || preferences.onboarding.completedAt) return preferences;
|
||||
const { onboarding } = preferences;
|
||||
if (
|
||||
isNewAccount ||
|
||||
onboarding.completedAt ||
|
||||
Object.keys(onboarding.seen).length > 0
|
||||
) {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
onboarding: {
|
||||
...preferences.onboarding,
|
||||
completedVersion: UI_ONBOARDING_VERSION,
|
||||
...onboarding,
|
||||
seen: { ...LEGACY_SEEN },
|
||||
baselinePending: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -51,7 +60,7 @@ export function withOnboardingBackfill(
|
||||
* /ui-preferences:
|
||||
* get:
|
||||
* summary: Get the UI complexity preferences for the current user
|
||||
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state. A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered before this feature existed are returned an already-completed onboarding state so they are never shown the first-run flow.
|
||||
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state (which onboarding steps they have seen, by version). A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered more than a day ago and never finished onboarding are returned as having seen every core step, so they are never shown the first-run flow.
|
||||
* tags:
|
||||
* - UI Preferences
|
||||
* responses:
|
||||
@@ -89,7 +98,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* /ui-preferences:
|
||||
* put:
|
||||
* summary: Update the UI complexity preferences for the current user
|
||||
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them.
|
||||
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them. Onboarding seen versions are merged per step and never go down, so two open tabs cannot undo each other.
|
||||
* tags:
|
||||
* - UI Preferences
|
||||
* requestBody:
|
||||
@@ -164,10 +173,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
// Must come after the body spread, or a raw overrides payload would
|
||||
// clobber the merge above.
|
||||
overrides: mergedOverrides,
|
||||
onboarding: {
|
||||
...base.onboarding,
|
||||
...((body.onboarding as Record<string, unknown>) ?? {}),
|
||||
},
|
||||
onboarding: mergeOnboarding(base, body.onboarding),
|
||||
});
|
||||
|
||||
await repository.upsert(userId, JSON.stringify(merged));
|
||||
@@ -182,4 +188,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
function mergeOnboarding(base: UiPreferences, patch: unknown) {
|
||||
if (!patch || typeof patch !== "object") return base.onboarding;
|
||||
const incoming = sanitizeOnboarding({
|
||||
...base.onboarding,
|
||||
...(patch as Record<string, unknown>),
|
||||
seen: (patch as Record<string, unknown>).seen ?? {},
|
||||
});
|
||||
return {
|
||||
...incoming,
|
||||
seen: mergeSeen(base.onboarding.seen, incoming.seen),
|
||||
completedAt: incoming.completedAt ?? base.onboarding.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default router;
|
||||
Reference in new issue
Block a user