diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..9a2392f --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + env: { + node: true, + "vue/setup-compiler-macros": true, + }, + extends: [ + "eslint:recommended", + "plugin:vue/vue3-recommended", + "@vue/eslint-config-typescript", + "prettier", + ], +}; diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 3691132..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - env: { - node: true, - "vue/setup-compiler-macros": true, - }, - extends: ["eslint:recommended", "plugin:vue/vue3-recommended", "prettier"], - rules: { - // https://eslint.vuejs.org/user-guide/#does-not-work-well-with-script-setup - "vue/script-setup-uses-vars": "error", - }, -}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 174d232..6c19d66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,12 @@ jobs: node-version: [18.x, 20.x] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: yarn install + - run: yarn lint - run: yarn test - run: yarn build diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c9526..d20ad80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,30 @@ As this project is a user-facing application, the places in the semantic version ## [Unreleased] +## [1.3.0] (2024-02-12) + +### Added + +- Converted code to TypeScript [#113](https://github.com/spraakbanken/mink-frontend/issues/113) +- Lint check in CI workflow script + +### Changed + +- Renamed one-word components, for linting + +### Fixed + +- Status panel is empty for new corpus, until page is reloaded [#151](https://github.com/spraakbanken/mink-frontend/issues/151) +- Word wrapping in log output in status panel [#139](https://github.com/spraakbanken/mink-frontend/issues/139) +- Upgraded dependencies + ## [1.2.0] (2024-01-17) ### Added - Option to enable named entity recognition - Check if admin mode is enabled on load +- Build check in CI workflow script ### Changed @@ -123,7 +141,8 @@ The frontend is now open to the general public! This version allows users to: Code changes up until this point are not documented other than in the git commit log. -[unreleased]: https://github.com/spraakbanken/mink-frontend/compare/v1.2.0...HEAD +[unreleased]: https://github.com/spraakbanken/mink-frontend/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/spraakbanken/mink-frontend/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/spraakbanken/mink-frontend/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/spraakbanken/mink-frontend/compare/v1.0.5...v1.1.0 [1.0.5]: https://github.com/spraakbanken/mink-frontend/compare/v1.0.4...v1.0.5 diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 0000000..4a2ffe3 --- /dev/null +++ b/env.d.ts @@ -0,0 +1,14 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BACKEND_URL: string; + readonly VITE_AUTH_URL: string; + readonly VITE_KORP_URL: string; + readonly VITE_STRIX_URL: string; + readonly VITE_MATOMO_URL: string; + readonly VITE_MATOMO_ID?: number; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..d9f966a --- /dev/null +++ b/index.d.ts @@ -0,0 +1 @@ +declare module "vue-matomo"; diff --git a/index.html b/index.html index 1d0741b..441bea0 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -9,6 +9,6 @@
- + diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 8da7df9..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "include": ["./src/**/*"], - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - } -} diff --git a/package.json b/package.json index 56d4d48..f54d565 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,21 @@ { "name": "mink-frontend", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "scripts": { "dev": "vite", - "build": "vite build", + "build": "yarn typecheck && vite build", "serve": "vite preview", + "typecheck": "vue-tsc --noEmit", "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src", - "format": "prettier . --write '!.yarn' --ignore-path .gitignore", + "format": "prettier . --write --ignore-path .gitignore", "test": "vitest", "coverage": "vitest run --coverage" }, "engines": { "node": ">=18.0.0" }, + "type": "module", "dependencies": { "@formkit/vue": "^1.0.0-beta.14", "@fortawesome/fontawesome-svg-core": "^6.1.1", @@ -22,9 +24,10 @@ "@fortawesome/vue-fontawesome": "^3.0.1", "@modyfi/vite-plugin-yaml": "^1.0.4", "@vitejs/plugin-vue": "^5", - "@vueuse/core": "^9.6.0", + "@vue/tsconfig": "^0.5.1", + "@vueuse/core": "^10.7.2", "autoprefixer": "^10.4.7", - "axios": "^0.21.4", + "axios": "^1", "eslint": "^8.15.0", "filesize": "^10.0.6", "js-yaml": "^4.1.0", @@ -32,19 +35,24 @@ "pinia": "^2.0.28", "rollup-plugin-visualizer": "^5.6.0", "tailwindcss": "^3.1.4", - "vite": "^4.0.5", - "vite-plugin-rewrite-all": "1.0.1 || ^1.0.3", + "typescript": "^5.3.3", + "vite": "^5", "vue": "^3.4", "vue-i18n": "9", "vue-matomo": "^4.2.0", - "vue-router": "4" + "vue-router": "4", + "vue-tsc": "^1.8.27" }, "devDependencies": { - "@testing-library/vue": "^6.6.1", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-vue": "^8.0", - "happy-dom": "^7.7.0", - "prettier": "^2.4.0", - "vitest": "^0.25.3" + "@testing-library/vue": "^8.0.1", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.14.202", + "@types/node": "^20.10.6", + "@vue/eslint-config-typescript": "^12.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-vue": "^9.0", + "happy-dom": "^12.10.3", + "prettier": "^3.2.4", + "vitest": "^1.2.2" } } diff --git a/postcss.config.js b/postcss.config.js index 12a703d..2aa7205 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/src/App.vue b/src/App.vue index d088e6b..b0bbf31 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,31 +1,15 @@ - - - - + diff --git a/src/api/api.js b/src/api/api.js deleted file mode 100644 index 50fcec7..0000000 --- a/src/api/api.js +++ /dev/null @@ -1,186 +0,0 @@ -import Axios from "axios"; -import { ensureTrailingSlash } from "@/util"; -import pick from "lodash/pick"; - -/** Mink backend API client */ -class MinkApi { - /** Creates the client instance */ - constructor() { - this.axios = Axios.create({ - baseURL: ensureTrailingSlash(import.meta.env.VITE_BACKEND_URL), - withCredentials: true, - }); - } - - /** Sets a JWT token which is then used to authenticate API requests. */ - setJwt(jwt) { - this.jwt = jwt; - this.axios.defaults.headers["Authorization"] = jwt - ? `Bearer ${jwt}` - : undefined; - } - - async getInfo() { - const response = await this.axios.get("info"); - return pick(response.data, [ - "status_codes", - "importer_modules", - "file_size_limits", - "recommended_file_size", - ]); - } - - async listCorpora() { - const response = await this.axios.get("list-corpora"); - return response.data.corpora; - } - - async createCorpus() { - const response = await this.axios.post("create-corpus"); - return response.data.corpus_id; - } - - async removeCorpus(corpusId) { - const response = await this.axios.delete("remove-corpus", { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - async uploadConfig(corpusId, config) { - const configFile = new File([config], "config.yaml", { type: "text/yaml" }); - const formData = new FormData(); - formData.append("files[]", configFile); - const response = await this.axios.put("upload-config", formData, { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - async downloadSourceFile(corpusId, filename, binary = false) { - const response = await this.axios.get("download-sources", { - params: { corpus_id: corpusId, file: filename, zip: false }, - responseType: binary ? "arraybuffer" : "text", - }); - return response.data; - } - - async downloadSourceText(corpusId, filename) { - const response = await this.axios.get("download-source-text", { - params: { corpus_id: corpusId, file: filename }, - }); - return response.data; - } - - async uploadSources(corpusId, files) { - const formData = new FormData(); - [...files].forEach((file) => formData.append("files[]", file)); - const response = await this.axios.put("upload-sources", formData, { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - async removeSource(corpusId, name) { - const response = await this.axios.delete("remove-sources", { - params: { corpus_id: corpusId, remove: name }, - }); - return response.data; - } - - async downloadConfig(corpusId) { - const response = await this.axios.get("download-config", { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - /** - * @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/resourceinfo - * - * An info record has {resource: ..., job: ...}. - * - * If corpus_id is given, the response is an info record. If not, it contains - * `resources` which is a list of info records. - */ - async resourceInfo(corpusId) { - const response = await this.axios.get("resource-info", { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - async runSparv(corpusId) { - const response = await this.axios - .put("run-sparv", null, { params: { corpus_id: corpusId } }) - // Errors are okay. - .catch((reason) => reason.response); - return response.data; - } - - async abortJob(corpusId) { - const response = await this.axios.post("abort-job", null, { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - async listExports(corpusId) { - const response = await this.axios.get("list-exports", { - params: { corpus_id: corpusId }, - }); - return response.data.contents; - } - - async downloadExports(corpusId) { - const response = await this.axios.get("download-exports", { - params: { corpus_id: corpusId }, - responseType: "blob", - }); - return response.data; - } - - async downloadExportFile(corpusId, path) { - const response = await this.axios.get("download-exports", { - params: { - corpus_id: corpusId, - file: path, - zip: false, - }, - responseType: "blob", - }); - return response.data; - } - - async installKorp(corpusId) { - const response = await this.axios.put("install-korp", null, { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - async installStrix(corpusId) { - const response = await this.axios.put("install-strix", null, { - params: { corpus_id: corpusId }, - }); - return response.data; - } - - async adminModeStatus() { - const response = await this.axios.get("admin-mode-status"); - return response.data.admin_mode_status; - } - - async adminModeOn() { - const response = await this.axios.post("admin-mode-on"); - return response.data; - } - - async adminModeOff() { - const response = await this.axios.post("admin-mode-off"); - return response.data; - } -} - -/** API client singleton instance. */ -export const api = new MinkApi(); diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 0000000..b55f879 --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,231 @@ +import Axios, { type AxiosInstance } from "axios"; +import { ensureTrailingSlash } from "@/util"; +import type { + MinkResponse, + InfoData, + ListCorporaData, + CreateCorpusData, + ResourceInfoAllData, + ResourceInfoOneData, + JobState, + JobType, + ListExportsData, + AdminModeStatusData, +} from "./api.types"; + +/** Mink backend API client */ +class MinkApi { + /** An instance of the Axios HTTP client. */ + axios: AxiosInstance; + + /** A JWT token used to authenticate API requests. */ + jwt: string | undefined; + + /** Creates the client instance */ + constructor() { + this.axios = Axios.create({ + baseURL: ensureTrailingSlash(import.meta.env.VITE_BACKEND_URL), + withCredentials: true, + }); + } + + /** Sets a JWT token which is then used to authenticate API requests. */ + setJwt(jwt?: string) { + this.jwt = jwt; + this.axios.defaults.headers["Authorization"] = jwt ? `Bearer ${jwt}` : null; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Documentation/operation/APIinfo */ + async getInfo() { + const response = await this.axios.get>("info"); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Corpora/operation/listcorpora */ + async listCorpora() { + const response = + await this.axios.get>("list-corpora"); + return response.data.corpora; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Corpora/operation/createcorpus */ + async createCorpus() { + const response = + await this.axios.post>("create-corpus"); + return response.data.corpus_id; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Corpora/operation/removecorpus */ + async removeCorpus(corpusId: string) { + const response = await this.axios.delete("remove-corpus", { + params: { corpus_id: corpusId }, + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Config/operation/uploadconfig */ + async uploadConfig(corpusId: string, config: string) { + const configFile = new File([config], "config.yaml", { type: "text/yaml" }); + const formData = new FormData(); + formData.append("files[]", configFile); + const response = await this.axios.put( + "upload-config", + formData, + { params: { corpus_id: corpusId } }, + ); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Sources/operation/downloadsources */ + async downloadSources(corpusId: string, filename: string, binary = false) { + const response = await this.axios.get("download-sources", { + params: { corpus_id: corpusId, file: filename, zip: false }, + responseType: binary ? "arraybuffer" : "text", + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Exports/operation/downloadsourcetext */ + async downloadSourceText(corpusId: string, filename: string) { + const response = await this.axios.get("download-source-text", { + params: { corpus_id: corpusId, file: filename }, + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Sources/operation/uploadsources */ + async uploadSources(corpusId: string, files: FileList) { + const formData = new FormData(); + [...files].forEach((file) => formData.append("files[]", file)); + const response = await this.axios.put( + "upload-sources", + formData, + { params: { corpus_id: corpusId } }, + ); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Sources/operation/removesources */ + async removeSource(corpusId: string, name: string) { + const response = await this.axios.delete("remove-sources", { + params: { corpus_id: corpusId, remove: name }, + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Config/operation/downloadconfig */ + async downloadConfig(corpusId: string) { + const response = await this.axios.get("download-config", { + params: { corpus_id: corpusId }, + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/resourceinfo */ + async resourceInfoAll() { + const response = + await this.axios.get>("resource-info"); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/resourceinfo */ + async resourceInfoOne(corpusId: string) { + const response = await this.axios.get>( + "resource-info", + { params: { corpus_id: corpusId } }, + ); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/runSparv */ + async runSparv(corpusId: string) { + const response = await this.axios + .put>("run-sparv", null, { + params: { corpus_id: corpusId }, + }) + // Errors are okay. + .catch((reason) => reason.response); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/abortjob */ + async abortJob(corpusId: string) { + const response = await this.axios.post< + MinkResponse> + >("abort-job", null, { + params: { corpus_id: corpusId }, + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Exports/operation/listexports */ + async listExports(corpusId: string) { + const response = await this.axios.get>( + "list-exports", + { params: { corpus_id: corpusId } }, + ); + return response.data.contents; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Exports/operation/downloadexports */ + async downloadExports(corpusId: string) { + const response = await this.axios.get("download-exports", { + params: { corpus_id: corpusId }, + responseType: "blob", + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Manage-Exports/operation/downloadexports */ + async downloadExportFile(corpusId: string, path: string) { + const response = await this.axios.get("download-exports", { + params: { corpus_id: corpusId, file: path, zip: false }, + responseType: "blob", + }); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/installinKorp */ + async installKorp(corpusId: string) { + const response = await this.axios.put>( + "install-korp", + null, + { params: { corpus_id: corpusId } }, + ); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/installinStrix */ + async installStrix(corpusId: string) { + const response = await this.axios.put>( + "install-strix", + null, + { params: { corpus_id: corpusId } }, + ); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Admin-Mode/operation/adminmodestatus */ + async adminModeStatus() { + const response = + await this.axios.get>( + "admin-mode-status", + ); + return response.data.admin_mode_status; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Admin-Mode/operation/adminmodeon */ + async adminModeOn() { + const response = await this.axios.post("admin-mode-on"); + return response.data; + } + + /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Admin-Mode/operation/adminmodeoff */ + async adminModeOff() { + const response = await this.axios.post("admin-mode-off"); + return response.data; + } +} + +/** API client singleton instance. */ +export default new MinkApi(); diff --git a/src/api/api.types.ts b/src/api/api.types.ts new file mode 100644 index 0000000..b8ae8d4 --- /dev/null +++ b/src/api/api.types.ts @@ -0,0 +1,127 @@ +import type { ByLang } from "@/util.types"; + +/** Properties common to most backend responses */ +export type MinkResponse = T & { + status: "success" | "error"; + return_code: string; + message: string; +}; + +/** Data in the info response */ +export type InfoData = { + status_codes: InfoDataSection<{ + name: JobState; + description: string; + }>; + importer_modules: InfoDataSection<{ + file_extension: `.${string}`; + importer: string; + }>; + file_size_limits: InfoDataSection<{ + name: "max_content_length" | "max_file_length" | "max_corpus_length"; + description: string; + value: number; + }>; + recommended_file_size: InfoDataSection<{ + name: "max_file_length" | "min_file_length"; + description: string; + value: number; + }>; +}; + +/** A structure for a section in the backend info response */ +type InfoDataSection = { + info: string; + data: T[]; +}; + +/** Data in the list-corpora response */ +export type ListCorporaData = { + corpora: string[]; +}; + +/** Data in the create-corpus response */ +export type CreateCorpusData = { + corpus_id: string; +}; + +/** Data in the resource-info response, if no corpus_id param is given */ +export type ResourceInfoAllData = { + resources: MinkResponse[]; +}; + +/** Data in the resource-info response, if the corpus_id param is given */ +export type ResourceInfoOneData = ResourceInfo; + +/** Data about a resource and its job status */ +export type ResourceInfo = { + resource: ResourceData; + job: CorpusStatus; +}; + +/** Basic data about a resource */ +export type ResourceData = { + type: "corpus"; + id: string; + public_id: string; + name: ByLang; + source_files: FileMeta[]; +}; + +/** Job status for a resource */ +// There's more but we're not using everything. +export type CorpusStatus = { + current_process: JobType | null; + status: Record; + warnings: string; + errors: string; + sparv_output: string; + installed_korp: boolean; + installed_strix: boolean; + /** ISO 8601 date */ + started: string | null; + /** ISO 8601 date */ + last_run_started: string | ""; + /** ISO 8601 date */ + last_run_ended: string | ""; + /** Queue number, starting at 1 */ + priority: number | ""; + /** Percentage of job completion, if running */ + progress: `${number}%` | ""; +}; + +/** File metadata */ +export type FileMeta = { + /** ISO 8601 date (with timezone offset) of last modification */ + last_modified: string; + /** Filename */ + name: string; + /** File path, relative to some directory */ + path: string; + /** File size (bytes) */ + size: number; + /** MIME type e.g. "text/xml" */ + type: string; +}; + +/** Indicates a job type that the backend can do */ +export type JobType = "sparv" | "korp" | "strix" | "sync2storage"; + +/** The states a job can have */ +export type JobState = + | "none" // "Process does not exist" + | "waiting" // "Waiting to be processed" + | "running" // "Process is running" + | "done" // "Process has finished" + | "error" // "An error occurred in the process" + | "aborted"; // "Process was aborted by the user" + +/** Data in the list-exports response */ +export type ListExportsData = { + contents: FileMeta[]; +}; + +/** Data in the admin-mode-status response */ +export type AdminModeStatusData = { + admin_mode_status: boolean; +}; diff --git a/src/api/backend.composable.js b/src/api/backend.composable.ts similarity index 59% rename from src/api/backend.composable.js rename to src/api/backend.composable.ts index c1ae424..618cc5b 100644 --- a/src/api/backend.composable.js +++ b/src/api/backend.composable.ts @@ -1,11 +1,7 @@ -import { reactive } from "vue"; import { useI18n } from "vue-i18n"; -import { api } from "./api"; +import api from "./api"; import useSpin from "@/spin/spin.composable"; -const info = reactive({}); -api.getInfo().then((values) => Object.assign(info, values)); - /** Wraps API endpoints with Spin. */ export default function useMinkBackend() { const { spin } = useSpin(); @@ -17,102 +13,104 @@ export default function useMinkBackend() { const createCorpus = () => spin(api.createCorpus(), t("corpus.creating"), "create"); - const deleteCorpus = (corpusId) => + const deleteCorpus = (corpusId: string) => spin( api.removeCorpus(corpusId), t("corpus.deleting"), - `corpus/${corpusId}` + `corpus/${corpusId}`, ); - const loadConfig = (corpusId) => + const loadConfig = (corpusId: string) => spin( api.downloadConfig(corpusId), t("config.loading"), - `corpus/${corpusId}/config` + `corpus/${corpusId}/config`, ); - const saveConfig = (corpusId, configYaml) => + const saveConfig = (corpusId: string, configYaml: string) => spin( api.uploadConfig(corpusId, configYaml), t("corpus.configuring"), - `corpus/${corpusId}/config` + `corpus/${corpusId}/config`, ); - const downloadSource = (corpusId, filename, binary) => + const downloadSource = (corpusId: string, filename: string, binary = false) => spin( - api.downloadSourceFile(corpusId, filename, binary), + api.downloadSources(corpusId, filename, binary), t("source.downloading"), - `corpus/${corpusId}/sources/${filename}` + `corpus/${corpusId}/sources/${filename}`, ); - const downloadPlaintext = (corpusId, filename) => + const downloadPlaintext = (corpusId: string, filename: string) => spin( api.downloadSourceText(corpusId, filename), t("source.downloading_plain"), - `corpus/${corpusId}/sources/${filename}` + `corpus/${corpusId}/sources/${filename}`, ); - const uploadSources = (corpusId, files) => + const uploadSources = (corpusId: string, files: FileList) => spin( api.uploadSources(corpusId, files), t("source.uploading", files.length), - `corpus/${corpusId}/sources` + `corpus/${corpusId}/sources`, ); - const deleteSource = (corpusId, filename) => + const deleteSource = (corpusId: string, filename: string) => spin( api.removeSource(corpusId, filename), t("source.deleting"), - `corpus/${corpusId}/sources` + `corpus/${corpusId}/sources`, ); - /** @see https://ws.spraakbanken.gu.se/ws/mink/api-doc#tag/Process-Corpus/operation/resourceinfo */ - const resourceInfo = (corpusId) => + const resourceInfoAll = () => + spin(api.resourceInfoAll(), t("resource.loading"), "corpora"); + + const resourceInfoOne = (corpusId: string) => spin( - api.resourceInfo(corpusId), + api.resourceInfoOne(corpusId), t("resource.loading"), - corpusId ? `corpus/${corpusId}/job` : "corpora" + `corpus/${corpusId}/job`, ); - const runJob = (corpusId) => + const runJob = (corpusId: string) => spin(api.runSparv(corpusId), t("job.starting"), `corpus/${corpusId}/job`); - const installKorp = (corpusId) => + const installKorp = (corpusId: string) => spin( api.installKorp(corpusId), t("job.installing"), - `corpus/${corpusId}/job` + `corpus/${corpusId}/job`, ); - const installStrix = (corpusId) => + const installStrix = (corpusId: string) => spin( api.installStrix(corpusId), t("job.installing"), - `corpus/${corpusId}/job` + `corpus/${corpusId}/job`, ); - const abortJob = (corpusId) => + const abortJob = (corpusId: string) => spin(api.abortJob(corpusId), t("job.aborting"), `corpus/${corpusId}/job`); - const loadExports = (corpusId) => + const loadExports = (corpusId: string) => spin( api.listExports(corpusId), t("exports.loading"), - `corpus/${corpusId}/exports` + `corpus/${corpusId}/exports`, ); - const downloadExports = (corpusId) => + const downloadExports = (corpusId: string) => spin( api.downloadExports(corpusId), t("exports.downloading"), - `corpus/${corpusId}/exports` + `corpus/${corpusId}/exports`, ); - const downloadExportFiles = (corpusId, filename) => + const downloadExportFiles = (corpusId: string, filename: string) => spin( api.downloadExportFile(corpusId, filename), t("exports.downloading"), - `corpus/${corpusId}/exports` + `corpus/${corpusId}/exports`, ); const checkAdminMode = () => spin(api.adminModeStatus(), null, "admin-mode"); @@ -124,7 +122,6 @@ export default function useMinkBackend() { spin(api.adminModeOff(), "Disabling admin mode", "admin-mode"); return { - info, loadCorpusIds, createCorpus, deleteCorpus, @@ -134,7 +131,8 @@ export default function useMinkBackend() { downloadPlaintext, uploadSources, deleteSource, - resourceInfo, + resourceInfoAll, + resourceInfoOne, runJob, installKorp, installStrix, diff --git a/src/api/backendInfo.composable.js b/src/api/backendInfo.composable.js deleted file mode 100644 index 047b3d1..0000000 --- a/src/api/backendInfo.composable.js +++ /dev/null @@ -1,18 +0,0 @@ -import { computed, reactive } from "vue"; -import { api } from "./api"; - -const info = reactive({}); -api.getInfo().then((values) => Object.assign(info, values)); - -/** Wraps API endpoints with Spin. */ -export default function useMinkBackendInfo() { - const hasInfo = computed(() => Object.keys(info).length); - - const findInfo = (field, name) => - info[field]?.data.find((item) => item.name == name).value; - return { - info, - hasInfo, - findInfo, - }; -} diff --git a/src/api/backendInfo.composable.ts b/src/api/backendInfo.composable.ts new file mode 100644 index 0000000..e15d348 --- /dev/null +++ b/src/api/backendInfo.composable.ts @@ -0,0 +1,58 @@ +import { computed, readonly, ref } from "vue"; +import api from "./api"; +import type { InfoData } from "./api.types"; +import { keyBy, objsToDict } from "@/util"; + +export type Info = { + status_codes: Record; + importer_modules: Record; + file_size_limits: Record< + InfoData["file_size_limits"]["data"][0]["name"], + { + description: string; + value: number; + } + >; + recommended_file_size: Record< + InfoData["recommended_file_size"]["data"][0]["name"], + { + description: string; + value: number; + } + >; +}; + +const info = ref(); +api.getInfo().then((original: InfoData) => { + const status_codes = objsToDict( + original.status_codes.data, + "name", + "description", + ); + const importer_modules = objsToDict( + original.importer_modules.data, + "file_extension", + "importer", + ); + const file_size_limits = keyBy(original.file_size_limits.data, "name"); + const recommended_file_size = keyBy( + original.recommended_file_size.data, + "name", + ); + info.value = { + status_codes, + importer_modules, + file_size_limits, + recommended_file_size, + }; +}); + +/** Wraps API endpoints with Spin. */ +export default function useMinkBackendInfo() { + const hasInfo = computed(() => !!Object.keys(info).length); + + return { + info: readonly(info), + hasInfo, + }; +} diff --git a/src/api/corpusConfig.test.ts b/src/api/corpusConfig.test.ts new file mode 100644 index 0000000..1ab101c --- /dev/null +++ b/src/api/corpusConfig.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "vitest"; +import yaml from "js-yaml"; +import { + makeConfig, + parseConfig, + type ConfigOptions, + validateConfig, +} from "./corpusConfig"; + +describe("makeConfig", () => { + test("sets minimal info", async () => { + const yaml = await makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "txt", + }); + expect(yaml).toContain("id: mink-abc123"); + expect(yaml).toContain("swe: Nyheter"); + expect(yaml).toContain("eng: News"); + expect(yaml).toContain("importer: text_import:parse"); + expect(yaml).toContain("- :saldo.baseform2 as lemma"); + }); + + test("sets segmenter", async () => { + const yaml = await makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "txt", + sentenceSegmenter: "linebreaks", + }); + expect(yaml).toContain("sentence_segmenter: linebreaks"); + }); + + test("sets text_annotation", async () => { + const yaml = await makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "xml", + textAnnotation: "article", + }); + expect(yaml).toContain("text_annotation: article"); + expect(yaml).toContain("- article as text"); + }); + + test("sets pdf annotations", async () => { + const yaml = await makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "pdf", + }); + expect(yaml).toContain("- text"); + expect(yaml).toContain("- page:number"); + }); + + test("requires complete timespan", async () => { + const yamlFrom = () => + makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "pdf", + datetimeFrom: "2024-02-01", + }); + expect(yamlFrom).rejects.toThrowError(); + + const yamlTo = () => + makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "pdf", + datetimeTo: "2024-02-01", + }); + expect(yamlTo).rejects.toThrowError(); + }); + + test("sets timespan info", async () => { + const yaml = await makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "pdf", + datetimeFrom: "2000-01-01", + datetimeTo: "2023-12-31", + }); + expect(yaml).toContain("datetime_from: :misc.datefrom"); + expect(yaml).toContain("datetime_to: :misc.dateto"); + expect(yaml).toContain("datetime_informat: '%Y-%m-%d'"); + expect(yaml).toContain("value: '2000-01-01'"); + expect(yaml).toContain("value: '2023-12-31'"); + expect(yaml).toContain("- :dateformat.datefrom"); + }); + + test("sets NER info", async () => { + const yaml = await makeConfig("mink-abc123", { + name: { swe: "Nyheter", eng: "News" }, + format: "pdf", + enableNer: true, + }); + expect(yaml).toContain("- swener.ne:swener.name"); + }); +}); + +describe("parseConfig", () => { + test("handle minimal info", async () => { + const configYaml = await yaml.dump({ + metadata: { name: { swe: "Nyheter", eng: "News" } }, + import: { importer: "text_import:parse" }, + }); + const config = await parseConfig(configYaml); + expect(config.name).toStrictEqual({ swe: "Nyheter", eng: "News" }); + expect(config.format).toBe("txt"); + }); + + test("requires format", async () => { + const configYaml = await yaml.dump({ + metadata: { name: { swe: "Nyheter", eng: "News" } }, + }); + expect(() => parseConfig(configYaml)).rejects.toThrowError(); + }); + + test("requires name", async () => { + const configYaml = await yaml.dump({ + import: { importer: "text_import:parse" }, + }); + expect(() => parseConfig(configYaml)).rejects.toThrowError(); + }); + + test("handle full info", async () => { + const configYaml = await yaml.dump({ + metadata: { + name: { swe: "Nyheter", eng: "News" }, + description: { swe: "Senaste nytt", eng: "Latest news" }, + }, + import: { + importer: "xml_import:parse", + text_annotation: "article", + }, + segment: { sentence_segmenter: "linebreaks" }, + custom_annotations: [ + { params: { out: ":misc.datefrom", value: "2000-01-01" } }, + { params: { out: ":misc.dateto", value: "2023-12-31" } }, + ], + export: { + annotations: ["swener.ne"], + }, + }); + const config = await parseConfig(configYaml); + const expected: ConfigOptions = { + format: "xml", + name: { swe: "Nyheter", eng: "News" }, + description: { swe: "Senaste nytt", eng: "Latest news" }, + textAnnotation: "article", + sentenceSegmenter: "linebreaks", + datetimeFrom: "2000-01-01", + datetimeTo: "2023-12-31", + enableNer: true, + }; + expect(config).toStrictEqual(expected); + }); +}); + +describe("validateConfig", () => { + test("missing text annotation", () => { + const options: ConfigOptions = { + name: { swe: "Nyheter", eng: "News" }, + format: "xml", + }; + + // Config can be handled + makeConfig("mink-abc123", options); + + // But is not ready for annotation + expect(() => validateConfig(options)).toThrow(); + }); +}); diff --git a/src/api/corpusConfig.js b/src/api/corpusConfig.ts similarity index 62% rename from src/api/corpusConfig.js rename to src/api/corpusConfig.ts index 31645ec..d077804 100644 --- a/src/api/corpusConfig.js +++ b/src/api/corpusConfig.ts @@ -1,6 +1,23 @@ const yaml = import("js-yaml").then((m) => m.default); -const FORMATS = { +import type { ByLang } from "@/util.types"; +import type { ConfigSentenceSegmenter, SparvConfig } from "./sparvConfig.types"; + +export type FileFormat = "txt" | "xml" | "odt" | "docx" | "pdf"; + +/** Frontend-internal format of a Sparv config. */ +export type ConfigOptions = { + format: FileFormat; + name: ByLang; + description?: ByLang; + textAnnotation?: string; + sentenceSegmenter?: ConfigSentenceSegmenter; + datetimeFrom?: string; + datetimeTo?: string; + enableNer?: boolean; +}; + +const FORMATS: Record = { txt: "text_import:parse", xml: "xml_import:parse", odt: "odt_import:parse", @@ -10,9 +27,9 @@ const FORMATS = { export const FORMATS_EXT = Object.keys(FORMATS); -export const SEGMENTERS = ["linebreaks"]; +export const SEGMENTERS: ConfigSentenceSegmenter[] = ["linebreaks"]; -export async function makeConfig(id, options) { +export async function makeConfig(id: string, options: ConfigOptions) { const { format, name, @@ -23,7 +40,8 @@ export async function makeConfig(id, options) { datetimeTo, enableNer, } = options; - const config = { + + const config: Partial = { metadata: { id, name, @@ -31,6 +49,10 @@ export async function makeConfig(id, options) { }, }; + if (!format) { + throw new TypeError("File format must be set."); + } + config.import = { importer: FORMATS[format], }; @@ -106,57 +128,78 @@ export async function makeConfig(id, options) { }, }, ]; - config.export.annotations.push( + config.export.annotations!.push( ":dateformat.datefrom", ":dateformat.dateto", ":dateformat.timefrom", - ":dateformat.timeto" + ":dateformat.timeto", ); } // Enable named entity recognition. if (enableNer) { - config.export.annotations.push( + config.export.annotations!.push( "swener.ne", "swener.ne:swener.name", "swener.ne:swener.ex", "swener.ne:swener.type", - "swener.ne:swener.subtype" + "swener.ne:swener.subtype", ); } - return (await yaml).dump(config); + return (await yaml).dump(config as SparvConfig); } -export function emptyConfig() { +export function emptyConfig(): ConfigOptions { return { name: { swe: "", eng: "" }, description: { swe: "", eng: "" }, + format: "txt", }; } -export async function parseConfig(configYaml) { - const config = (await yaml).load(configYaml); +/** + * Parse a Sparv config YAML string. + * + * May throw all kinds of errors, the sky is the limit. + */ +export async function parseConfig(configYaml: string): Promise { + const config = (await yaml).load(configYaml) as any; + + if (!config) + throw new TypeError(`Parsing config failed, returned "${config}"`); + + // Throw specific errors if required parts are missing. + const format = (Object.keys(FORMATS) as FileFormat[]).find( + (ext) => FORMATS[ext as FileFormat] == config.import.importer, + ); + if (!format) + throw new TypeError(`Unrecognized importer: "${config.import.importer}"`); + + const name = config.metadata.name; + if (!name) + throw new TypeError(`Name missing in metadata: ${config.metadata}`); + if (!name.swe || !name.eng) + throw new TypeError(`Name must contain swe and eng: ${name}`); + return { - name: config.metadata?.name, - description: config.metadata?.description, - format: Object.keys(FORMATS).find( - (ext) => FORMATS[ext] == config.import?.importer - ), + format, + name, + description: config.metadata.description, textAnnotation: config.import?.text_annotation, sentenceSegmenter: config.segment?.sentence_segmenter, datetimeFrom: config.custom_annotations?.find( - (a) => a.params.out == ":misc.datefrom" - ).params.value, + (a: any) => a.params.out == ":misc.datefrom", + )?.params.value, datetimeTo: config.custom_annotations?.find( - (a) => a.params.out == ":misc.dateto" - ).params.value, + (a: any) => a.params.out == ":misc.dateto", + )?.params.value, enableNer: config.export?.annotations?.includes("swener.ne"), }; } /** Check if the config looks ready to run. May throw anything. */ -export function validateConfig(config) { +export function validateConfig(config: ConfigOptions) { if (!config.format) { throw new TypeError("Format missing"); } diff --git a/src/api/sparvConfig.types.ts b/src/api/sparvConfig.types.ts new file mode 100644 index 0000000..e9aaec7 --- /dev/null +++ b/src/api/sparvConfig.types.ts @@ -0,0 +1,58 @@ +import type { ByLang } from "@/util.types"; + +/** Models a Sparv config file */ +export type SparvConfig = { + metadata: ConfigMetadata; + import: ConfigImport; + segment: ConfigSegment; + export: ConfigExport; + dateformat?: ConfigDateformat; + custom_annotations?: ConfigCustomAnnotations[]; + korp?: ConfigKorp; + sbx_strix?: ConfigStrix; +}; + +type ConfigMetadata = { + id: string; + name: ByLang; + description?: ByLang; +}; + +type ConfigImport = { + importer: string; + text_annotation?: string; +}; + +type ConfigSegment = { + sentence_segmenter?: ConfigSentenceSegmenter; +}; + +export type ConfigSentenceSegmenter = "linebreaks"; + +type ConfigExport = { + annotations?: string[]; + source_annotations?: string[]; +}; + +type ConfigDateformat = { + datetime_from?: string; + datetime_to?: string; + datetime_informat?: string; +}; + +type ConfigCustomAnnotations = { + annotator: string; + params: { + out: string; + chunk: string; + value: string; + }; +}; + +type ConfigKorp = { + protected?: boolean; +}; + +type ConfigStrix = { + modes?: Array<{ name: string }>; +}; diff --git a/src/auth/LoginButton.vue b/src/auth/LoginButton.vue index c18a717..ca7758b 100644 --- a/src/auth/LoginButton.vue +++ b/src/auth/LoginButton.vue @@ -1,17 +1,18 @@ + + - - diff --git a/src/auth/Login.vue b/src/auth/LoginView.vue similarity index 90% rename from src/auth/Login.vue rename to src/auth/LoginView.vue index 9e730c1..25392ec 100644 --- a/src/auth/Login.vue +++ b/src/auth/LoginView.vue @@ -1,10 +1,10 @@ + + - - diff --git a/src/auth/Signup.vue b/src/auth/SignupView.vue similarity index 79% rename from src/auth/Signup.vue rename to src/auth/SignupView.vue index 9db9a61..bd6bc97 100644 --- a/src/auth/Signup.vue +++ b/src/auth/SignupView.vue @@ -1,5 +1,5 @@ - @@ -8,7 +8,7 @@ import PageTitle from "@/components/PageTitle.vue";
{{ $t("signup") }}
-
@@ -19,9 +19,9 @@ import PageTitle from "@/components/PageTitle.vue";

{{ $t("signup.existing.tip") }}

-
+ -
+ -
+
diff --git a/src/auth/auth.composable.js b/src/auth/auth.composable.ts similarity index 76% rename from src/auth/auth.composable.js rename to src/auth/auth.composable.ts index 70a7e86..aa5ce6a 100644 --- a/src/auth/auth.composable.js +++ b/src/auth/auth.composable.ts @@ -1,10 +1,10 @@ import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import { useRouter, useRoute } from "vue-router"; -import useSpin from "@/spin/spin.composable"; import { checkLogin } from "./auth"; -import { api } from "@/api/api"; -import { canAdmin, decodeJwt } from "./jwtSb"; +import api from "@/api/api"; +import useSpin from "@/spin/spin.composable"; +import { canAdmin, decodeJwt, type JwtSbPayload } from "./jwtSb"; /** * JWT request slot. @@ -12,16 +12,16 @@ import { canAdmin, decodeJwt } from "./jwtSb"; * Globally, we make only one JWT request at a time. A second request while the * first one is pending can re-use that same request promise. */ -let jwtPromise = null; +let jwtPromise: Promise | undefined = undefined; /** * Timeout slot for refreshing on expiration. * * After fetching the JWT, the expiration time is read, and a timeout is set to refresh the JWT accordingly. */ -let refreshTimer = null; +let refreshTimer: NodeJS.Timeout | undefined = undefined; -const jwt = ref(null); +const jwt = ref(undefined); export function useAuth() { const router = useRouter(); @@ -29,17 +29,19 @@ export function useAuth() { const { spin, pending } = useSpin(); const { t } = useI18n(); - const isAuthenticated = computed(() => !!jwt.value); - const payload = computed(() => decodeJwt(jwt.value)?.payload); - const canUserAdmin = computed( - () => payload.value && canAdmin(payload.value, "other", "mink-app") + const isAuthenticated = computed(() => !!jwt.value); + const payload = computed(() => + jwt.value ? decodeJwt(jwt.value)?.payload : undefined, + ); + const canUserAdmin = computed( + () => !!payload.value && canAdmin(payload.value, "other", "mink-app"), ); const canUserWrite = computed(() => isAuthenticated.value); /** Indicates whether a jwt request is currently loading. */ const isAuthenticating = computed(() => pending.value.includes("jwt")); /** If not authenticated, redirect to the login page. */ - async function requireAuthentication(callback) { + async function requireAuthentication(callback?: () => void) { // First, ensure the jwt has been fetched. if (!jwt.value) { await refreshJwt(); @@ -58,14 +60,14 @@ export function useAuth() { async function refreshJwt() { async function fetchAndStoreJwt() { // Fetch JWT. - const jwtValue = await checkLogin(); + const jwtValue = (await checkLogin()) || undefined; // Store it to make username etc available to GUI. jwt.value = jwtValue; // Register it with the API client. api.setJwt(jwtValue); // Schedule next request shortly before expiration time. - clearTimeout(refreshTimer); + refreshTimer && clearTimeout(refreshTimer); if (payload.value && payload.value.exp) { const timeoutMs = (payload.value.exp - 10) * 1000 - Date.now(); refreshTimer = setTimeout(refreshJwt, timeoutMs); @@ -76,7 +78,7 @@ export function useAuth() { jwtPromise || spin(fetchAndStoreJwt(), t("jwt.refreshing"), "jwt"); await jwtPromise; // Free the slot for subsequent refreshes. - jwtPromise = null; + jwtPromise = undefined; } return { diff --git a/src/auth/auth.js b/src/auth/auth.ts similarity index 96% rename from src/auth/auth.js rename to src/auth/auth.ts index f794fb5..df8ca6f 100644 --- a/src/auth/auth.js +++ b/src/auth/auth.ts @@ -7,7 +7,7 @@ export function getLoginUrl(redirectLocation = "") { redirectLocation = pathJoin( window.location.origin, import.meta.env.BASE_URL, - redirectLocation + redirectLocation, ); return AUTH_BASE + `login?redirect=${redirectLocation}`; } diff --git a/src/auth/jwtSb.js b/src/auth/jwtSb.js deleted file mode 100644 index 6e52769..0000000 --- a/src/auth/jwtSb.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @file Common handling of JWTs from the SB Auth system. - */ - -export function decodeJwt(jwt) { - if (!jwt) return undefined; - const parts = jwt.split("."); - if (parts.length != 3) { - throw new RangeError(`Not a JWT: "${jwt}"`); - } - - return { - header: JSON.parse(atob(parts[0])), - payload: JSON.parse(atob(parts[1])), - }; -} - -export function assertValidPayload(payload) { - const isValid = - payload && - payload.scope && - payload.levels && - payload.levels.ADMIN && - payload.levels.WRITE && - payload.levels.READ; - - if (!isValid) { - throw new TypeError("Malformed jwt payload: " + JSON.stringify(payload)); - } -} - -export function canAdmin(payload, resourceType, resourceName) { - assertValidPayload(payload); - return payload.scope[resourceType]?.[resourceName] >= payload.levels.ADMIN; -} - -export function canWrite(payload, resourceType, resourceName) { - assertValidPayload(payload); - return payload.scope[resourceType]?.[resourceName] >= payload.levels.WRITE; -} - -export function canRead(payload, resourceType, resourceName) { - assertValidPayload(payload); - return payload.scope[resourceType]?.[resourceName] >= payload.levels.READ; -} diff --git a/src/auth/jwtSb.ts b/src/auth/jwtSb.ts new file mode 100644 index 0000000..9bdc58f --- /dev/null +++ b/src/auth/jwtSb.ts @@ -0,0 +1,79 @@ +/** + * @file Common handling of JWTs from the SB Auth system. + */ + +export type JwtSb = { + header: any; + payload: JwtSbPayload; +}; + +export type JwtSbPayload = { + name: string; + email: string; + /** Token expiration time as a UNIX timestamp */ + exp: number; + /** First level keys are resource types, second level keys are resource ids and values are permission levels */ + scope: Record>; + /** Defines permission levels */ + levels: { + READ: number; + WRITE: number; + ADMIN: number; + }; +}; + +export function decodeJwt(jwt: string): JwtSb | undefined { + if (!jwt) return undefined; + const parts = jwt.split("."); + if (parts.length != 3) { + throw new RangeError(`Not a JWT: "${jwt}"`); + } + + return { + header: JSON.parse(atob(parts[0])), + payload: JSON.parse(atob(parts[1])), + }; +} + +export function assertValidPayload(payload: any): payload is JwtSbPayload { + const isValid = + payload && + payload.scope && + payload.levels && + payload.levels.ADMIN && + payload.levels.WRITE && + payload.levels.READ; + + if (!isValid) { + throw new TypeError("Malformed jwt payload: " + JSON.stringify(payload)); + } + + return true; +} + +export function canAdmin( + payload: JwtSbPayload, + resourceType: string, + resourceName: string, +) { + assertValidPayload(payload); + return payload.scope[resourceType]?.[resourceName] >= payload.levels.ADMIN; +} + +export function canWrite( + payload: JwtSbPayload, + resourceType: string, + resourceName: string, +) { + assertValidPayload(payload); + return payload.scope[resourceType]?.[resourceName] >= payload.levels.WRITE; +} + +export function canRead( + payload: JwtSbPayload, + resourceType: string, + resourceName: string, +) { + assertValidPayload(payload); + return payload.scope[resourceType]?.[resourceName] >= payload.levels.READ; +} diff --git a/src/components/ActionButton.vue b/src/components/ActionButton.vue index 63fdee1..498e690 100644 --- a/src/components/ActionButton.vue +++ b/src/components/ActionButton.vue @@ -1,9 +1,11 @@ + + - - diff --git a/src/components/HelpBox.vue b/src/components/HelpBox.vue index 705e8d8..d92d1fa 100644 --- a/src/components/HelpBox.vue +++ b/src/components/HelpBox.vue @@ -1,9 +1,7 @@ -