diff --git a/docs/assets/images/export-template.png b/docs/assets/images/export-template.png new file mode 100644 index 000000000..383af2ab3 Binary files /dev/null and b/docs/assets/images/export-template.png differ diff --git a/docs/assets/images/folder-setup-dialog.png b/docs/assets/images/folder-setup-dialog.png new file mode 100644 index 000000000..83ea46355 Binary files /dev/null and b/docs/assets/images/folder-setup-dialog.png differ diff --git a/docs/assets/images/intialise-templates.png b/docs/assets/images/intialise-templates.png new file mode 100644 index 000000000..a87666061 Binary files /dev/null and b/docs/assets/images/intialise-templates.png differ diff --git a/docs/assets/images/read-only-templates-portal.png b/docs/assets/images/read-only-templates-portal.png new file mode 100644 index 000000000..015b4fdbc Binary files /dev/null and b/docs/assets/images/read-only-templates-portal.png differ diff --git a/docs/assets/images/template-button.png b/docs/assets/images/template-button.png index 9dc394dc4..9f0be15d8 100644 Binary files a/docs/assets/images/template-button.png and b/docs/assets/images/template-button.png differ diff --git a/docs/configure/local.md b/docs/configure/local.md index 3f9bc3bfb..5b69ea004 100644 --- a/docs/configure/local.md +++ b/docs/configure/local.md @@ -127,6 +127,50 @@ or if using Windows: - `docker run -d -p 8080:3000 -v %CD%/test.env:/app/.env owasp/threat-dragon:stable` +## Configuring Templates for Desktop + +Navigate to the cog icon in the navigation bar and click **Manage Templates**. + +![Manage template image]({{ '/assets/images/manage-template.png' + | relative_url }}){: style="max-width: 400px; width: 100%;" } + +If templates have not been configured before, you will be presented with a setup dialog offering three options: + +![Folder Setup Dialog Box]({{ '/assets/images/folder-setup-dialog.png' + | relative_url }}){: style="max-width: 400px; width: 100%;" } + +- **Use default location** — creates a `templates` folder in the application data directory +(`AppData/Roaming/Threat Dragon/templates` on Windows) +- **Choose custom location** — opens a folder browser so you can select any folder on your filesystem +- **Select existing template folder** — point to a folder that already contains a `template_info.json` index file + +For the default and custom location options, Threat Dragon will automatically create a `template_info.json` +index file in the selected folder if one does not already exist. + +For the existing folder option, the folder must already contain a valid `template_info.json` file. +If the file is not found, setup will fail with an error. + +### Folder Structure + +All template files are stored flat in the configured folder alongside the index file: + +```plaintext +templates/ +├── template_info.json ← index file listing all templates +├── my-template-abc123.json ← template model file +└── another-template-xyz.json +``` + +The `template_info.json` file is managed automatically by Threat Dragon — you do not need to edit it manually. + +### Changing the Template Folder Location + +The configured template folder path is persisted in `AppData/Roaming/Threat Dragon/templates-path.txt`. +To change the template storage location, delete this file and restart Threat Dragon and +you will be prompted to configure a new location on next launch. + +Note that templates from the previous folder will not be migrated automatically. + ### Example production local environment Important: this example file contains test values, do not use these values for anything other than short-term tests. diff --git a/docs/usage/templates.md b/docs/usage/templates.md index b8c1f9c45..0fa7a5fbd 100644 --- a/docs/usage/templates.md +++ b/docs/usage/templates.md @@ -13,7 +13,7 @@ quickly create new threat models based on pre-defined structures. **Note**: The template feature is currently available for GitHub repositories and for local users who have templates stored in their local file system. Support for Atlassian Bitbucket, GitLab, Google Drive, -and desktop is coming in future releases. +is coming in future releases. ## Create a new model from a template @@ -50,7 +50,7 @@ where you can enter general information about your model. The template's diagrams, components, and threats will already be populated as a starting point. The name that you provide for the model will be used as the file name within the repository. -## Loading a template from a local file +### Loading a template from a local file In addition to organisation templates, you can also start from a local template file. This is useful when you have received a template file from a colleague. @@ -71,10 +71,7 @@ The imported template's diagrams, components, and threats will be used as a star to your organisation's template repository. Only administrators can add templates to the shared template gallery. -## Exporting an existing model as a template - -**Note** : This feature is currently only available for web-based local sessions and GitHub authenticated -sessions. Support for additional providers will be added in a future releases. +### Exporting an existing model as a template If you have created a threat model that would be useful as a template for others, you can export it as a template file that can be shared or imported later. @@ -106,7 +103,7 @@ The exported template file can then be: **Note**: The export removes any organisation-specific information (such as repository paths) and generates new unique identifiers, making it suitable for use as a reusable template. -## Managing Templates (Administrators) +### Managing Templates (Administrators) Users with **push** or **admin** permissions on the template repository (`GITHUB_CONTENT_REPO`) are considered administrators and can manage the organisation's shared template gallery. @@ -119,7 +116,7 @@ which takes you to the Manage Templates page where you can add, edit, and delete | relative_url }}){: style="max-width: 400px; width: 100%;" } For information on configuring the template repository, see the -[GitHub configuration guide](../configure/github.md#template-repository-configuration). +[GitHub configuration guide]({{ '/configure/github.html#template-repository-configuration'| relative_url }}). ### Bootstrapping the Template Repository @@ -179,3 +176,143 @@ template are not affected, but the template will no longer be available for crea **Session timeout**: When logging in to an external drive or repository, be aware that sessions can time out. This timeout length varies by provider; if this is a problem, keep the session alive using a tab in the same browser window. + +## Using the Desktop application + +![Start Button]({{ '/assets/images/start.png'| relative_url }}) + +The threat dragon desktop application can be configured with templates stored in local file system. + +For information on how to configure templates in your desktop deployment, see the +[Local configuration guide]({{ '/configure/local.html#configuring-templates-for-desktop' | relative_url }}) + +![Template button image]({{ '/assets/images/template-button.png' +| relative_url }}){: .float-right style="max-width: 170px; width: 100%;" } + +To create a new threat model from a template, go to the Welcome page and click the +**Create model from a Template** button. + +You will then be presented with the Template Gallery showing all available templates. +Browse or search for a template that fits your needs, then click on the template card to select it. + +![Template Gallery]({{ '/assets/images/template-gallery.png' + | relative_url }}){: style="max-width: 500px; width: 100%;" } + +After selecting a template, you will be taken to the threat model edit screen. +The template's diagrams, components, and threats will be pre-populated, +but metadata fields such as owner, reviewer, and contributors will be empty for you to fill in. + +### Loading a template from a file + +In addition to templates stored in your configured folder, you can also load a template directly +from any file on your filesystem. This is useful when you have received a template file from a colleague +that you want to use as a one-off starting point without adding it to your gallery. + +**Note**: Template files use a different schema to standard threat model files and are not interchangeable. +To use an existing model as a starting point, first export it as a template using the **Export as Template** option. + +From the Template Gallery, click the **Start from a Local Template** button to browse for a template file. +![Local template image]({{ '/assets/images/local-template.png' + | relative_url }}){: style="max-width: 500px; width: 100%;" } + +The file will be validated to ensure it is a properly formatted Threat Dragon template. +Once loaded, you will be taken to the threat model edit screen with the template's diagrams, +components,and threats pre-populated. + +**Note**: This creates a new model based on the selected file. It does not add the template +to your configured template folder. + +### Exporting an existing model as a template on Desktop + +If you have created a threat model that would be useful as a template for others, +you can export it as a template file that can be shared or imported later. + +From your threat model's details page, click on the manage dropdown and select **Export as Template**. +You will be taken to the export template page where you can review and customise the template details. + +![Export template image]({{ '/assets/images/export-template-button.png' + | relative_url }}){: style="max-width: 400px; width: 100%;" } + +![Export template page]({{ '/assets/images/export-template-page.png' + | relative_url }}){: style="max-width: 1000px; width: 100%;" } + +On the export template page, you can: + +- Review the template name and description +- Add or modify tags for easier searching + +Once you are satisfied with the template details, click **Save Template** to download the +template file to your local filesystem. + +The exported template file can then be: + +- Shared directly with colleagues so they can use it as a one-off starting point, without needing to add it to their gallery +- Imported into your own template gallery using the 'Manage Templates' portal. +- Used as a local backup or starting point for future models + +**Note**: The export removes any model-specific information and generates new unique identifiers, +making it suitable for use as a reusable template. + +### Managing Templates + +On the desktop application, all users have full administrator access to the template gallery. + +Click the **cog icon** in the navigation bar and select **Manage Templates** to open the +Manage Templates page,where you can add, edit, and delete templates. + +![Manage template image]({{ '/assets/images/manage-template.png' + | relative_url }}){: style="max-width: 400px; width: 100%;" } + +**Note**: You can only add, edit, or delete templates if you have write access to the configured template folder. +If the folder is read-only, the Manage Templates page will open in read-only mode and modification options will be hidden. + +![Manage template image]({{ '/assets/images/read-only-templates-portal.png' + | relative_url }}){: style="max-width: 700px; width: 100%;"} + +For information on configuring the template folder, see the +[Desktop Template Configuration Guide]({{ '/configure/local.html#configuring-templates-for-desktop' | relative_url }}). + +### Bootstrapping the Template Folder + +On the desktop application, template storage is initialised automatically when you configure a folder location. +Simply navigate to Manage Templates, select a setup option, and Threat Dragon will create the template_info.json +index file in the chosen folder automatically. + +No separate initialisation step is required. + +### Importing Templates to the Gallery on Desktop + +1. Obtain a template file (either exported from an existing model or received from a colleague) +2. Navigate to the Manage Templates portal +3. Click the **Add New Template** button + ![import-templates]({{ '/assets/images/import-template-button.png' + | relative_url }}){: style="max-width: 170px; width: 100%;"} +4. Select the template file from your local filesystem + +The template will now appear in the Template Gallery. + +### Updating Template Metadata on Desktop + +You can update a template's name, description, or tags without modifying the template content. + +1. Navigate to the Manage Templates page +2. Find the template you want to update +3. Click the kebab menu on the template card and select **Edit** +4. Modify the name, description, or tags as needed + +![Kebab-Menu]({{ '/assets/images/kebab-menu.png' | relative_url }}) + +![Edit-Template]({{ '/assets/images/edit-template.png' + | relative_url }}){: style="max-width: 400px; width: 100%;" } + +### Deleting Templates on Desktop + +You can delete templates from your template gallery. + +1. Navigate to the Manage Templates page +2. Find the template you want to delete +3. Click the kebab menu on the template card and select **Delete** +4. Confirm the deletion + +**Warning**: Deleting a template cannot be undone. Existing threat models created from the +template are not affected, but the template will no longer be available for creating new models. diff --git a/package-lock.json b/package-lock.json index 0cbf4d296..33f64e4c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,6 +1413,7 @@ "integrity": "sha512-OmwPKV8c5ecLqo+EkytN7oUeYfNmRI4uOXGIR1ybP7AK5Zz+l9R0dGfoadEuwi1aZXAL0vwuhtq3p0OL3dfqHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18.20.0" }, @@ -1435,6 +1436,7 @@ "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.1.2", "loglevel": "^1.6.0", @@ -7186,6 +7188,7 @@ "integrity": "sha512-LTJt6Z/iDM0ne/4ytd3BykoPv9CuJ+CAILOzlwFeMGn4Mj02i4Bk2Rg9o/jeJ89f52hnv4OPmNjD0e8nzWAy5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", diff --git a/td.server/package-lock.json b/td.server/package-lock.json index 12e5aab99..2a8da4ad0 100644 --- a/td.server/package-lock.json +++ b/td.server/package-lock.json @@ -110,6 +110,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2778,6 +2779,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3442,6 +3444,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3767,6 +3770,7 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -4866,6 +4870,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5144,6 +5149,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", diff --git a/td.server/src/config/bearer.config.js b/td.server/src/config/bearer.config.js index 298831df3..3184af6af 100644 --- a/td.server/src/config/bearer.config.js +++ b/td.server/src/config/bearer.config.js @@ -49,6 +49,15 @@ const middleware = (req, res, next) => { } }; +const adminMiddleware = (req, res, next) => { + if (!req.user?.isAdmin) { + logger.warn(`User ${req.user?.id} attempted to access admin resource ${req.url}`); + return errors.forbidden(res, logger); + } + return next(); +}; + export default { - middleware + middleware, + adminMiddleware }; diff --git a/td.server/src/config/routes.config.js b/td.server/src/config/routes.config.js index 5c2efade5..b0e9289b1 100644 --- a/td.server/src/config/routes.config.js +++ b/td.server/src/config/routes.config.js @@ -10,6 +10,7 @@ import templateController from '../controllers/templateController.js'; import threatmodelController from '../controllers/threatmodelcontroller.js'; + /** * Routes that do **NOT** require authentication * Use with caution!!!! @@ -22,7 +23,7 @@ const unauthRoutes = (router) => { router.get('/healthz', healthcheck.healthz); router.get('/api/config', configController.config); router.get('/api/threatmodel/organisation', threatmodelController.organisation); - + router.get('/api/login/:provider', auth.login); router.get('/api/logout', auth.logout); @@ -39,12 +40,9 @@ const unauthRoutes = (router) => { const routes = (router) => { router.post('/api/logout', auth.logout); router.post('/api/token/refresh', auth.refresh); - // Template routes - router.post('/api/templates/bootstrap', templateController.bootstrapTemplateRepository);// bootstrap template repo + // Template routes + router.get('/api/templates/', templateController.listTemplates);// list all templates - router.post('/api/templates/import', templateController.importTemplate);// import a new template - router.delete('/api/templates/:id', templateController.deleteTemplate);// delete a template - router.put('/api/templates/:id', templateController.updateTemplate);// update template metadata router.get('/api/templates/:id/content', templateController.getTemplateContent);// get template content by id router.get('/api/threatmodel/repos', threatmodelController.repos); @@ -67,17 +65,34 @@ const routes = (router) => { router.get('/api/googleproviderthreatmodel/:file/data', googleProviderThreatmodelController.model); }; +/** + * Routes that require authentication and authorisation + * Use with caution!!!! + * @param {express.Router} router + * @returns {express.Router} + */ +const adminRoutes = (router) => { + router.post('/api/templates/import', templateController.importTemplate);// import a new template + router.delete('/api/templates/:id', templateController.deleteTemplate);// delete a template + router.put('/api/templates/:id', templateController.updateTemplate);// update template metadata + router.post('/api/templates/bootstrap', templateController.bootstrapTemplateRepository);// bootstrap template repo + +}; + + const config = (app) => { const router = express.Router(); unauthRoutes(router); - // routes protected by authorization - router.use(bearer.middleware); + router.use(bearer.middleware); routes(router); + router.use(bearer.adminMiddleware); + adminRoutes(router); app.use('/', router); }; + export default { config }; diff --git a/td.server/src/controllers/templateController.js b/td.server/src/controllers/templateController.js index fb246dd56..27ef47bb9 100644 --- a/td.server/src/controllers/templateController.js +++ b/td.server/src/controllers/templateController.js @@ -1,4 +1,4 @@ -import { badRequest, forbidden, notFound, serverError } from "./errors.js"; +import { badRequest, notFound, serverError } from "./errors.js"; import env from "../env/Env.js"; import loggerHelper from "../helpers/logger.helper.js"; @@ -26,7 +26,6 @@ const fetchTemplateMetadata = async (repository, accessToken) => { * - Normal operation: Returns array of template metadata * - NOT_CONFIGURED: GITHUB_CONTENT_REPO environment variable not set * - NOT_INITIALIZED: Repository exists but metadata file not created (admin can initialize, non-admins informed) - * - REPO_NOT_FOUND: Repository doesn't exist or is inaccessible (404) * @param {Object} req - Express request object * @param {Object} res - Express response object * @returns {Promise} @@ -38,8 +37,8 @@ const listTemplates = (req, res) => responseWrapper.sendResponseAsync(async () = if (!contentRepo) { return { templates: [], - repoStatus: "NOT_CONFIGURED", - canInitialize: false, + status: "NOT_CONFIGURED", + canWrite: false, message: "Template repository not configured. Set GITHUB_CONTENT_REPO environment variable." }; } @@ -53,8 +52,8 @@ const listTemplates = (req, res) => responseWrapper.sendResponseAsync(async () = return { templates: [], - repoStatus: "NOT_INITIALIZED", - canInitialize: req.user?.isAdmin || false, + status: "NOT_INITIALIZED", + canWrite: req.user?.isAdmin || false, message: req.user?.isAdmin ? "Template repository not initialized." : "Template repository not initialized. Contact administrator." @@ -68,11 +67,6 @@ const listTemplates = (req, res) => responseWrapper.sendResponseAsync(async () = }, req, res, logger); const importTemplate = async (req, res) => { - if (!req.user?.isAdmin) { - logger.warn(`Non-admin user attempted to import template: ${req.user?.username}`); - return forbidden(res, logger); - } - const repository = repositories.get(); const accessToken = req.provider.access_token; const { templateMetadata, model } = req.body; @@ -104,11 +98,6 @@ const importTemplate = async (req, res) => { }; const deleteTemplate = async (req, res) => { - if (!req.user?.isAdmin) { - logger.warn(`Non-admin user attempted to delete template: ${req.user?.username}`); - return forbidden(res, logger); - } - const repository = repositories.get(); const accessToken = req.provider.access_token; const { id } = req.params; @@ -138,11 +127,6 @@ const deleteTemplate = async (req, res) => { }; const updateTemplate = async (req, res) => { - if (!req.user?.isAdmin) { - logger.warn(`Non-admin user attempted to update template: ${req.user?.username}`); - return forbidden(res, logger); - } - const repository = repositories.get(); const accessToken = req.provider.access_token; const { id } = req.params; @@ -221,11 +205,6 @@ const getTemplateContent = (req, res) => { const bootstrapTemplateRepository = async (req, res) => { - if (!req.user?.isAdmin) { - logger.warn(`Non-admin user attempted to bootstrap template repository: ${req.user?.username}`); - return forbidden(res, logger); - } - const repository = repositories.get(); const accessToken = req.provider.access_token; const contentRepo = env.get().config.GITHUB_CONTENT_REPO; diff --git a/td.vue/package-lock.json b/td.vue/package-lock.json index 39c07e830..5a7719d7b 100644 --- a/td.vue/package-lock.json +++ b/td.vue/package-lock.json @@ -164,6 +164,7 @@ "resolved": "https://registry.npmjs.org/@antv/x6/-/x6-2.19.2.tgz", "integrity": "sha512-lr9sUAlrR7eSVANru8kUNZUqCRl7eOCgb36M61FMDonUYREwkHpRN+0zq+1egSSOdz3HHSDBjs7UMtGH1ZEg2w==", "license": "MIT", + "peer": true, "dependencies": { "@antv/x6-common": "^2.0.16", "@antv/x6-geometry": "^2.0.5", @@ -358,6 +359,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2365,6 +2367,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2800,6 +2803,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -5479,8 +5483,7 @@ "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/eslint": { "version": "8.56.12", @@ -5605,7 +5608,6 @@ "integrity": "sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" @@ -5669,7 +5671,6 @@ "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -5689,8 +5690,7 @@ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/lodash.flattendeep": { "version": "4.4.9", @@ -5698,7 +5698,6 @@ "integrity": "sha512-Oacs/ZMuMvVWkhMqvj+Spad457Beln5pnkauif+6s65fE2cSL7J7NoMfwkxjuQsOsr4DUCDH/iDbmuZo81Nypw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -5709,7 +5708,6 @@ "integrity": "sha512-SPI248FYnyd3jOxDeJq2vX2UKQnDzqacuqdeOVqwE1MPSk8gN8TA3FcHSMQWLlpBnuHgXvgKInvywbOFbidpJA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -5720,7 +5718,6 @@ "integrity": "sha512-l/GEj9Xp2DptsfFYZ1JUczg6W/6JGbbDi0mVK8urg8XLUMguNJ2L1ya0QJzMctrtlP9+t5lfyL4QLF6P9/6ssQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -5822,7 +5819,6 @@ "integrity": "sha512-84REEGT3lcgopvpkmGApzmU5UEG0valme5rQS/KGiguTkJ70/Au8UYZTyrzoZnY9svuX9351+1uvrRPzWDD/uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -5931,7 +5927,6 @@ "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -6562,6 +6557,7 @@ "integrity": "sha512-yTX7GVyM19tEbd+y5/gA6MkVKA6K61nVYHYAivD61Hx6odVFmQsaC3/R3cWAHM1P5oVKCevBbumPljbT+tFG2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-compilation-targets": "^7.12.16", "@soda/friendly-errors-webpack-plugin": "^1.8.0", @@ -6927,6 +6923,7 @@ "integrity": "sha512-r8YGOuqEWpAf2wGfgxfOL6Jce3WYOMcYji2qd8kuDe466ZsybHFeMryMJi6JrELOOI+MCA/8eFsSOx1KoJa7Dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-modules-commonjs": "^7.2.0", "@vue/component-compiler-utils": "^3.1.0", @@ -9367,6 +9364,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9496,6 +9494,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10484,6 +10483,7 @@ "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/transform": "^27.5.1", "@jest/types": "^27.5.1", @@ -11452,6 +11452,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -12115,7 +12116,6 @@ "integrity": "sha512-hq4rxE3NT5PlaEiVV39Z45d6MoFcQZG5dsgJqtAUeOz3408LEQAElToDkf9i5IYSCOmK0If/81dLg7nKxqPR0w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "camelcase-keys": "^3.0.0", "chalk": "^1.1.3", @@ -12135,7 +12135,6 @@ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12146,7 +12145,6 @@ "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12157,7 +12155,6 @@ "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -12175,7 +12172,6 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.0" } @@ -12186,7 +12182,6 @@ "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" @@ -12200,8 +12195,7 @@ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/cac/node_modules/indent-string": { "version": "3.2.0", @@ -12209,7 +12203,6 @@ "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -12220,7 +12213,6 @@ "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", @@ -12234,7 +12226,6 @@ "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pinkie-promise": "^2.0.0" }, @@ -12248,7 +12239,6 @@ "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "pify": "^2.0.0", @@ -12264,7 +12254,6 @@ "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "load-json-file": "^1.0.0", "normalize-package-data": "^2.3.2", @@ -12280,7 +12269,6 @@ "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "find-up": "^1.0.0", "read-pkg": "^1.0.0" @@ -12295,7 +12283,6 @@ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver" } @@ -12306,7 +12293,6 @@ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -12320,7 +12306,6 @@ "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.0" } @@ -12535,7 +12520,6 @@ "integrity": "sha512-U4E6A6aFyYnNW+tDt5/yIUKQURKXe3WMFPfX4FxrQFcwZ/R08AUk1xWcUtlr7oq6CV07Ji+aa69V2g7BSpblnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "camelcase": "^3.0.0", "map-obj": "^1.0.0" @@ -12550,7 +12534,6 @@ "integrity": "sha512-4nhGqUkc4BqbBBB4Q6zLuD7lzzrHYrjKGeYaEji/3tFR5VdJu9v+LilhGIVe8wxEJPPOeWo7eg8dwY13TZ1BNg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13709,6 +13692,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14104,6 +14088,7 @@ "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", @@ -14470,6 +14455,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@cypress/request": "^3.0.6", "@cypress/xvfb": "^1.2.4", @@ -15493,6 +15479,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -16075,6 +16062,7 @@ "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -16159,7 +16147,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -16173,7 +16160,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -16192,8 +16178,7 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { "version": "10.1.0", @@ -16201,7 +16186,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -16250,6 +16234,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.1", "extract-zip": "^2.0.0" @@ -16620,6 +16605,7 @@ "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -16927,6 +16913,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -17182,6 +17169,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -17288,6 +17276,7 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -17432,6 +17421,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -17472,6 +17462,7 @@ "integrity": "sha512-28sbtm4l4cOzoO1LtzQPxfxhQABararUb1JtqusQqObJpWX2e/gmVyeYVfepizPFne0Q5cILkYGiBoV36L12Wg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "eslint-utils": "^3.0.0", "natural-compare": "^1.4.0", @@ -22324,8 +22315,7 @@ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/is-weakmap": { "version": "2.0.2", @@ -22622,6 +22612,7 @@ "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -24564,7 +24555,8 @@ "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-beautify": { "version": "1.15.4", @@ -25211,7 +25203,6 @@ "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^2.2.0", @@ -25229,7 +25220,6 @@ "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "error-ex": "^1.2.0" }, @@ -25243,7 +25233,6 @@ "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-utf8": "^0.2.0" }, @@ -25739,7 +25728,6 @@ "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -29134,6 +29122,7 @@ "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -29208,6 +29197,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -32190,6 +32180,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -32378,6 +32369,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -34138,7 +34130,6 @@ "integrity": "sha512-j5uf6MJtMCfC4vBe5LFktSe4bGyNTBk7I2Kdri0jeLrcv5B9pWfxVa5JQpoxgtR8vaVB7bVxsWgnfQbX5wkhAA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -34951,6 +34942,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -35529,7 +35521,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -35782,6 +35775,7 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -36475,6 +36469,7 @@ "integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==", "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-sfc": "2.7.16", "csstype": "^3.1.0" @@ -36796,6 +36791,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -37999,6 +37995,7 @@ "integrity": "sha512-td7fYwgLSrky3fI1EuU5cneU4+pbH6GgOfuKNS1tNPcfdGinGELAqsb/BP4nnvZyKSG2i/xFGU7+n2PvZA8HJQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.9.0", "@webassemblyjs/helper-module-context": "1.9.0", @@ -38499,6 +38496,7 @@ "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" @@ -38538,6 +38536,7 @@ "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.2.tgz", "integrity": "sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==", "license": "MIT", + "peer": true, "peerDependencies": { "vue": "^2.0.0" } @@ -38930,7 +38929,6 @@ "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/jsonfile": "*", "@types/node": "*" @@ -38959,7 +38957,6 @@ "integrity": "sha512-M0txYEqqamBvJe4FEuqwWq1jd879sElF047BXSv2GRu4R1/iEBPYJHjn9KuL60Fkkpp/L1NMHTl7gW9i445edQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/ejs": "^3.0.5", "@types/fs-extra": "^11.0.1", @@ -39003,7 +39000,6 @@ "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -39130,7 +39126,6 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -39198,7 +39193,6 @@ "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">= 10" } @@ -39380,7 +39374,6 @@ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -39394,7 +39387,6 @@ "integrity": "sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", @@ -39470,7 +39462,6 @@ "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "mkdirp": "dist/cjs/src/bin.js" }, @@ -39493,8 +39484,7 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/wdio-electron-service/node_modules/pkg-dir": { "version": "4.2.0", @@ -39546,7 +39536,6 @@ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -39623,7 +39612,6 @@ "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.12.0" } @@ -39748,6 +39736,7 @@ "integrity": "sha512-UswBOjpWwk7ziGi9beZGX/XFrp4m1Ws0ni5HI9mzAkOlpKKKWhnX6i95pWQV6sPF4Urv4RJf8WXayHhTbzXzdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/aria-query": "^5.0.0", "@types/node": "^18.0.0", @@ -40674,6 +40663,7 @@ "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -41089,6 +41079,7 @@ "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } @@ -41952,7 +41943,6 @@ "integrity": "sha512-VO1u181msinhPcGvQTVMnHVOae8zjX/NSksR17e6eXHRveDvHCF5mGjh9hkN8mzyfnCqcBe42LdTs7bScuTaeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cac": "^3.0.3", "chalk": "^1.1.3", @@ -41972,7 +41962,6 @@ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -41983,7 +41972,6 @@ "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -41994,7 +41982,6 @@ "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -42012,7 +41999,6 @@ "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -42030,7 +42016,6 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.0" } @@ -42040,8 +42025,7 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/yarn-install/node_modules/path-key": { "version": "2.0.1", @@ -42049,7 +42033,6 @@ "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -42060,7 +42043,6 @@ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver" } @@ -42071,7 +42053,6 @@ "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^1.0.0" }, @@ -42085,7 +42066,6 @@ "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -42096,7 +42076,6 @@ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -42110,7 +42089,6 @@ "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.0" } @@ -42121,7 +42099,6 @@ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, diff --git a/td.vue/public/preload.js b/td.vue/public/preload.js index 2a9fface9..7b32c0fbd 100644 --- a/td.vue/public/preload.js +++ b/td.vue/public/preload.js @@ -13,8 +13,19 @@ contextBridge.exposeInMainWorld('electronAPI', { modelPrint: (format) => ipcRenderer.send('model-print', format), modelSave: (modelData, fileName) => ipcRenderer.send('model-save', modelData, fileName), updateMenu: (locale) => ipcRenderer.send('update-menu', locale), + setTemplateFolderDefault: () => ipcRenderer.send('set-template-folder-default'), + setTemplateFolderCustom: () => ipcRenderer.send('set-template-folder-custom'), + setTemplateFolderExisting: () => ipcRenderer.send('set-template-folder-existing'), + getTemplates: () => ipcRenderer.send('get-templates'), + bootstrapTemplates: () => ipcRenderer.send('bootstrap-templates'), + importTemplate: (templateData) => ipcRenderer.send('import-template', templateData), + fetchModelById: (templateId) => ipcRenderer.send('fetch-model-by-id', templateId), + exportTemplate: (data, filename) => ipcRenderer.send('export-template', data, filename), + deleteTemplate: (templateId) => ipcRenderer.send('delete-template', templateId), + updateTemplate: (templateMetadata) => ipcRenderer.send('update-template', templateMetadata), // electron main to renderer + onTemplatesResult: (callback) => ipcRenderer.on('templates-result', callback), onCloseAppRequest: (callback) => ipcRenderer.on('close-app-request', callback), onCloseModelRequest: (callback) => ipcRenderer.on('close-model-request', callback), onNewModelRequest: (callback) => ipcRenderer.on('new-model-request', callback), @@ -25,5 +36,19 @@ contextBridge.exposeInMainWorld('electronAPI', { onSaveModelRequest: (callback) => ipcRenderer.on('save-model-request', callback), onSaveModelConfirmed: (callback) => ipcRenderer.on('save-model-confirmed', callback), onSaveModelFailed: (callback) => ipcRenderer.on('save-model-failed', callback), + onImportTemplateSuccess: (callback) => ipcRenderer.on('import-template-success', callback), + onImportTemplateError: (callback) => ipcRenderer.on('import-template-error', callback), + onFetchModelByIdResult: (callback) => ipcRenderer.on('fetch-model-by-id-result', callback), + onExportTemplateSuccess: (callback) => ipcRenderer.on('export-template-success', callback), + onExportTemplateError: (callback) => ipcRenderer.on('export-template-error', callback), + onDeleteTemplateSuccess: (callback) => ipcRenderer.on('delete-template-success', callback), + onDeleteTemplateError: (callback) => ipcRenderer.on('delete-template-error', callback), + onUpdateTemplateSuccess: (callback) => ipcRenderer.on('update-template-success', callback), + onUpdateTemplateError: (callback) => ipcRenderer.on('update-template-error', callback), + onBootstrapTemplatesSuccess: (callback) => ipcRenderer.on('bootstrap-templates-success', callback), + onBootstrapTemplatesError: (callback) => ipcRenderer.on('bootstrap-templates-error', callback), + onSetTemplateFolderSuccess: (callback) => ipcRenderer.on('set-template-folder-success', callback), + onSetTemplateFolderError: (callback) => ipcRenderer.on('set-template-folder-error', callback), onApplyDiagramRequest: (callback) => ipcRenderer.on('apply-diagram-request', callback) }); + \ No newline at end of file diff --git a/td.vue/src/desktop/desktop.js b/td.vue/src/desktop/desktop.js index 7b9a78193..612333a7d 100644 --- a/td.vue/src/desktop/desktop.js +++ b/td.vue/src/desktop/desktop.js @@ -4,6 +4,7 @@ import { app, protocol, BrowserWindow, Menu, ipcMain } from 'electron'; import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; import menu from './menu.js'; +import template from './templates.js'; import logger from './logger.js'; import { electronURL, isDevelopment, isTest, isMacOS, isWin } from './utils.js'; @@ -48,12 +49,15 @@ export function registerDesktop (deps) { } }); + // Event listeners on the window mainWindow.webContents.on('did-finish-load', () => { mainWindow.show(); mainWindow.focus(); // menu system needs to access the main window menuApi.setMainWindow(mainWindow); + template.setMainWindow(mainWindow); + }); mainWindow.on('close', (event) => { @@ -76,7 +80,6 @@ export function registerDesktop (deps) { mainWindow.loadURL('app://./index.html'); } } - // Scheme must be registered before the app is ready protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } } @@ -127,6 +130,16 @@ export function registerDesktop (deps) { ipcMainApi.on('model-print', handleModelPrint); ipcMainApi.on('model-save', handleModelSave); ipcMainApi.on('update-menu', handleUpdateMenu); + ipcMainApi.on('set-template-folder-default', handleSetTemplateFolderDefault); + ipcMainApi.on('set-template-folder-custom', handleSetTemplateFolderCustom); + ipcMainApi.on('set-template-folder-existing', handleSetTemplateFolderExisting); + ipcMainApi.on('get-templates', handleGetTemplates); + ipcMainApi.on('bootstrap-templates', handleBootstrapTemplates); + ipcMainApi.on('import-template', handleImportTemplate); + ipcMainApi.on('fetch-model-by-id', handleFetchModelById); + ipcMainApi.on('export-template', handleExportTemplate); + ipcMainApi.on('delete-template', handleDeleteTemplate); + ipcMainApi.on('update-template', handleUpdateTemplate); createWindow(); @@ -142,6 +155,31 @@ export function registerDesktop (deps) { menuApi.openModelRequest(filePath); }); + function handleSetTemplateFolderDefault() { + loggerApi.log.debug('Set template folder to default location'); + template.setTemplateFolderDefault(); + } + + function handleSetTemplateFolderCustom() { + loggerApi.log.debug('Set template folder to custom location'); + template.setTemplateFolderCustom(); + } + + function handleSetTemplateFolderExisting() { + loggerApi.log.debug('Select existing template folder'); + template.setTemplateFolderExisting(); + } + + function handleGetTemplates() { + loggerApi.log.debug('Get templates request from renderer'); + template.getTemplates(); + } + + function handleBootstrapTemplates() { + loggerApi.log.debug('Bootstrap templates request from renderer'); + template.bootstrapTemplates(); + } + function handleCloseApp () { loggerApi.log.debug('Close application request from renderer '); runApp = false; @@ -163,6 +201,7 @@ export function registerDesktop (deps) { menuApi.modelOpened(); } + function handleModelPrint (_event, format) { loggerApi.log.debug('Model print request from renderer with printer : ' + format); menuApi.modelPrint(format); @@ -172,7 +211,15 @@ export function registerDesktop (deps) { loggerApi.log.debug('Model save request from renderer with file name : ' + fileName); menuApi.modelSave(modelData, fileName); } + function handleImportTemplate(_event, templateData) { + loggerApi.log.debug('Import template request from renderer'); + template.importTemplate(templateData); + } + function handleFetchModelById(_event, templateId) { + loggerApi.log.debug('Fetch model by ID request from renderer for template ID: ' + templateId); + template.fetchModelById(templateId); + } function handleUpdateMenu (_event, locale) { loggerApi.log.debug('Re-labeling the menu system for: ' + locale); menuApi.setLocale(locale); @@ -180,6 +227,21 @@ export function registerDesktop (deps) { MenuApi.setApplicationMenu(MenuApi.buildFromTemplate(template)); } + function handleExportTemplate(_event, data, filename) { + loggerApi.log.debug('Export template request from renderer with filename: ' + filename); + template.exportTemplate(data, filename); + } + + function handleDeleteTemplate(_event, templateId) { + loggerApi.log.debug('Delete template request from renderer for template ID: ' + templateId); + template.deleteTemplate(templateId); + } + + function handleUpdateTemplate(_event, templateMetadata) { + loggerApi.log.debug('Update template request from renderer for template ID: ' + templateMetadata.id); + template.updateTemplate(templateMetadata); + } + // Exit cleanly on request from parent process in development mode. if (isDev) { if (win) { @@ -213,3 +275,4 @@ if (!isTest) { path }); } + diff --git a/td.vue/src/desktop/templates.js b/td.vue/src/desktop/templates.js new file mode 100644 index 000000000..7a65242ac --- /dev/null +++ b/td.vue/src/desktop/templates.js @@ -0,0 +1,418 @@ +'use strict'; + +import { app, dialog } from 'electron'; +import path from 'path'; +import logger from './logger.js'; + +const fs = require('fs'); + +// provided by electron server bootstrap +var mainWindow; + +// access the i18n message strings +import ara from '@/i18n/ar.js'; +import deu from '@/i18n/de.js'; +import ell from '@/i18n/el.js'; +import eng from '@/i18n/en.js'; +import fin from '@/i18n/fi.js'; +import fra from '@/i18n/fr.js'; +import hin from '@/i18n/hi.js'; +import ind from '@/i18n/id.js'; +import jpn from '@/i18n/ja.js'; +import msa from '@/i18n/ms.js'; +import por from '@/i18n/pt.js'; +import spa from '@/i18n/es.js'; +import zho from '@/i18n/zh.js'; + + +const messages = { ara, deu, ell, eng, fin, fra, hin, ind, jpn, msa, por, spa, zho }; +const defaultLanguage = 'eng'; +var language = defaultLanguage; + +// Constants +const CONFIG_FILE = 'templates-path.txt'; +const TEMPLATE_INDEX = 'template_info.json'; + + + + +// get path to config file +function getConfigPath() { + return path.join(app.getPath('userData'), CONFIG_FILE); +} + +// get templates folder path from config file +async function getTemplatesPath() { + try { + const data = await fs.promises.readFile(getConfigPath(), 'utf-8'); + return data.trim(); + } catch { + return null; + } +} + +// check if folder has write access by attempting a test write +// (fs.access W_OK is unreliable on Windows for NTFS ACL permissions) +async function hasWriteAccess(folderPath) { + const testFile = path.join(folderPath, `.write_test_${Date.now()}`); + try { + await fs.promises.writeFile(testFile, ''); + await fs.promises.unlink(testFile); + return true; + } catch (err) { + logger.log.debug('Write access denied for ' + folderPath + ': ' + err.code); + return false; + } +} + +// check if folder exists +async function folderExists(folderPath) { + try { + const stats = await fs.promises.stat(folderPath); + return stats.isDirectory(); + } catch { + return false; + } +} + + + +// get full path to template index file +function getIndexPath(folderPath) { + return path.join(folderPath, TEMPLATE_INDEX); +} + +// check if template index file exists in folder +async function hasTemplateIndex(folderPath) { + try { + const indexPath = getIndexPath(folderPath); + const stats = await fs.promises.stat(indexPath); + return stats.isFile(); + } catch { + return false; + } +} + +// Read template metadata from index file, returns null if not initialized +async function listTemplates(basePath) { + if (!await hasTemplateIndex(basePath)) { + return null; + } + + try { + const indexFilePath = getIndexPath(basePath); + const data = await fs.promises.readFile(indexFilePath, 'utf-8'); + const parsed = JSON.parse(data); + return parsed.templates || []; + } catch (err) { + logger.log.warn('Error reading template index: ' + err); + return []; + } +} + +// Creates template index file in the given folder +async function createTemplateIndex(folderPath) { + const indexPath = path.join(folderPath, TEMPLATE_INDEX); + await fs.promises.writeFile(indexPath, '{"templates":[]}', 'utf-8'); + logger.log.debug('Created template index at: ' + folderPath); +} + +// IPC-callable: bootstrap templates for configured folder +async function bootstrapTemplates() { + const templatePath = await getTemplatesPath(); + try { + await createTemplateIndex(templatePath); + await getTemplates(); + mainWindow.webContents.send('bootstrap-templates-success', messages[language].template.toast.configureSuccess); + } catch (err) { + logger.log.error('Error bootstrapping templates: ' + err); + mainWindow.webContents.send('bootstrap-templates-error', messages[language].template.errors.initializeFailed); + } +} + + +//import template file +async function importTemplate(template) { + const templatePath = await getTemplatesPath(); + const { templateMetadata, model } = template; + + try { + // Append metadata to index + const currentTemplates = await listTemplates(templatePath) || []; + const duplicate = currentTemplates.find(t => t.name === templateMetadata.name); + if (duplicate) { + mainWindow.webContents.send('import-template-error', messages[language].template.errors.duplicate); + return; + } + + const newTemplates = [...currentTemplates, templateMetadata]; + const indexPath = getIndexPath(templatePath); + await fs.promises.writeFile(indexPath, JSON.stringify({ templates: newTemplates }, null, 2), 'utf-8'); + + // Save model file + const modelPath = path.join(templatePath, templateMetadata.modelRef + '.json'); + await fs.promises.writeFile(modelPath, JSON.stringify(model, null, 2), 'utf-8'); + mainWindow.webContents.send('import-template-success', messages[language].template.toast.importSuccess); + + // Refresh template list (sends updated list to frontend) + await getTemplates(); + + logger.log.debug('Template imported: ' + templateMetadata.name); + } catch (err) { + logger.log.error('Error importing template: ' + err); + mainWindow.webContents.send('import-template-error', messages[language].template.errors.importFailed); + } +} + +async function fetchModelById(templateId) { + const templatePath = await getTemplatesPath(); + try { + const templates = await listTemplates(templatePath); + const template = templates.find(t => t.id === templateId); + const modelPath = path.join(templatePath, template.modelRef + '.json'); + const modelData = await fs.promises.readFile(modelPath, 'utf-8'); + mainWindow.webContents.send('fetch-model-by-id-result', { + model: JSON.parse(modelData) + }); + } catch (err) { + logger.log.error('Error fetching model by ID: ' + err); + } +} + +async function exportTemplate(data, filename) { + const dialogOptions = { + title: messages[language].template.exportTemplate, + defaultPath: filename, + filters: [{ name: 'Template', extensions: ['json'] }] + }; + + try { + const result = await dialog.showSaveDialog(mainWindow, dialogOptions); + if (result.canceled) { + logger.log.debug('Export template canceled'); + return; + } + + await fs.promises.writeFile(result.filePath, JSON.stringify(data, null, 2), 'utf-8'); + mainWindow.webContents.send('export-template-success', messages[language].template.toast.exportSuccess); + logger.log.debug('Template exported: ' + result.filePath); + } catch (err) { + logger.log.error('Error exporting template: ' + err); + mainWindow.webContents.send('export-template-error', messages[language].template.errors.exportFailed); + } +} + + + + + +// Helper: save path to config and bootstrap if needed +async function savePathToConfig(folderPath) { + await fs.promises.writeFile(getConfigPath(), folderPath, 'utf-8'); + logger.log.debug('Template folder path saved: ' + folderPath); +} + +// Helper: validate folder, save path, and bootstrap if needed +async function saveAndBootstrap(folderPath) { + try { + // Check write access + const canWrite = await hasWriteAccess(folderPath); + if (!canWrite) { + mainWindow.webContents.send('set-template-folder-error', messages[language].template.errors.noWriteAccess); + return; + } + + // Save path to config + await savePathToConfig(folderPath); + + // Create template index if it doesn't exist + if (!await hasTemplateIndex(folderPath)) { + await createTemplateIndex(folderPath); + } + + // Refresh template list + await getTemplates(); + + mainWindow.webContents.send('set-template-folder-success', messages[language].template.toast.configureSuccess); + } catch (err) { + logger.log.error('Error setting up template folder: ' + err); + mainWindow.webContents.send('set-template-folder-error', messages[language].template.errors.setupFailed); + } +} + +// Set template folder to default location (userData/templates) +async function setTemplateFolderDefault() { + logger.log.debug('Setting template folder to default location'); + const defaultPath = path.join(app.getPath('userData'), 'templates'); + await fs.promises.mkdir(defaultPath, { recursive: true }); + await saveAndBootstrap(defaultPath); +} + +// Set template folder to custom location (user picks folder) +async function setTemplateFolderCustom() { + logger.log.debug('Setting template folder to custom location'); + try { + const result = await dialog.showOpenDialog(mainWindow, { + title: messages[language].template.actions.selectFolder, + properties: ['openDirectory'], + defaultPath: app.getPath('documents') + }); + + if (result.canceled) { + logger.log.debug('Custom folder selection canceled'); + return; + } + + await saveAndBootstrap(result.filePaths[0]); + } catch (err) { + logger.log.error('Error selecting custom folder: ' + err); + mainWindow.webContents.send('set-template-folder-error', messages[language].template.errors.setupFailed); + } +} + +// Set template folder to existing location (must have template_info.json) +async function setTemplateFolderExisting() { + logger.log.debug('Selecting existing template folder'); + try { + const result = await dialog.showOpenDialog(mainWindow, { + title: messages[language].template.actions.selectFolder, + properties: ['openDirectory'], + defaultPath: app.getPath('documents') + }); + + if (result.canceled) { + logger.log.debug('Existing folder selection canceled'); + return; + } + + const selectedPath = result.filePaths[0]; + + // Validate folder has template_info.json + if (await hasTemplateIndex(selectedPath)) { + await savePathToConfig(selectedPath); + await getTemplates(); + mainWindow.webContents.send('set-template-folder-success', messages[language].template.toast.configureSuccess); + } else { + mainWindow.webContents.send('set-template-folder-error', messages[language].template.errors.folderInvalid); + } + } catch (err) { + logger.log.error('Error selecting existing folder: ' + err); + mainWindow.webContents.send('set-template-folder-error', messages[language].template.errors.setupFailed); + } +} + + +// pipeline function to get templates and send state to renderer +async function getTemplates() { + // check if there is a templates path configured + const templatePath = await getTemplatesPath(); + if (!templatePath) { + mainWindow.webContents.send('templates-result', { + status: 'NOT_CONFIGURED', + templates: [] + }); + return; + } + + // check if that folder exists + const exists = await folderExists(templatePath); + if (!exists) { + mainWindow.webContents.send('templates-result', { + status: 'NOT_FOUND', + templates: [] + }); + return; + } + + // check if folder has write access + const canWrite = await hasWriteAccess(templatePath); + logger.log.debug('getTemplates - canWrite result:', canWrite, 'for path:', templatePath); + + // Read template metadata from index file + const templates = await listTemplates(templatePath); + if (templates === null) { + mainWindow.webContents.send('templates-result', { + status: 'NOT_INITIALIZED', + canWrite: canWrite, + templates: [] + }); + return; + } + + + mainWindow.webContents.send('templates-result', { + canWrite: canWrite, + templates: templates + }); +} + +async function deleteTemplate(templateId) { + const templatePath = await getTemplatesPath(); + try { + const templates = await listTemplates(templatePath); + const newTemplates = templates.filter(t => t.id !== templateId); + const indexPath = getIndexPath(templatePath); + await fs.promises.writeFile(indexPath, JSON.stringify({ templates: newTemplates }, null, 2), 'utf-8'); + // Also delete the model file + const modelPath = path.join(templatePath, templates.find(t => t.id === templateId).modelRef + '.json'); + await fs.promises.unlink(modelPath); + mainWindow.webContents.send('delete-template-success', messages[language].template.toast.deleteSuccess); + // Refresh template list (sends updated list to frontend) + await getTemplates(); + logger.log.debug('Template deleted: ' + templateId); + } catch (err) { + logger.log.error('Error deleting template: ' + err); + mainWindow.webContents.send('delete-template-error', messages[language].template.errors.deleteFailed); + } +} + +async function updateTemplate(templateMetadata) { + const templatePath = await getTemplatesPath(); + try { + const templates = await listTemplates(templatePath); + const templateIndex = templates.findIndex(t => t.id === templateMetadata.id); + if (templateIndex === -1) { + throw new Error('Template not found'); + } + // Update metadata in index + templates[templateIndex] = { + ...templates[templateIndex], + name: templateMetadata.name, + description: templateMetadata.description, + tags: templateMetadata.tags + }; + const indexPath = getIndexPath(templatePath); + await fs.promises.writeFile(indexPath, JSON.stringify({ templates }, null, 2), 'utf-8'); + mainWindow.webContents.send('update-template-success', messages[language].template.toast.updateSuccess); + // Refresh template list + await getTemplates(); + logger.log.debug('Template updated: ' + templateMetadata.id); + } catch (err) { + logger.log.error('Error updating template: ' + err); + mainWindow.webContents.send('update-template-error', messages[language].template.errors.updateFailed); + } +} + +const setMainWindow = (window) => { + mainWindow = window; +}; + +const setLocale = (locale) => { + const languages = ['ara', 'deu', 'ell', 'eng', 'fin', 'fra', 'hin', 'ind', 'jpn', 'msa', 'por', 'spa', 'zho']; + language = languages.includes(locale) ? locale : defaultLanguage; +}; + +export default { + setMainWindow, + setLocale, + setTemplateFolderDefault, + setTemplateFolderCustom, + setTemplateFolderExisting, + getTemplates, + bootstrapTemplates, + importTemplate, + fetchModelById, + exportTemplate, + deleteTemplate, + updateTemplate +}; diff --git a/td.vue/src/i18n/en.js b/td.vue/src/i18n/en.js index 750eda429..5089370f0 100644 --- a/td.vue/src/i18n/en.js +++ b/td.vue/src/i18n/en.js @@ -112,71 +112,107 @@ const eng = { repo: 'repo', newThreatModel: 'Create a New Threat Model' }, - template:{ - startFromLocalTemplate: 'Start from a Local Template', - select: 'Select a Template from the list below', + + + + template: { + // Gallery & selection + select: 'Select a Template', selectDescription: 'Templates provide a starting point for new threat models, pre-populated with relevant components and threats.', + startFromLocalTemplate: 'Start from a Local Template', noTemplates: 'No templates found', templatesLocalSession: 'Remote templates are not available for local sessions.', search: 'Search templates...', - exportTemplate: 'Export as Template', - tags: 'Tags', - name: 'Template Name', - description: 'Template Description', - saveTemplate: 'Save Template', - addNew: 'Add New Template', + + // Management manage: 'Manage Templates', manageDescription: 'Import, export, and manage your threat model templates here.', + addNew: 'Add New Template', editTemplate: 'Edit Template', - addTagsPlaceholder: 'Add tags...', - updateSuccess: 'Template updated successfully', - importSuccess: 'Template imported successfully', - deleteSuccess: 'Template deleted successfully', - deleteTitle: 'Confirm Delete', + exportTemplate: 'Export as Template', deleteConfirm: 'Are you sure you want to delete "{name}"?', - errors: { - invalidJson: 'Invalid JSON. Please check your template file and try again', - invalidTemplate: 'Invalid template format. Please check your template file and try again', - loadFailed: 'Failed to load templates. Please try again', - duplicateTemplate: 'A template with this name already exists. Please use a different name', - updateFailed: 'Failed to update template', - deleteFailed: 'Failed to delete template' - }, - warnings: { - templateSave: 'Could not save the template. Check the developer console for more information', - invalidSchema: 'Template does not strictly match schema. Details in the developer console' - }, - prompts: { - templateSaved: 'Template successfully saved', - templateDownloading: 'Downloading template' - }, - repo: { - - notInitialized: { - title: 'Template Repository Not Initialized', - userMessage: 'The template repository has not been initialized. Please contact your administrator.', - adminMessage: 'Please go to the Manage Templates page to initialize the template repository.' - }, + deleteTitle: 'Delete Template', + + // Form fields + name: 'Template Name', + description: 'Template Description', + tags: 'Tags', + addTagsPlaceholder: 'Add tags...', + + // Permissions + readOnlyNotice: 'You have read-only access. You can view templates but cannot modify them.', + + // Unified status messages (desktop vs web, admin vs user) + status: { notConfigured: { - title: 'Template Repository Not Configured', - userMessage: 'The template repository is not configured. Please set up the repository to access templates.' + title: 'Template Storage Not Configured', + desktop: 'Please set up a folder to store templates.', + web: 'Template repository not configured. Contact your administrator.' }, notFound: { - title: 'Template Repository Not Found', - userMessage: 'The repository {repoName} is not a valid repository. Please check your configuration.' + title: 'Template Storage Not Found', + desktop: 'The configured template folder no longer exists.', + web: 'The configured template repository could not be found.' }, - bootstrap:{ - bootstrapping:'Initializing..', - title: 'Initialize Template Repository', - description: 'This will create the necessary folder structure within the repository if it does not already exist.', - action: 'Initialize', - success: 'Template repository successfully initialized.', - error: 'Could not initialize the template repository. Check the developer console for more information.' + notInitialized: { + title: 'Template Storage Not Initialized', + user: 'No templates available yet. Contact your administrator.', + admin: 'Initialize the template storage to get started.' + } + }, + + // Actions (buttons, loading states) + actions: { + selectFolder: 'Select Folder', + initialize: 'Initialize', + initializing: 'Initializing...', + save: 'Save Template', + }, + + // Desktop-only: setup dialog + setupDialog: { + title: 'Set Up Template Storage', + createNew: 'Create a new template folder', + useDefault: 'Use default location', + defaultPath: 'AppData/Roaming/Threat Dragon/templates', + chooseCustom: 'Choose custom location', + selectExisting: 'Select existing template folder', + selectExistingHint: 'Pick a folder that already contains templates', + confirm: 'Set Up' + }, + // Bootstrap + bootstrap: { + description: 'Create the template index at the configured storage location' + }, - } + // Toast messages (success notifications) + toast: { + importSuccess: 'Template imported successfully', + updateSuccess: 'Template updated successfully', + deleteSuccess: 'Template deleted successfully', + configureSuccess: 'Template storage configured successfully', + initializeSuccess: 'Template storage initialized successfully', + exportSuccess: 'Template exported successfully' }, + + // Errors + errors: { + invalidJson: 'Invalid JSON format', + invalidTemplate: 'Invalid template format', + duplicate: 'A template with this name already exists', + loadFailed: 'Failed to load templates', + importFailed: 'Failed to import template', + updateFailed: 'Failed to update template', + deleteFailed: 'Failed to delete template', + exportFailed: 'Failed to export template', + noWriteAccess: 'Cannot write to selected location', + setupFailed: 'Failed to set up template storage', + initializeFailed: 'Failed to initialize template storage', + folderInvalid: 'Selected folder does not contain valid templates' + } }, + threatmodel: { contributors: 'Contributors', contributorsPlaceholder: 'Start typing to add a contributor', diff --git a/td.vue/src/main.desktop.js b/td.vue/src/main.desktop.js index c461d8387..a578ecbb6 100644 --- a/td.vue/src/main.desktop.js +++ b/td.vue/src/main.desktop.js @@ -13,6 +13,7 @@ import storeFactory from './store/index.js'; import authActions from './store/actions/auth.js'; import providerActions from './store/actions/provider.js'; import tmActions from './store/actions/threatmodel.js'; +import { TEMPLATE_SET_CONTENT_STORE_STATUS, TEMPLATE_SET_TEMPLATES } from './store/actions/template.js'; import './plugins/bootstrap-vue.js'; import './plugins/fontawesome-vue.js'; @@ -31,6 +32,19 @@ const getConfirmModal = () => { }); }; + +//request from electron to renderer with template store status and templates +window.electronAPI.onTemplatesResult((_event, result) => { + console.debug('Templates result:', result); + + // Commit to store + app.$store.commit(TEMPLATE_SET_CONTENT_STORE_STATUS, { + status: result.status, + canWrite: result.canWrite || false + }); + app.$store.commit(TEMPLATE_SET_TEMPLATES, result.templates || []); +}); + // request from electron to renderer to close the application window.electronAPI.onCloseAppRequest(async (_event) => { // eslint-disable-line no-unused-vars console.debug('Close application request'); @@ -176,12 +190,84 @@ window.electronAPI.onSaveModelConfirmed((_event, fileName) => { app.$store.dispatch(tmActions.notModified); app.$toast.success(app.$t('threatmodel.prompts.saved')); }); - +window.electronAPI.onImportTemplateSuccess((_event,message) => { + console.debug('Template imported successfully'); + app.$toast.success(message); +}); +window.electronAPI.onImportTemplateError((_event,message) => { + console.debug('Template import failed'); + app.$toast.error(message); +}); window.electronAPI.onSaveModelFailed((_event, fileName, message) => { console.debug('Failed to save model file : ' + fileName); app.$toast.warning(message); }); +window.electronAPI.onFetchModelByIdResult((_event, result) => { + console.debug('Fetch model by ID result:', result); + + // Load template (regenerates IDs and sets as current model) + app.$store.dispatch(tmActions.templateLoad, { + templateData: result.model + }); + + // Route to new threat model page + const model = app.$store.state.threatmodel.data; + const params = { threatmodel: model.summary.title }; + app.$router.push({ name: `${providerNames.desktop}ThreatModel`, params }); +}); + +window.electronAPI.onExportTemplateSuccess((_event, message) => { + console.debug('Template exported successfully'); + app.$toast.success(message); +}); + +window.electronAPI.onExportTemplateError((_event, message) => { + console.debug('Template export failed'); + app.$toast.error(message); +}); + +window.electronAPI.onDeleteTemplateSuccess((_event, message) => { + console.debug('Template deleted successfully'); + app.$toast.success(message); +} +); + +window.electronAPI.onDeleteTemplateError((_event, message) => { + console.debug('Template delete failed'); + app.$toast.error(message); +}); + +window.electronAPI.onUpdateTemplateSuccess((_event, message) => { + console.debug('Template updated successfully'); + app.$toast.success(message); +}); + +window.electronAPI.onUpdateTemplateError((_event, message) => { + console.debug('Template update failed'); + app.$toast.error(message); +}); + +window.electronAPI.onBootstrapTemplatesSuccess((_event, message) => { + console.debug('Templates bootstrapped successfully'); + app.$toast.success(message); +}); + +window.electronAPI.onBootstrapTemplatesError((_event, message) => { + console.debug('Templates bootstrap failed'); + app.$toast.error(message); +}); + +window.electronAPI.onSetTemplateFolderSuccess((_event, message) => { + console.debug('Template folder set successfully'); + app.$toast.success(message); +}); + +window.electronAPI.onSetTemplateFolderError((_event, message) => { + console.debug('Template folder setup failed'); + app.$toast.error(message); +}); + const localAuth = () => { app.$store.dispatch(providerActions.selected, providerNames.desktop); app.$store.dispatch(authActions.setLocal); diff --git a/td.vue/src/router/desktop.js b/td.vue/src/router/desktop.js index cdd94e31a..6d5739a1f 100644 --- a/td.vue/src/router/desktop.js +++ b/td.vue/src/router/desktop.js @@ -1,8 +1,10 @@ import { providerTypes } from '../service/provider/providerTypes.js'; - +import { getTemplateRoutes} from './template.js'; const providerType = providerTypes.desktop; export const desktopRoutes = [ + + ...getTemplateRoutes(providerType, `/${providerType}`), { path: `/${providerType}/:threatmodel`, name: `${providerType}ThreatModel`, diff --git a/td.vue/src/service/provider/desktop.provider.js b/td.vue/src/service/provider/desktop.provider.js index 4937ee16f..19f81c374 100644 --- a/td.vue/src/service/provider/desktop.provider.js +++ b/td.vue/src/service/provider/desktop.provider.js @@ -17,9 +17,14 @@ const getDashboardActions = () => ([ to: '/demo/select', key: 'readDemo', icon: 'cloud-download-alt' + }, + { + to: `/${providerType}/templates`, + key: 'createFromTemplate', + icon: 'file-alt' } ]); export default { getDashboardActions -}; +}; \ No newline at end of file diff --git a/td.vue/src/service/schema/owasp-threat-dragon-template.schema.json b/td.vue/src/service/schema/owasp-threat-dragon-template.schema.json new file mode 100644 index 000000000..cdf818a93 --- /dev/null +++ b/td.vue/src/service/schema/owasp-threat-dragon-template.schema.json @@ -0,0 +1,479 @@ +{ + "$id": "https://owasp.org/www-project-threat-dragon/assets/schemas/owasp.threat-dragon.template.schema.json", + "title": "Threat Dragon template schema", + "description": "The template structure used by OWASP Threat Dragon for reusable threat models", + "type": "object", + "properties": { + "templateMetadata": { + "description": "Template meta-information", + "type": "object", + "properties": { + "id": { + "description": "A unique identifier for this template (UUID)", + "type": "string", + "minLength": 36, + "maxLength": 36 + }, + "name": { + "description": "The name of the template", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "Description of what this template is for", + "type": "string" + }, + "tags": { + "description": "An array of tags for categorizing the template", + "type": "array", + "items": { + "type": "string" + } + }, + "modelRef": { + "description": "The unique reference to the threat model content file", + "type": "string", + "minLength": 36, + "maxLength": 36 + } + }, + "required": ["id", "name", "description", "tags"] + }, + "model": { + "description": "The threat model content following the standard Threat Dragon V2 schema", + "type": "object", + "properties": { + "version": { + "description": "Threat Dragon version used in the model", + "type": "string", + "maxLength": 10 + }, + "summary": { + "description": "Threat model project meta-data", + "type": "object", + "properties": { + "description": { + "description": "Description of the threat model used for report outputs", + "type": "string" + }, + "id": { + "description": "A unique identifier for this main threat model object", + "type": ["integer", "string"], + "minimum": 0 + }, + "owner": { + "description": "The original creator or overall owner of the model", + "type": "string" + }, + "title": { + "description": "Threat model title", + "type": "string" + } + }, + "required": ["title"] + }, + "detail": { + "description": "Threat model definition", + "type": "object", + "properties": { + "contributors": { + "description": "An array of contributors to the threat model project", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "The name of the contributor", + "type": "string" + } + } + } + }, + "diagrams": { + "description": "An array of single or multiple threat data-flow diagrams", + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "description": "The description of the diagram", + "type": "string" + }, + "diagramType": { + "description": "The methodology used by the data-flow diagram", + "type": "string", + "minLength": 3 + }, + "id": { + "description": "The sequence number of the diagram", + "type": ["integer", "string"], + "minimum": 0 + }, + "placeholder": { + "description": "The text used when the description is empty", + "type": "string" + }, + "thumbnail": { + "description": "The path to the thumbnail image for the diagram", + "type": "string" + }, + "title": { + "description": "The title of the data-flow diagram", + "type": "string" + }, + "version": { + "description": "Threat Dragon version used in the diagram", + "type": "string", + "maxLength": 10 + }, + "cells": { + "description": "The individual diagram components", + "type": "array", + "items": { + "type": "object", + "properties": { + "attrs": { + "description": "The component display attributes", + "type": "object", + "properties": { + "body": { + "description": "The component stroke attributes", + "type": "object", + "properties": { + "stroke": { + "description": "The stroke color", + "type": "string" + }, + "strokeWidth": { + "description": "The stroke width", + "type": "number" + }, + "strokeDasharray": { + "description": "The stroke dash ratio", + "type": "string", + "nullable": true + } + }, + "required": ["stroke", "strokeWidth", "strokeDasharray"] + }, + "line": { + "description": "The component stroke attributes", + "type": "object", + "properties": { + "stroke": { + "description": "The stroke color", + "type": "string" + }, + "strokeWidth": { + "description": "The stroke width", + "type": "number" + }, + "sourceMarker": { + "description": "The line source marker", + "type": ["object", "string"], + "properties": { + "name": { + "description": "The source marker shape", + "type": "string" + } + }, + "required": ["name"] + }, + "strokeDasharray": { + "description": "The stroke dash ratio", + "type": "string", + "nullable": true + }, + "targetMarker": { + "description": "The line target marker", + "type": ["object", "string"], + "properties": { + "name": { + "description": "The target marker shape", + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["targetMarker"] + } + } + }, + "data": { + "description": "The component parameters", + "type": "object", + "properties": { + "description": { + "description": "The component description", + "type": "string" + }, + "handlesCardPayment": { + "description": "The component flag set if the process handles credit card payment", + "type": "boolean" + }, + "handlesGoodsOrServices": { + "description": "The component flag set if the process is part of a retail site", + "type": "boolean" + }, + "isALog": { + "description": "The component flag set if the store contains logs", + "type": "boolean" + }, + "isBidirectional": { + "description": "The component flag set if it is not in scope", + "type": "boolean" + }, + "isEncrypted": { + "description": "The data-flow flag set if is bidirectional", + "type": "boolean" + }, + "isPublicNetwork": { + "description": "The data-flow flag set if it crosses a public network", + "type": "boolean" + }, + "isSigned": { + "description": "The component flag set if the data store uses signatures", + "type": "boolean" + }, + "isTrustBoundary": { + "description": "The flag set if the component is a trust boundary curve or trust boundary box", + "type": "boolean" + }, + "isWebApplication": { + "description": "The component flag set if the process is a web application", + "type": "boolean" + }, + "name": { + "description": "The component name", + "type": "string" + }, + "outOfScope": { + "description": "The component flag set if it is not in scope", + "type": "boolean" + }, + "privilegeLevel": { + "description": "The component's level of privilege/permissions", + "type": "string" + }, + "protocol": { + "description": "The data-flow protocol", + "type": "string" + }, + "providesAuthentication": { + "description": "The component flag set if the Actor provides Authentication", + "type": "boolean" + }, + "reasonOutOfScope": { + "description": "The component description if out of scope", + "type": "string" + }, + "storesCredentials": { + "description": "The component flag set if store contains credentials/PII", + "type": "boolean" + }, + "storesInventory": { + "description": "The component flag set if store is part of a retail web application", + "type": "boolean" + }, + "type": { + "description": "The component type", + "type": "string" + }, + "hasOpenThreats": { + "description": "The component flag set if there are open threats", + "type": "boolean" + } + }, + "required": ["hasOpenThreats", "type"] + }, + "id": { + "description": "The component unique identifier (UUID)", + "type": "string", + "minLength": 2 + }, + "position": { + "description": "The component position", + "type": "object", + "properties": { + "x": { + "description": "The component horizontal position", + "type": "number" + }, + "y": { + "description": "The component vertical position", + "type": "number" + } + }, + "required": ["x", "y"] + }, + "size": { + "description": "The component body size (not line)", + "type": "object", + "properties": { + "height": { + "description": "The component height", + "type": "number", + "minimum": 10 + }, + "width": { + "description": "The component width", + "type": "number", + "minimum": 10 + } + }, + "required": ["height", "width"] + }, + "connector": { + "description": "The data flows and boundary geometry", + "type": "string" + }, + "source": { + "description": "The component curve source", + "type": "object", + "properties": { + "cell": { + "description": "The data-flow source attachment point", + "type": "string" + }, + "x": { + "description": "The boundary horizontal curve source", + "type": "integer" + }, + "y": { + "description": "The boundary vertical curve source", + "type": "integer" + } + } + }, + "target": { + "description": "The component curve target", + "type": "object", + "properties": { + "cell": { + "description": "The data-flow target attachment point", + "type": "string" + }, + "x": { + "description": "The boundary horizontal curve target", + "type": "integer" + }, + "y": { + "description": "The boundary vertical curve target", + "type": "integer" + } + } + }, + "threats": { + "description": "The threats associated with the component", + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "description": "The threat description", + "type": "string" + }, + "mitigation": { + "description": "The threat mitigation", + "type": "string" + }, + "modelType": { + "description": "The threat methodology type", + "type": "string" + }, + "number": { + "description": "The unique number for the threat", + "type": "integer", + "minimum": 0 + }, + "score": { + "description": "The custom score/risk for the threat", + "type": "string" + }, + "severity": { + "description": "The threat severity as High, Medium or Low", + "type": "string" + }, + "status": { + "description": "The threat status as NA, Open or Mitigated", + "type": "string" + }, + "threatId": { + "description": "The threat ID as a UUID", + "type": "string", + "minLength": 2 + }, + "title": { + "description": "The threat title", + "type": "string" + }, + "type": { + "description": "The threat type, selection according to modelType", + "type": "string" + } + }, + "required": ["description", "mitigation", "severity", "status", "title", "type"] + } + }, + "shape": { + "description": "The component shape", + "type": "string" + }, + "visible": { + "description": "The component visibility", + "type": "boolean" + }, + "vertices": { + "description": "The boundary or data-flow curve points", + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "description": "The horizontal value of the curve point", + "type": "number" + }, + "y": { + "description": "The vertical value of the curve point", + "type": "number" + } + }, + "required": ["x", "y"] + } + }, + "zIndex": { + "description": "The component Z-plane", + "type": "integer" + } + }, + "required": ["id", "shape", "zIndex"] + } + } + }, + "required": ["diagramType", "id", "thumbnail", "title", "version"] + } + }, + "diagramTop": { + "description": "The highest diagram number in the threat model", + "type": "integer", + "minimum": 0 + }, + "reviewer": { + "description": "The reviewer of the overall threat model", + "type": "string" + }, + "threatTop": { + "description": "The highest threat number in the threat model", + "type": "integer", + "minimum": 0 + } + }, + "required": ["contributors", "diagrams", "diagramTop", "reviewer", "threatTop"] + } + }, + "required": ["version", "summary", "detail"] + } + }, + "required": ["templateMetadata", "model"] +} diff --git a/td.vue/src/store/actions/template.js b/td.vue/src/store/actions/template.js index ca31621bb..38e124b45 100644 --- a/td.vue/src/store/actions/template.js +++ b/td.vue/src/store/actions/template.js @@ -6,7 +6,7 @@ export const TEMPLATE_DELETE = 'TEMPLATE_DELETE'; export const TEMPLATE_FETCH_MODEL_BY_ID = 'TEMPLATE_FETCH_MODEL_BY_ID'; export const TEMPLATE_SET_TEMPLATES = 'TEMPLATE_SET_TEMPLATES';// mutation ot set templates fetched from backend export const TEMPLATE_BOOTSTRAP = 'TEMPLATE_BOOTSTRAP'; -export const TEMPLATE_SET_CONTENT_REPO_STATUS = 'TEMPLATE_SET_CONTENT_REPO_STATUS';// mutation to set content repo status +export const TEMPLATE_SET_CONTENT_STORE_STATUS = 'TEMPLATE_SET_CONTENT_STORE_STATUS';// mutation to set content repo status export default { fetchAll: TEMPLATE_FETCH_ALL, diff --git a/td.vue/src/store/modules/auth.js b/td.vue/src/store/modules/auth.js index 91205e3ba..cfa79a363 100644 --- a/td.vue/src/store/modules/auth.js +++ b/td.vue/src/store/modules/auth.js @@ -23,7 +23,11 @@ const state = { const actions = { [AUTH_CLEAR]: ({ commit }) => commit(AUTH_CLEAR), [AUTH_SET_JWT]: ({ commit }, tokens) => commit(AUTH_SET_JWT, tokens), - [AUTH_SET_LOCAL]: ({ commit }) => commit(AUTH_SET_LOCAL), + // Have a precheck to differentiate desktop vs local(server deployment) + [AUTH_SET_LOCAL]: ({ commit, rootState }) => { + const isDesktop = rootState.provider.selected === providers.allProviders.desktop.key; // flg to indicate desktop login + commit(AUTH_SET_LOCAL, { isDesktop }); + }, [LOGOUT]: async ({ dispatch, state, rootState }) => { try { if (rootState.provider.selected !== providers.allProviders.local.key && rootState.provider.selected !== providers.allProviders.desktop.key) { @@ -57,9 +61,10 @@ const mutations = { throw e; } }, - [AUTH_SET_LOCAL]: (state) => { + [AUTH_SET_LOCAL]: (state, { isDesktop }) => { state.user = { - username: 'local-user' + username: 'local-user', + isAdmin: isDesktop }; } }; @@ -74,4 +79,4 @@ export default { actions, mutations, getters -}; +}; \ No newline at end of file diff --git a/td.vue/src/store/modules/template.js b/td.vue/src/store/modules/template.js index d78d8f5ab..fba1cd554 100644 --- a/td.vue/src/store/modules/template.js +++ b/td.vue/src/store/modules/template.js @@ -6,18 +6,19 @@ import { TEMPLATE_DELETE, TEMPLATE_FETCH_MODEL_BY_ID, TEMPLATE_SET_TEMPLATES, - TEMPLATE_SET_CONTENT_REPO_STATUS, + TEMPLATE_SET_CONTENT_STORE_STATUS, TEMPLATE_BOOTSTRAP } from '@/store/actions/template'; import templateApi from '@/service/api/templateApi.js'; +import { providerTypes } from '@/service/provider/providerTypes'; +import { getProviderType } from '@/service/provider/providers'; const state = { templates: [],// list of templates - contentRepo: { - status: null, // null (initialized & working) | 'NOT_CONFIGURED' | 'REPO_NOT_FOUND' | 'NOT_INITIALIZED' - canInitialize: false,// determined by the permissions of the current user in context of the repo - repoName: null, // Only populated for 'REPO_NOT_FOUND' scenario + contentStore: { + status: null, // null (READY) | 'NOT_CONFIGURED' | 'NOT_FOUND' | 'NOT_INITIALIZED' + canWrite: false // user has write permissions (web: repo push access, desktop: folder write access) } }; @@ -60,57 +61,57 @@ const actions = { /** * Fetches all templates from the backend - * - * Handles multiple repository states: - * - Normal operation: Returns templates array - * - NOT_CONFIGURED: Template repository URL not configured - * - NOT_INITIALIZED: Repository exists but folder structure not bootstrapped - * - REPO_NOT_FOUND: Repository doesn't exist (404 - not an error) - * - * @async - * @param {Object} context - Vuex action context - * @param {Function} context.commit - Vuex commit function - * @returns {Promise} + * + * Handles unified statuses (desktop + web): + * - null (READY): Normal operation, templates available + * - NOT_CONFIGURED: Storage location not set up + * - NOT_FOUND: Storage was configured but no longer exists (404) + * - NOT_INITIALIZED: Storage exists but no template index + * + * canWrite flag indicates write permissions (repo push / folder write access) */ - [TEMPLATE_FETCH_ALL]: async ({ commit }) => { + [TEMPLATE_FETCH_ALL]: async ({ commit, rootState }) => { try { + // Desktop: fire IPC request, result comes via onTemplatesResult listener + if (getProviderType(rootState.provider.selected) === providerTypes.desktop) { + window.electronAPI.getTemplates(); + return; + } + const response = await templateApi.fetchAllAsync(); // Handle special statuses (NOT_CONFIGURED, NOT_INITIALIZED) - if (response.data.repoStatus) { - commit(TEMPLATE_SET_CONTENT_REPO_STATUS, { - status: response.data.repoStatus, - canInitialize: response.data.canInitialize, - repoName: null + if (response.data.status) { + commit(TEMPLATE_SET_CONTENT_STORE_STATUS, { + status: response.data.status, + canWrite: response.data.canWrite }); commit(TEMPLATE_SET_TEMPLATES, []); } else { - // Normal operation - commit(TEMPLATE_SET_CONTENT_REPO_STATUS, { + // Normal operation - templates exist, user has write access + commit(TEMPLATE_SET_CONTENT_STORE_STATUS, { status: null, - canInitialize: false, - repoName: null + canWrite: true }); commit(TEMPLATE_SET_TEMPLATES, response.data.templates); } } catch (error) { - // Handle 404 (REPO_NOT_FOUND) - it's a STATE, not an error + // Handle 404 (NOT_FOUND) - it's a STATE, not an error if (error.response?.status === 404) { - const errorDetails = error.response.data?.details || ''; - const repoMatch = errorDetails.match(/Template repository '([^']+)'/); - - commit(TEMPLATE_SET_CONTENT_REPO_STATUS, { - status: 'REPO_NOT_FOUND', - canInitialize: false, - repoName: repoMatch ? repoMatch[1] : null + commit(TEMPLATE_SET_CONTENT_STORE_STATUS, { + status: 'NOT_FOUND', }); - console.log('Template repository not found:', repoMatch ? repoMatch[1] : 'unknown'); + console.log('Template repository not found'); commit(TEMPLATE_SET_TEMPLATES, []); } } }, - [TEMPLATE_FETCH_MODEL_BY_ID]: async (_, templateId) => { + [TEMPLATE_FETCH_MODEL_BY_ID]: async ({rootState}, templateId) => { + if (getProviderType(rootState.provider.selected) === providerTypes.desktop) { + window.electronAPI.fetchModelById(templateId); + return; + } const response = await templateApi.fetchModelByIdAsync(templateId); return response.data; }, @@ -121,11 +122,10 @@ const actions = { }; const mutations = { - [TEMPLATE_SET_CONTENT_REPO_STATUS]: (state, { status, canInitialize, repoName }) => { - state.contentRepo = { + [TEMPLATE_SET_CONTENT_STORE_STATUS]: (state, { status, canWrite }) => { + state.contentStore = { status: status || null, - canInitialize: canInitialize || false, - repoName: repoName || null + canWrite: canWrite || false }; }, @@ -134,10 +134,9 @@ const mutations = { }, [TEMPLATE_CLEAR]: (state) => { state.templates = []; - state.contentRepo = { + state.contentStore = { status: null, - canInitialize: false, - repoName: null + canWrite: false }; } }; @@ -145,9 +144,8 @@ const mutations = { const getters = { templates: (state) => state.templates, hasTemplates: (state) => state.templates.length > 0, - contentRepoStatus: (state) => state.contentRepo.status, - canInitializeRepo: (state) => state.contentRepo.canInitialize, - contentRepoName: (state) => state.contentRepo.repoName + contentStoreStatus: (state) => state.contentStore.status, + canWriteStore: (state) => state.contentStore.canWrite }; export default { diff --git a/td.vue/src/store/modules/threatmodel.js b/td.vue/src/store/modules/threatmodel.js index 22bd3aec6..4270dd55c 100644 --- a/td.vue/src/store/modules/threatmodel.js +++ b/td.vue/src/store/modules/threatmodel.js @@ -152,7 +152,7 @@ const actions = { [THREATMODEL_STASH]: ({ commit }) => commit(THREATMODEL_STASH), [THREATMODEL_NOT_MODIFIED]: ({ commit }) => commit(THREATMODEL_NOT_MODIFIED), [THREATMODEL_UPDATE]: ({ commit }, update) => commit(THREATMODEL_UPDATE, update), - [THREATMODEL_TEMPLATE_DOWNLOAD]: async ({ state }, templateMetadata) => { + [THREATMODEL_TEMPLATE_DOWNLOAD]: async ({ state,rootState}, templateMetadata) => { console.debug('Download template action'); const model = JSON.parse(JSON.stringify(state.data)); @@ -188,6 +188,10 @@ const actions = { // CALL THE NEW FUNCTION // Pass data and filename explicitly. No confusing 'state' wrapper needed. + if (getProviderType(rootState.provider.selected) === providerTypes.desktop) { + window.electronAPI.exportTemplate(templateData, fileName); + return; + } return await save.template(templateData, fileName); diff --git a/td.vue/src/views/ExportTemplate.vue b/td.vue/src/views/ExportTemplate.vue index b90d77a75..b35bf8392 100644 --- a/td.vue/src/views/ExportTemplate.vue +++ b/td.vue/src/views/ExportTemplate.vue @@ -61,7 +61,7 @@ :isPrimary="true" :onBtnClick="onSaveClick" icon="save" - :text="$t('template.saveTemplate')" + :text="$t('template.actions.save')" /> - - + + + + +
{{ $t('template.status.notConfigured.title') }}
+

