mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move snippets backend and tables into a plugin (B2)
This commit is contained in:
1 parent
fdedca39fd
commit
e07221eaf3
51 files changed
+28426
-3553
No files matched your search
@@ -28,7 +28,6 @@ import {
|
||||
createCurrentSessionRecordingRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSharedHostSecretsRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentTermixIdentityCaRepository,
|
||||
createCurrentTermixIdentityRepository,
|
||||
@@ -93,7 +92,8 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentSnippetRepository().deleteByUserId(userId);
|
||||
// snippets, snippet_folders and snippet_access cascade on the user's
|
||||
// refUser() foreign key, as the snippets plugin's adopted tables.
|
||||
|
||||
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
@@ -1941,383 +1940,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
// SNIPPET SHARING
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/share:
|
||||
* post:
|
||||
* summary: Share a snippet
|
||||
* description: Shares a snippet with a user or role.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.post(
|
||||
"/snippet/:id/share",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("snippets.share"),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId)) {
|
||||
return res.status(400).json({ error: "Invalid snippet ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
targetType = "user",
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
} = req.body;
|
||||
|
||||
if (!["user", "role"].includes(targetType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
||||
}
|
||||
|
||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target user ID is required when sharing with user" });
|
||||
}
|
||||
if (targetType === "role" && !targetRoleId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target role ID is required when sharing with role" });
|
||||
}
|
||||
|
||||
const snippet = await createCurrentSnippetRepository().findOwnedById(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
|
||||
if (!snippet) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
if (targetType === "user") {
|
||||
const targetUser =
|
||||
await createCurrentUserRepository().findById(targetUserId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
} else {
|
||||
const targetRole =
|
||||
await createCurrentRoleRepository().findRoleById(targetRoleId);
|
||||
if (!targetRole) {
|
||||
return res.status(404).json({ error: "Target role not found" });
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: string | null = null;
|
||||
if (
|
||||
durationHours &&
|
||||
typeof durationHours === "number" &&
|
||||
durationHours > 0
|
||||
) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
const accessGrant =
|
||||
await createCurrentRbacAccessRepository().upsertSnippetAccess({
|
||||
snippetId,
|
||||
grantedBy: userId,
|
||||
expiresAt,
|
||||
...(targetType === "user"
|
||||
? { targetType: "user" as const, targetUserId: targetUserId! }
|
||||
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
|
||||
});
|
||||
|
||||
if (!accessGrant.created) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Snippet access updated",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.success("Snippet shared successfully", {
|
||||
operation: "rbac_snippet_share",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Snippet shared successfully with ${targetType}`,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share snippet", error, {
|
||||
operation: "share_snippet",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to share snippet" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/access/{accessId}:
|
||||
* delete:
|
||||
* summary: Revoke snippet access
|
||||
* description: Revokes a user's or role's access to a snippet.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet-folder/share:
|
||||
* post:
|
||||
* summary: Share every snippet in a folder
|
||||
* description: Grants view access to each owned snippet in the folder (and its subfolders) to the given users or roles.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* folder:
|
||||
* type: string
|
||||
* targets:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [user, role]
|
||||
* id:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: integer
|
||||
* durationHours:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder shared.
|
||||
* 404:
|
||||
* description: A target was not found.
|
||||
*/
|
||||
router.post(
|
||||
"/snippet-folder/share",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("snippets.share"),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { folder, durationHours } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(folder)) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
const targets = parseShareTargets(req.body ?? {});
|
||||
if (!targets) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
for (const target of targets) {
|
||||
const found =
|
||||
target.type === "user"
|
||||
? await userRepository.findById(target.id as string)
|
||||
: await roleRepository.findRoleById(target.id as number);
|
||||
if (!found) {
|
||||
return res.status(404).json({
|
||||
error: `Target ${target.type} not found`,
|
||||
targetId: target.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const snippetsInFolder =
|
||||
await createCurrentSnippetRepository().listOwnedSnippetsInFolder(
|
||||
userId,
|
||||
folder,
|
||||
);
|
||||
const expiresAt = expiryFromDuration(durationHours);
|
||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||
|
||||
for (const snippet of snippetsInFolder) {
|
||||
for (const target of targets) {
|
||||
if (target.type === "user" && target.id === userId) continue;
|
||||
await rbacAccessRepository.upsertSnippetAccess({
|
||||
snippetId: snippet.id,
|
||||
grantedBy: userId,
|
||||
expiresAt,
|
||||
...(target.type === "user"
|
||||
? {
|
||||
targetType: "user" as const,
|
||||
targetUserId: target.id as string,
|
||||
}
|
||||
: {
|
||||
targetType: "role" as const,
|
||||
targetRoleId: target.id as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success("Snippet folder shared successfully", {
|
||||
operation: "rbac_snippet_folder_share",
|
||||
userId,
|
||||
folder,
|
||||
snippetsShared: snippetsInFolder.length,
|
||||
targets: targets.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
expiresAt,
|
||||
snippetsShared: snippetsInFolder.length,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share snippet folder", error, {
|
||||
operation: "share_snippet_folder",
|
||||
folder,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to share snippet folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/snippet/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
? req.params.accessId[0]
|
||||
: req.params.accessId;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const accessId = parseInt(accessIdParam, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId) || isNaN(accessId)) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const snippet = await createCurrentSnippetRepository().findOwnedById(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
|
||||
if (!snippet) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
await createCurrentRbacAccessRepository().revokeSnippetAccess(
|
||||
accessId,
|
||||
snippetId,
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "Snippet access revoked" });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke snippet access", error, {
|
||||
operation: "revoke_snippet_access",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to revoke access" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/access:
|
||||
* get:
|
||||
* summary: Get snippet access list
|
||||
* description: Retrieves the list of users and roles with access to a snippet.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.get(
|
||||
"/snippet/:id/access",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId)) {
|
||||
return res.status(400).json({ error: "Invalid snippet ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const snippet = await createCurrentSnippetRepository().findOwnedById(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
|
||||
if (!snippet) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
const accessList =
|
||||
await createCurrentRbacAccessRepository().listSnippetAccess(snippetId);
|
||||
|
||||
res.json({ accessList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get snippet access list", error, {
|
||||
operation: "get_snippet_access_list",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get access list" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/shared-snippets:
|
||||
* get:
|
||||
* summary: Get shared snippets
|
||||
* description: Retrieves snippets shared with the current user.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.get(
|
||||
"/shared-snippets",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
|
||||
try {
|
||||
const roleIds =
|
||||
await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
const sharedSnippets =
|
||||
await createCurrentRbacAccessRepository().listSharedSnippets(
|
||||
userId,
|
||||
roleIds,
|
||||
);
|
||||
|
||||
res.json({ sharedSnippets });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared snippets", error, {
|
||||
operation: "get_shared_snippets",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get shared snippets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/host-access/{hostId}/auth/{protocol}:
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
export interface SnippetExecutionResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SnippetHostVars {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
function replaceVar(content: string, name: string, value?: string): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
|
||||
* backend are separate builds, so this is kept as a small standalone copy
|
||||
* rather than a shared package for one pure function.
|
||||
*/
|
||||
export function resolveSnippetCommand(
|
||||
content: string,
|
||||
host: SnippetHostVars | null,
|
||||
inputValues: Record<string, string> = {},
|
||||
): string {
|
||||
let resolved = content;
|
||||
|
||||
resolved = replaceVar(resolved, "HOST", host?.ip);
|
||||
resolved = replaceVar(resolved, "USER", host?.username);
|
||||
resolved = replaceVar(
|
||||
resolved,
|
||||
"PORT",
|
||||
host?.port !== undefined ? String(host.port) : undefined,
|
||||
);
|
||||
resolved = replaceVar(resolved, "NAME", host?.name);
|
||||
|
||||
resolved = resolved.replace(
|
||||
INPUT_PATTERN,
|
||||
(
|
||||
fullMatch,
|
||||
braceDigits: string | undefined,
|
||||
_label,
|
||||
plainDigits: string | undefined,
|
||||
) => {
|
||||
const key = `INPUT_${braceDigits ?? plainDigits}`;
|
||||
return key in inputValues ? inputValues[key] : fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getSnippetExecutionTimeoutMs(
|
||||
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
|
||||
): number | undefined {
|
||||
if (value === undefined || value.trim() === "") return undefined;
|
||||
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
|
||||
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
export function createSnippetExecutionResult(
|
||||
exitCode: number | null,
|
||||
output: string,
|
||||
errorOutput: string,
|
||||
): SnippetExecutionResult {
|
||||
const success = exitCode === 0 || (exitCode === null && !errorOutput);
|
||||
return {
|
||||
success,
|
||||
output,
|
||||
...(errorOutput ? { error: errorOutput } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
type SnippetReorderRequestBody = {
|
||||
snippets?: unknown;
|
||||
updates?: unknown;
|
||||
};
|
||||
|
||||
export function extractSnippetReorderUpdates(
|
||||
body: unknown,
|
||||
): SnippetReorderUpdate[] | null {
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = body as SnippetReorderRequestBody;
|
||||
// Keep accepting the legacy `updates` key so older clients do not break
|
||||
// while the web and desktop helpers converge on `snippets`.
|
||||
const snippetsUpdates = Array.isArray(payload.snippets)
|
||||
? payload.snippets
|
||||
: Array.isArray(payload.updates)
|
||||
? payload.updates
|
||||
: null;
|
||||
|
||||
if (!snippetsUpdates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snippetsUpdates as SnippetReorderUpdate[];
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,8 +15,6 @@ import {
|
||||
hosts,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
snippets,
|
||||
snippetFolders,
|
||||
vaultProfiles,
|
||||
dashboardServiceLinks,
|
||||
homepageItems,
|
||||
@@ -95,12 +93,6 @@ export function registerCoreSyncEntities(): void {
|
||||
references: [CREDENTIAL_REFERENCE],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "snippetFolders",
|
||||
table: snippetFolders,
|
||||
order: 40,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "hosts",
|
||||
table: hosts,
|
||||
@@ -112,12 +104,6 @@ export function registerCoreSyncEntities(): void {
|
||||
encryptedFields: ["ssh_data"],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "snippets",
|
||||
table: snippets,
|
||||
order: 60,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "dashboardServiceLinks",
|
||||
table: dashboardServiceLinks,
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
|
||||
interface UserPasswordResetRoutesDeps {
|
||||
authManager: AuthManager;
|
||||
@@ -73,9 +73,11 @@ export async function resetUserPassword(
|
||||
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||
await createCurrentSnippetRepository().deleteByUserId(userId);
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
// A plugin holding this user's data (snippets and anything else keyed by
|
||||
// refUser()) wipes it on this topic, since the user row itself survives.
|
||||
pluginEvents.emit(TOPICS.userDataWiped, { userId });
|
||||
|
||||
const { UserKeyManager } = await import("../../utils/user-keys.js");
|
||||
await UserKeyManager.getInstance().rotateUserDEK(userId);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function validateDefaultsJson(value: string): boolean {
|
||||
* /user-preferences:
|
||||
* get:
|
||||
* summary: Get preferences for the current user
|
||||
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are legacy fields, kept here read-only for backward compatibility. The authoritative copy is GET /host-sidebar/preferences.
|
||||
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences. foldersCollapsed is likewise legacy and read-only; its authoritative copy is the snippets plugin's own user settings.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
@@ -160,7 +160,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* /user-preferences:
|
||||
* put:
|
||||
* summary: Update preferences for the current user
|
||||
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign.
|
||||
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. foldersCollapsed is no longer accepted either, now that it is a snippets plugin user setting.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* requestBody:
|
||||
@@ -259,11 +259,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
rdpDefaults?: string | null;
|
||||
terminalMacros?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme,
|
||||
// foldersCollapsed are no longer writable here -- they moved to
|
||||
// /host-sidebar/preferences as of the sidebar redesign. The columns stay
|
||||
// in the table (read once as a migration seed by that route) but this
|
||||
// endpoint silently ignores them if a stale client still sends them.
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||
// sidebar redesign. The columns stay in the table (read once as a
|
||||
// migration seed by that route) but this endpoint silently ignores them if
|
||||
// a stale client still sends them. foldersCollapsed similarly moved out,
|
||||
// to the snippets plugin's own user settings.
|
||||
|
||||
const updates: UserPreferenceUpdate = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
||||
Reference in new issue
Block a user