{{ $t('template.status.notConfigured.desktop') }}

+ + + {{ $t('template.setupDialog.confirm') }} + +
+
+
+ + + + + +
{{ $t('template.status.notFound.title') }}
+

{{ $t('template.status.notFound.desktop') }}

+
+
+
+ + + -

{{ $t('template.repo.bootstrap.title') }}

-

{{ $t('template.repo.bootstrap.description') }}

- +

{{ $t('template.status.notInitialized.title') }}

+

{{ $t('template.bootstrap.description') }}

+ - {{ isBootstrapping ? $t('template.repo.bootstrap.bootstrapping') : $t('template.repo.bootstrap.action') }} + {{ isBootstrapping ? $t('template.actions.initializing') : + $t('template.actions.initialize') }}
- + + @@ -101,6 +134,49 @@ + + + + + + + + {{ $t('template.setupDialog.createNew') }} + + +
+ + {{ $t('template.setupDialog.useDefault') }} +
+ {{ $t('template.setupDialog.defaultPath') }} +
+ + {{ $t('template.setupDialog.chooseCustom') }} + +
+
+ +
+ + + + + {{ $t('template.setupDialog.selectExisting') }} +
+ {{ $t('template.setupDialog.selectExistingHint') }} +
+
+ +
+ + {{ $t('forms.cancel') }} + + + {{ $t('template.setupDialog.confirm') }} + +
+
+
@@ -109,6 +185,7 @@ import { mapGetters } from 'vuex'; import { v4 } from 'uuid'; import templateActions from '@/store/actions/template.js'; import schema from '@/service/schema/ajv.js'; +import isElectron from 'is-electron'; export default { name: 'ManageTemplates', @@ -121,16 +198,21 @@ export default { description: '', tags: [] }, - isBootstrapping: false + isBootstrapping: false, + // Setup modal state + setupMode: 'create', + createLocation: 'default' }; }, computed: { ...mapGetters({ templates: 'templates', - contentRepoStatus: 'contentRepoStatus', - canInitializeRepo: 'canInitializeRepo', - contentRepoName: 'contentRepoName' + contentStoreStatus: 'contentStoreStatus', + canWriteStore: 'canWriteStore' }), + isDesktop() { + return isElectron(); + }, filteredTemplates() { if (!this.searchQuery) return this.templates; const search = this.searchQuery.toLowerCase(); @@ -142,26 +224,49 @@ export default { } }, mounted() { - this.$store.dispatch(templateActions.fetchAll); - - }, methods: { async handleBootstrap() { this.isBootstrapping = true; + if (isElectron()) { + window.electronAPI.bootstrapTemplates(); + this.isBootstrapping = false; + return; + } + try { await this.$store.dispatch(templateActions.bootstrap); - this.$toast.success(this.$t('template.repo.bootstrap.success')); + this.$toast.success(this.$t('template.toast.initializeSuccess')); } catch (error) { console.error('Bootstrap failed:', error); - this.$toast.error(error.message || this.$t('template.repo.bootstrap.error')); + this.$toast.error(error.message || this.$t('template.errors.initializeFailed')); } finally { this.isBootstrapping = false; } }, + showSetupModal() { + // Reset form state + this.setupMode = 'create'; + this.createLocation = 'default'; + this.$bvModal.show('setup-template-modal'); + }, + + onSetupConfirm() { + if (this.setupMode === 'create') { + if (this.createLocation === 'default') { + window.electronAPI.setTemplateFolderDefault(); + } else { + window.electronAPI.setTemplateFolderCustom(); + } + } else { + window.electronAPI.setTemplateFolderExisting(); + } + this.$bvModal.hide('setup-template-modal'); + }, + onEditTemplate(template) { // Populate the edit form this.editingTemplate = template; @@ -175,6 +280,15 @@ export default { async onSaveEdit() { try { + if (isElectron()) { + await window.electronAPI.updateTemplate({ + id: this.editingTemplate.id, + name: this.editForm.name, + description: this.editForm.description, + tags: this.editForm.tags + }); + return; // Skip the rest since desktop handles it differently + } await this.$store.dispatch(templateActions.update, { name: this.editForm.name, description: this.editForm.description, @@ -182,8 +296,8 @@ export default { id: this.editingTemplate.id }); - this.$toast.success(this.$t('template.updateSuccess')); + this.$toast.success(this.$t('template.toast.updateSuccess')); this.$bvModal.hide('edit-template-modal'); } catch (error) { console.error('Error updating template:', error); @@ -243,18 +357,23 @@ export default { try { templateData.templateMetadata.id = v4(); templateData.templateMetadata.modelRef = v4(); + if (isElectron()) { + // For desktop, we need to save the template files to the filesystem + await window.electronAPI.importTemplate(templateData); + return; // Skip the rest since desktop handles it differently + } await this.$store.dispatch(templateActions.create, { template: templateData }); - this.$toast.success(this.$t('template.importSuccess')); + this.$toast.success(this.$t('template.toast.importSuccess')); } catch (error) { console.error('Error saving template:', error); - + // Check for duplicate template error - if (error.response?.status === 400 ) { - this.$toast.error(this.$t('template.errors.duplicateTemplate')); + if (error.response?.status === 400) { + this.$toast.error(this.$t('template.errors.duplicate')); } else { - this.$toast.error(this.$t('template.warnings.templateSave')); + this.$toast.error(this.$t('template.errors.importFailed')); } } }, @@ -274,8 +393,12 @@ export default { if (confirmed) { try { + if (isElectron()) { + await window.electronAPI.deleteTemplate(template.id); + return; // Skip the rest since desktop handles it differently + } await this.$store.dispatch(templateActions.delete, template.id); - this.$toast.success(this.$t('template.deleteSuccess')); + this.$toast.success(this.$t('template.toast.deleteSuccess')); } catch (error) { this.$toast.error(this.$t('template.errors.deleteFailed')); } diff --git a/td.vue/src/views/TemplateGallery.vue b/td.vue/src/views/TemplateGallery.vue index 1d434a88b..0269d1135 100644 --- a/td.vue/src/views/TemplateGallery.vue +++ b/td.vue/src/views/TemplateGallery.vue @@ -19,49 +19,41 @@
- - + + -
{{ $t('template.repo.notConfigured.title') }}
-

{{ $t('template.repo.notConfigured.userMessage') }}

+
{{ $t('template.status.notConfigured.title') }}
+

{{ $t(`template.status.notConfigured.${isDesktop ? 'desktop' : 'web'}`) }}

+ + + {{ $t('template.manage') }} +
- - + + -
{{ $t('template.repo.notFound.title') }}
-

{{ $t('template.repo.notFound.userMessage', { repoName: contentRepoName }) }}

+
{{ $t('template.status.notFound.title') }}
+

{{ $t(`template.status.notFound.${isDesktop ? 'desktop' : 'web'}`) }}

- - + + - -
{{ $t('template.repo.notInitialized.title') }}
-

{{ $t('template.repo.notInitialized.userMessage') }}

-
- -
-
- - - - - -
{{ $t('template.repo.notInitialized.title') }}
-

{{ $t('template.repo.notInitialized.adminMessage') }}

- + +
{{ $t('template.status.notInitialized.title') }}
+

{{ $t(`template.status.notInitialized.${canWriteStore ? 'admin' : 'user'}`) }}

+ {{ $t('template.manage') }}
-
@@ -72,8 +64,7 @@
- +
@@ -82,7 +73,7 @@ -
{{ template.name }}

{{ template.description }}

@@ -108,6 +99,7 @@ import tmActions from '@/store/actions/threatmodel.js'; import schema from '@/service/schema/ajv.js'; import { getProviderType } from '@/service/provider/providers'; import { providerTypes } from '@/service/provider/providerTypes'; +import isElectron from 'is-electron'; export default { name: 'TemplateGallery', @@ -119,9 +111,8 @@ export default { computed: { ...mapGetters({ templates: 'templates', - contentRepoStatus: 'contentRepoStatus', - canInitializeRepo: 'canInitializeRepo', - contentRepoName: 'contentRepoName' + contentStoreStatus: 'contentStoreStatus', + canWriteStore: 'canWriteStore' }), ...mapState({ selectedProvider: state => state.provider.selected @@ -130,7 +121,10 @@ export default { return getProviderType(this.selectedProvider); }, isLocalProvider() { - return this.providerType === providerTypes.local || this.providerType === providerTypes.desktop; + return this.providerType === providerTypes.local; + }, + isDesktop(){ + return isElectron(); }, filteredTemplates() { if (!this.searchQuery) return this.templates; @@ -147,9 +141,7 @@ export default { // Only fetch templates for git providers (requires authentication) // Local/desktop providers use file picker only if (!this.isLocalProvider) { - this.$store.dispatch(templateActions.fetchAll); - } }, methods: { @@ -225,11 +217,15 @@ export default { template.id ); + if (this.isDesktop) return; + // Load template (regenerates IDs and sets as current model, current model set in the action) await this.$store.dispatch(tmActions.templateLoad, { templateData: templateData.content }); + + // Route to repository/folder selection based on provider type, repo for git and folder for google drive const routeName = this.providerType === providerTypes.google ? `${this.providerType}Folder` diff --git a/td.vue/src/views/ThreatModel.vue b/td.vue/src/views/ThreatModel.vue index dffe3cead..8e37bf9fe 100644 --- a/td.vue/src/views/ThreatModel.vue +++ b/td.vue/src/views/ThreatModel.vue @@ -50,7 +50,7 @@ - + {{ $t('forms.exportTemplate') }} @@ -99,7 +99,6 @@ export default { TdThreatModelSummaryCard }, computed: mapState({ - enableTemplates: (state) => ['github', 'local'].includes(state.provider.selected), model: (state) => state.threatmodel.data, providerType: (state) => getProviderType(state.provider.selected), version: (state) => state.packageBuildVersion diff --git a/td.vue/tests/unit/service/api/templateApi.spec.js b/td.vue/tests/unit/service/api/templateApi.spec.js new file mode 100644 index 000000000..d84cf251d --- /dev/null +++ b/td.vue/tests/unit/service/api/templateApi.spec.js @@ -0,0 +1,104 @@ +import api from '@/service/api/api.js'; +import templateApi from '@/service/api/templateApi.js'; + +describe('service/templateApi.js', () => { + beforeEach(() => { + jest.spyOn(api, 'getAsync').mockImplementation(() => {}); + jest.spyOn(api, 'putAsync').mockImplementation(() => {}); + jest.spyOn(api, 'postAsync').mockImplementation(() => {}); + jest.spyOn(api, 'deleteAsync').mockImplementation(() => {}); + }); + + describe('import a template to gallery', () => { + const template = { + templateMetadata: { + id: 'foo', + name: 'Foo Template', + description: 'A foo template', + tags: ['foo'], + modelRef: 'bar', + }, + model: { title: 'Foo Model' }, + }; + beforeEach(async () => { + await templateApi.importTemplateAsync(template); + }); + + it('calls the import template endpoint', () => { + expect(api.postAsync).toHaveBeenCalledWith( + '/api/templates/import', + template + ); + }); + }); + + describe('fetch all templates', () => { + beforeEach(async () => { + await templateApi.fetchAllAsync(); + }); + + it('calls the fetch templates endpoint', () => { + expect(api.getAsync).toHaveBeenCalledWith('/api/templates'); + }); + }); + + describe('update template with metadata', () => { + const templateMetadata = { + id: 'foo', + name: 'Foo Template', + description: 'A foo template', + tags: ['foo'], + }; + + beforeEach(async () => { + await templateApi.updateTemplateAsync(templateMetadata); + }); + + it('calls the update template endpoint with encoded id', () => { + expect(api.putAsync).toHaveBeenCalledWith('/api/templates/foo', { + name: templateMetadata.name, + description: templateMetadata.description, + tags: templateMetadata.tags, + }); + }); + }); + + describe('delete template using id', () => { + const templateId = 'foo'; + + beforeEach(async () => { + await templateApi.deleteTemplateAsync(templateId); + }); + + it('calls the delete template endpoint with encoded id', () => { + expect(api.deleteAsync).toHaveBeenCalledWith('/api/templates/foo'); + }); + }); + + describe('Fetch model by template Id', () => { + const templateId = 'foo'; + + beforeEach(async () => { + await templateApi.fetchModelByIdAsync(templateId); + }); + + it('calls the fetch model endpoint with encoded template id', () => { + expect(api.getAsync).toHaveBeenCalledWith( + '/api/templates/foo/content' + ); + }); + }); + + describe('bootstrap template store', () => { + + beforeEach(async () => { + await templateApi.bootstrapAsync(); + }); + + it('calls the bootstrap endpoint', () => { + expect(api.postAsync).toHaveBeenCalledWith( + '/api/templates/bootstrap' + ); + }); + }); +}); diff --git a/td.vue/tests/unit/store/modules/auth.spec.js b/td.vue/tests/unit/store/modules/auth.spec.js index 25ed5a617..4b1d69e1a 100644 --- a/td.vue/tests/unit/store/modules/auth.spec.js +++ b/td.vue/tests/unit/store/modules/auth.spec.js @@ -1,4 +1,9 @@ -import { AUTH_CLEAR, AUTH_SET_JWT, AUTH_SET_LOCAL, LOGOUT } from '@/store/actions/auth.js'; +import { + AUTH_CLEAR, + AUTH_SET_JWT, + AUTH_SET_LOCAL, + LOGOUT, +} from '@/store/actions/auth.js'; import authModule, { clearState } from '@/store/modules/auth.js'; import { BRANCH_CLEAR } from '@/store/actions/branch.js'; import loginApi from '@/service/api/loginApi.js'; @@ -10,12 +15,13 @@ describe('store/modules/auth.js', () => { const getMocks = () => ({ commit: () => {}, dispatch: () => {}, - rootState: {} + rootState: {}, }); - const jwtBody = { foo: 'bar', user: { username: 'whatever' }}; + const jwtBody = { foo: 'bar', user: { username: 'whatever' } }; const apiResp = { - accessToken: 'blah.eyJmb28iOiJiYXIiLCJ1c2VyIjp7InVzZXJuYW1lIjoid2hhdGV2ZXIifX0.blah', - refreshToken: 'howrefreshing' + accessToken: + 'blah.eyJmb28iOiJiYXIiLCJ1c2VyIjp7InVzZXJuYW1lIjoid2hhdGV2ZXIifX0.blah', + refreshToken: 'howrefreshing', }; let mocks; @@ -64,14 +70,26 @@ describe('store/modules/auth.js', () => { expect(mocks.commit).toHaveBeenCalledWith(AUTH_SET_JWT, apiResp); }); - it('commits set local', () => { - authModule.actions[AUTH_SET_LOCAL](mocks); - expect(mocks.commit).toHaveBeenCalledWith(AUTH_SET_LOCAL); + describe('set local', () => { + it('commits set local with isDesktop true when desktop provider', () => { + mocks.rootState.provider = { selected: 'desktop' }; + authModule.actions[AUTH_SET_LOCAL](mocks); + expect(mocks.commit).toHaveBeenCalledWith(AUTH_SET_LOCAL, { + isDesktop: true, + }); + }); + + it('commits set local with isDesktop false when web provider', () => { + mocks.rootState.provider = { selected: 'local' }; + authModule.actions[AUTH_SET_LOCAL](mocks); + expect(mocks.commit).toHaveBeenCalledWith(AUTH_SET_LOCAL, { + isDesktop: false, + }); + }); }); - - describe('logout', () => { - describe('local provider', () => { + describe('logout', () => { + describe('local provider', () => { beforeEach(() => { mocks.rootState.provider = { selected: 'local' }; authModule.actions[LOGOUT](mocks); @@ -94,15 +112,19 @@ describe('store/modules/auth.js', () => { }); it('dispatches the REPOSITORY_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(REPOSITORY_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + REPOSITORY_CLEAR + ); }); it('dispatches the THREATMODEL_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(THREATMODEL_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + THREATMODEL_CLEAR + ); }); }); - describe('remote provider', () => { + describe('remote provider', () => { describe('without error', () => { beforeEach(() => { mocks.rootState.provider = { selected: 'github' }; @@ -111,27 +133,37 @@ describe('store/modules/auth.js', () => { }); it('calls the API', () => { - expect(loginApi.logoutAsync).toHaveBeenCalledWith(mocks.state.refreshToken); + expect(loginApi.logoutAsync).toHaveBeenCalledWith( + mocks.state.refreshToken + ); }); - + it('dispatches the AUTH_CLEAR action', () => { expect(mocks.dispatch).toHaveBeenCalledWith(AUTH_CLEAR); }); - + it('dispatches the BRANCH_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(BRANCH_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + BRANCH_CLEAR + ); }); - + it('dispatches the PROVIDER_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(PROVIDER_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + PROVIDER_CLEAR + ); }); - + it('dispatches the REPOSITORY_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(REPOSITORY_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + REPOSITORY_CLEAR + ); }); - + it('dispatches the THREATMODEL_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(THREATMODEL_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + THREATMODEL_CLEAR + ); }); }); @@ -147,31 +179,44 @@ describe('store/modules/auth.js', () => { }); it('calls the API', () => { - expect(loginApi.logoutAsync).toHaveBeenCalledWith(mocks.state.refreshToken); + expect(loginApi.logoutAsync).toHaveBeenCalledWith( + mocks.state.refreshToken + ); }); it('logs the error', () => { - expect(console.error).toHaveBeenCalledWith('Error calling logout api', err); + expect(console.error).toHaveBeenCalledWith( + 'Error calling logout api', + err + ); }); - + it('dispatches the AUTH_CLEAR action', () => { expect(mocks.dispatch).toHaveBeenCalledWith(AUTH_CLEAR); }); - + it('dispatches the BRANCH_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(BRANCH_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + BRANCH_CLEAR + ); }); - + it('dispatches the PROVIDER_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(PROVIDER_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + PROVIDER_CLEAR + ); }); - + it('dispatches the REPOSITORY_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(REPOSITORY_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + REPOSITORY_CLEAR + ); }); - + it('dispatches the THREATMODEL_CLEAR action', () => { - expect(mocks.dispatch).toHaveBeenCalledWith(THREATMODEL_CLEAR); + expect(mocks.dispatch).toHaveBeenCalledWith( + THREATMODEL_CLEAR + ); }); }); }); @@ -208,21 +253,26 @@ describe('store/modules/auth.js', () => { describe('set jwt', () => { describe('happy path', () => { beforeEach(() => { - authModule.mutations[AUTH_SET_JWT](authModule.state, apiResp); + authModule.mutations[AUTH_SET_JWT]( + authModule.state, + apiResp + ); }); - + it('sets the jwt', () => { expect(authModule.state.jwt).toEqual(apiResp.accessToken); }); - + it('sets the refreshToken', () => { - expect(authModule.state.refreshToken).toEqual(apiResp.refreshToken); + expect(authModule.state.refreshToken).toEqual( + apiResp.refreshToken + ); }); - + it('sets the user', () => { expect(authModule.state.user).toEqual(jwtBody.user); }); - + it('sets the jwtBody', () => { expect(authModule.state.jwtBody).toEqual(jwtBody); }); @@ -231,7 +281,10 @@ describe('store/modules/auth.js', () => { describe('with error', () => { it('re-throws the error', () => { expect(() => { - authModule.mutations[AUTH_SET_JWT](authModule.state, 'someBadData'); + authModule.mutations[AUTH_SET_JWT]( + authModule.state, + 'someBadData' + ); }).toThrow(); }); }); @@ -239,7 +292,35 @@ describe('store/modules/auth.js', () => { describe('set local', () => { beforeEach(() => { - authModule.mutations[AUTH_SET_LOCAL](authModule.state); + authModule.mutations[AUTH_SET_LOCAL](authModule.state, { + isDesktop: false, + }); + }); + + describe('set local', () => { + describe('when desktop', () => { + beforeEach(() => { + authModule.mutations[AUTH_SET_LOCAL](authModule.state, { + isDesktop: true, + }); + }); + + it('sets isAdmin to true', () => { + expect(authModule.state.user.isAdmin).toBe(true); + }); + }); + + describe('when web local session', () => { + beforeEach(() => { + authModule.mutations[AUTH_SET_LOCAL](authModule.state, { + isDesktop: false, + }); + }); + + it('sets isAdmin to false', () => { + expect(authModule.state.user.isAdmin).toBe(false); + }); + }); }); it('sets the username to "local-user"', () => { @@ -266,12 +347,14 @@ describe('store/modules/auth.js', () => { authModule.state.user = { username: 'foo' }; authModule.state.jwtBody = { exp: now + 5000, - iat: now - 1000 + iat: now - 1000, }; }); it('gets the username', () => { - expect(authModule.getters.username(authModule.state)).toEqual('foo'); + expect(authModule.getters.username(authModule.state)).toEqual( + 'foo' + ); }); it('gets an empty string when there is no username', () => { @@ -279,4 +362,4 @@ describe('store/modules/auth.js', () => { expect(authModule.getters.username(authModule.state)).toEqual(''); }); }); -}); \ No newline at end of file +}); diff --git a/td.vue/tests/unit/views/exportTemplate.spec.js b/td.vue/tests/unit/views/exportTemplate.spec.js new file mode 100644 index 000000000..1e0ffba70 --- /dev/null +++ b/td.vue/tests/unit/views/exportTemplate.spec.js @@ -0,0 +1,101 @@ +import { BootstrapVue, BFormInput, BFormTextarea } from 'bootstrap-vue'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; + +import ExportTemplate from '@/views/ExportTemplate.vue'; +import TdFormButton from '@/components/FormButton.vue'; +import tmActions from '@/store/actions/threatmodel.js'; + +describe('ExportTemplate.vue', () => { + let wrapper, localVue, mockRouter, mockStore, toast; + + beforeEach(() => { + toast = { error: jest.fn(), warning: jest.fn() }; + localVue = createLocalVue(); + localVue.use(BootstrapVue); + localVue.use(Vuex); + mockStore = new Vuex.Store({ + state: { + provider: { selected: 'local' }, + threatmodel: { + data: { + summary: { + title: 'Test Model', + description: 'Test description', + }, + }, + }, + }, + }); + mockStore.dispatch = jest.fn(); + mockRouter = { push: jest.fn() }; + wrapper = shallowMount(ExportTemplate, { + localVue, + store: mockStore, + mocks: { + $t: (key) => key, + $toast: toast, + $route: { params: {} }, + $router: mockRouter, + }, + }); + }); + + it('has the save template button and cancel button', () => { + expect(wrapper.findAllComponents(TdFormButton)).toHaveLength(2); + }); + it('has the input fields for template name', () => { + expect(wrapper.findComponent(BFormInput).exists()).toEqual(true); + }); + it('has a text field for template description', () => { + expect(wrapper.findComponent(BFormTextarea).exists()).toEqual(true); + }); + describe('onSaveClick', () => { + const mockEvt = { preventDefault: jest.fn() }; + + beforeEach(async () => { + await wrapper.vm.onSaveClick(mockEvt); + }); + + it('prevents default form submission', () => { + expect(mockEvt.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('dispatches templateDownload with the template metadata', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith( + tmActions.templateDownload, + { + name: 'Test Model', + description: 'Test description', + tags: [], + } + ); + }); + + it('navigates to the threat model view', () => { + expect(mockRouter.push).toHaveBeenCalledWith({ + name: 'localThreatModel', + params: {}, + }); + }); + }); + + describe('onCancelClick', () => { + const mockEvt = { preventDefault: jest.fn() }; + + beforeEach(() => { + wrapper.vm.onCancelClick(mockEvt); + }); + + it('prevents default', () => { + expect(mockEvt.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('navigates to the threat model route', () => { + expect(mockRouter.push).toHaveBeenCalledWith({ + name: 'localThreatModel', + params: {}, + }); + }); + }); +}); diff --git a/td.vue/tests/unit/views/templateGallery.spec.js b/td.vue/tests/unit/views/templateGallery.spec.js new file mode 100644 index 000000000..ad613fdde --- /dev/null +++ b/td.vue/tests/unit/views/templateGallery.spec.js @@ -0,0 +1,231 @@ +import { BootstrapVue, BJumbotron, BButton, BAlert, BListGroupItem } from 'bootstrap-vue'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; + +import TemplateGallery from '@/views/TemplateGallery.vue'; +import templateActions from '@/store/actions/template.js'; +import tmActions from '@/store/actions/threatmodel.js'; + +describe('TemplateGallery.vue', () => { + let wrapper, localVue, mockRouter, mockStore, toast; + + beforeEach(() => { + toast = { error: jest.fn(), warning: jest.fn() }; + localVue = createLocalVue(); + localVue.use(BootstrapVue); + localVue.use(Vuex); + mockStore = new Vuex.Store({ + state: { + provider: { selected: 'local' }, + template: { + templates: [], + contentStore: { + status: null, + canWrite: false, + }, + }, + }, + getters: { + templates: () => [], + contentStoreStatus: () => null, + canWriteStore: () => false, + }, + }); + mockStore.dispatch = jest.fn(); + mockRouter = { push: jest.fn() }; + wrapper = shallowMount(TemplateGallery, { + localVue, + store: mockStore, + mocks: { + $t: (key) => key, + $toast: toast, + $route: { params: {} }, + $router: mockRouter, + }, + }); + }); + + it('shows the jumbotron title', () => { + expect(wrapper.findComponent(BJumbotron).text()).toContain( + 'template.select' + ); + }); + + it('shows the jumbotron description', () => { + expect(wrapper.findComponent(BJumbotron).text()).toContain( + 'template.selectDescription' + ); + }); + + it('shows a button to start from a local template', () => { + expect(wrapper.findComponent(BButton).text()).toEqual( + 'template.startFromLocalTemplate' + ); + }); + it('shows an alert if no templates are available', () => { + expect(wrapper.findComponent(BAlert).text()).toContain( + 'template.templatesLocalSession' + ); + }); + + describe('when content store is NOT_CONFIGURED', () => { + beforeEach(() => { + mockStore = new Vuex.Store({ + state: { + provider: { selected: 'github' }, + }, + getters: { + templates: () => [], + contentStoreStatus: () => 'NOT_CONFIGURED', + canWriteStore: () => false, + }, + }); + mockStore.dispatch = jest.fn(); + wrapper = shallowMount(TemplateGallery, { + localVue, + store: mockStore, + mocks: { + $t: (key) => key, + $toast: toast, + $route: { params: {} }, + $router: mockRouter, + }, + }); + }); + + it('shows the not configured alert', () => { + expect(wrapper.findComponent(BAlert).text()).toContain( + 'template.status.notConfigured.title' + ); + }); + }); + describe('when content store is NOT_FOUND', () => { + beforeEach(() => { + mockStore = new Vuex.Store({ + state: { + provider: { selected: 'github' }, + }, + getters: { + templates: () => [], + contentStoreStatus: () => 'NOT_FOUND', + canWriteStore: () => false, + }, + }); + mockStore.dispatch = jest.fn(); + wrapper = shallowMount(TemplateGallery, { + localVue, + store: mockStore, + mocks: { + $t: (key) => key, + $toast: toast, + $route: { params: {} }, + $router: mockRouter, + }, + }); + }); + + it('shows the not configured alert', () => { + expect(wrapper.findComponent(BAlert).text()).toContain( + 'template.status.notFound.title' + ); + }); + }); + describe('when content store is NOT_INITIALIZED', () => { + beforeEach(() => { + mockStore = new Vuex.Store({ + state: { + provider: { selected: 'github' }, + }, + getters: { + templates: () => [], + contentStoreStatus: () => 'NOT_INITIALIZED', + canWriteStore: () => false, + }, + }); + mockStore.dispatch = jest.fn(); + wrapper = shallowMount(TemplateGallery, { + localVue, + store: mockStore, + mocks: { + $t: (key) => key, + $toast: toast, + $route: { params: {} }, + $router: mockRouter, + }, + }); + }); + + it('shows the not initialized alert', () => { + expect(wrapper.findComponent(BAlert).text()).toContain( + 'template.status.notInitialized.title' + ); + }); + }); + describe('when content repo is configured and templates are available', () => { + const templates = [ + { + id: '1', + name: 'Foo Template', + description: 'A foo template', + tags: ['foo'], + modelRef: 'foo', + }, + ]; + beforeEach(() => { + mockStore = new Vuex.Store({ + state: { + provider: { selected: 'github' }, + }, + getters: { + templates: () => templates, + contentStoreStatus: () => null, + canWriteStore: () => false, + }, + }); + mockStore.dispatch = jest.fn(); + wrapper = shallowMount(TemplateGallery, { + localVue, + store: mockStore, + mocks: { + $t: (key) => key, + $toast: toast, + $route: { params: {} }, + $router: mockRouter, + }, + }); + }); + + it('renders a list item for each template', () => { + expect(wrapper.findAllComponents(BListGroupItem)).toHaveLength(1); + }); + }); + + describe('when user clicks on a template', () => { + const templateMetadata = { + id: '1', + name: 'Foo Template', + description: 'A foo template', + tags: ['foo'], + modelRef: 'foo', + }; + + beforeEach(async () => { + mockStore.dispatch = jest.fn().mockResolvedValue({ content: {} }); + await wrapper.vm.onTemplateClick(templateMetadata); + }); + + it('dispatches fetchModelById with the template id', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith( + templateActions.fetchModelById, + templateMetadata.id + ); + }); + + it('dispatches templateLoad with the template content', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith( + tmActions.templateLoad, + { templateData: {} } + ); + }); + }); +});