Merge branch 'development'

This commit is contained in:
Methapon2001 2023-12-06 10:44:13 +07:00
commit 842b3a10b9
No known key found for this signature in database
GPG key ID: 849924FEF46BD132
46 changed files with 1236 additions and 1201 deletions

View file

@ -26,3 +26,7 @@ coverage
*.njsproj
*.sln
*.sw?
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View file

@ -1,5 +1,6 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": true
}
"singleQuote": true,
"trailingComma": "all"
}

View file

@ -1,16 +1,17 @@
# EDM Frontend
Enterprise Document Management (EDM) ส่วน frontend
Enterprise Document Management (EDM) ส่วน frontend
# ส่วนประกอบ
- Vue.js (TypeScript) ใช้ Quasar Framework เป็น UI Framework หลักสำหรับการพัฒนา
- pnpm เป็น package manager
- Library Document ใช้ typedoc, typedoc-plugin-vue
```
pnpm i -D typedoc typedoc-plugin-vue
```
สำหรับ User
## โครงสร้างโฟลเดอร์
@ -91,20 +92,8 @@ npm run build
npm run test:unit
```
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
```sh
npm run test:e2e:dev
```
This runs the end-to-end tests against the Vite development server.
It is much faster than the production build.
But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
```sh
npm run build
npm run test:e2e
```
### Lint with [ESLint](https://eslint.org/)

View file

@ -1,8 +0,0 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://localhost:4173',
},
})

View file

@ -1,8 +0,0 @@
// https://docs.cypress.io/api/introduction/api.html
describe('My First Test', () => {
it('visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'You did it!')
})
})

View file

@ -1,10 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./**/*", "../support/**/*"],
"compilerOptions": {
"isolatedModules": false,
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
}
}

View file

@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View file

@ -1,39 +0,0 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
export {}

View file

@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View file

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />

View file

@ -8,8 +8,6 @@
"build": "run-p type-check build-only",
"preview": "vite preview",
"test:unit": "vitest --environment jsdom --root src/",
"test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
@ -22,6 +20,7 @@
"@tsconfig/node18": "^18.2.2",
"axios": "^1.6.2",
"keycloak-js": "^23.0.0",
"nanoid": "^5.0.4",
"pinia": "^2.1.7",
"quasar": "^2.14.0",
"vite-plugin-pwa": "^0.17.2",
@ -29,6 +28,7 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@playwright/test": "^1.40.1",
"@quasar/vite-plugin": "^1.6.0",
"@rushstack/eslint-patch": "^1.6.0",
"@types/jsdom": "^21.1.6",
@ -39,15 +39,12 @@
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.2",
"@vue/tsconfig": "^0.4.0",
"cypress": "^13.6.0",
"eslint": "^8.54.0",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.18.1",
"jsdom": "^23.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.1.0",
"sass": "^1.69.5",
"start-server-and-test": "^2.0.3",
"typedoc": "^0.25.4",
"typedoc-plugin-vue": "^1.1.0",
"typescript": "~5.3.2",

View file

@ -0,0 +1,77 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"realm": "EDM",
"auth-server-url": "https://edm-id.frappet.synology.me/",
"ssl-required": "external",
"resource": "EDM-V1",
"public-client": true,
"confidential-port": 0
}
"realm": "EDM",
"auth-server-url": "https://edm-id.frappet.synology.me/",
"ssl-required": "external",
"resource": "EDM-V1",
"public-client": true,
"confidential-port": 0
}

View file

@ -12,12 +12,12 @@ const props = withDefaults(
</script>
<template>
<q-dialog
persistent
transition-show="scale"
transition-hide="scale"
:model-value="props.open"
@update:model-value="(v) => $emit('update:open', v)"
<q-dialog
persistent
transition-show="scale"
transition-hide="scale"
:model-value="props.open"
@update:model-value="(v) => $emit('update:open', v)"
>
<q-card style="width: 400px">
<q-card-section>
@ -36,7 +36,7 @@ const props = withDefaults(
label="ยกเลิก"
flat
v-close-popup
@click="() => ($emit('update:open', !open))"
@click="() => $emit('update:open', !open)"
id="dialogDeleteClose"
/>

View file

@ -190,7 +190,9 @@ const file = ref<File | undefined>()
<div class="q-mt-md">
<q-select
outlined
dense
:model-value="category"
label="กดปุ่มEnterเพื่อเพิ่ม"
use-input
use-chips
multiple
@ -200,7 +202,7 @@ const file = ref<File | undefined>()
@filter="filterCategory"
style="width: 250px"
@update:model-value="(v) => $emit('update:category', v)"
id="inputCategory"
data-testid="filterDataCategory"
>
<template v-slot:no-option>
<q-item>
@ -216,6 +218,8 @@ const file = ref<File | undefined>()
<div class="q-mt-md">
<q-select
outlined
dense
label="กดปุ่มEnterเพื่อเพิ่ม"
use-input
use-chips
multiple
@ -225,7 +229,7 @@ const file = ref<File | undefined>()
:model-value="props.keyword"
@update:model-value="(v) => $emit('update:keyword', v)"
@new-value="createKeyword"
id="inputKeyword"
data-testid="filterDataKeyWord"
>
</q-select>
</div>

View file

@ -18,7 +18,7 @@ const props = withDefaults(
},
)
const DEPT_NAME = ['ตู้เอกสาร', 'ลิ้นชัก', 'แฟ้ม', 'แฟ้มย่อย'] as const
const { getFileInfo, getFileNameFormat } = useFileInfoStore()
const { getFileInfo } = useFileInfoStore()
const { currentFolder, currentFile, currentDept, currentPath } =
storeToRefs(useTreeDataStore())
const {
@ -183,7 +183,10 @@ async function submitFileForm(
{{ DEPT_NAME[currentDept] }}
</div>
<div class="grid q-mt-md">
<div v-for="value in currentFolder" key="value.name">
<div
v-for="(value, index) in currentFolder"
:data-pathname="value.pathname"
>
<div
:style="{
position: 'relative',
@ -215,6 +218,7 @@ async function submitFileForm(
v-if="props.action"
>
<file-item-action
:nameId="value.pathname"
@delete="() => triggerFolderDelete(value.pathname)"
@edit="() => triggerFolderEdit(value.name, value.pathname)"
/>
@ -243,6 +247,7 @@ async function submitFileForm(
padding: currentDept > 2 ? '.5rem 0' : '.5rem',
}"
@click="() => triggerFolderCreate()"
id="triggerFolderCreateFileItem"
>
<div
class="q-px-md flex items-center justify-center"
@ -286,7 +291,7 @@ async function submitFileForm(
เอกสาร
</div>
<div class="grid q-mt-md">
<div v-for="value in currentFile">
<div v-for="(value, index) in currentFile" :data-pathname="value.pathname">
<div
:style="{
position: 'relative',
@ -299,6 +304,7 @@ async function submitFileForm(
}"
class="box"
@click="() => getFileInfo(value)"
:id="`getFileInfoFileItem${index}`"
>
<div class="q-px-md flex items-center justify-center">
<file-icon
@ -312,6 +318,7 @@ async function submitFileForm(
v-if="props.action"
>
<file-item-action
:nameId="value.pathname"
@edit="
() =>
triggerFileEdit(
@ -347,6 +354,7 @@ async function submitFileForm(
}"
class="dashed"
@click="() => triggerFileCreate()"
id="triggerFileCreateFileItem"
>
<div
class="q-px-md flex items-center justify-center"

View file

@ -1,22 +1,34 @@
<script lang="ts" setup>
defineEmits(['edit', 'delete'])
const props =
defineProps<{
nameId: string
}>()
</script>
<template>
<q-btn @click.stop icon="more_vert" color="grey" flat dense>
<q-btn @click.stop icon="more_vert" color="grey" flat dense :data-testid="`action${props.nameId}`">
<q-menu auto-close>
<q-list dense>
<q-item clickable @click="() => $emit('edit')">
<q-item clickable @click="() => $emit('edit')" id="FileltemActionEdit">
<q-item-section>
<div class="row items-center white ">
<div class="row items-center white">
<q-icon name="edit" color="positive" />
<span class="q-ml-sm">แกไข</span>
</div>
</q-item-section>
</q-item>
<q-item clickable @click="() => $emit('delete')">
<q-item
clickable
@click="() => $emit('delete')"
id="FileltemActiondelete"
>
<q-item-section>
<div class="row items-center white ">
<div class="row items-center white">
<q-icon name="delete" color="negative" />
<span class="q-ml-sm">ลบ</span>
</div>
@ -27,15 +39,11 @@ defineEmits(['edit', 'delete'])
</q-btn>
</template>
<style scoped>
.white {
.white {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
display: block;
}
</style>

View file

@ -2,21 +2,52 @@
import { storeToRefs } from 'pinia'
import { useSearchDataStore } from '@/stores/searched-data'
import { useFileInfoStore } from '@/stores/file-info-data'
import { useTreeDataStore } from '@/stores/tree-data'
import DialogDelete from './DialogDelete.vue'
import UploadExistDialog from './UploadExistDialog.vue'
import FileForm from './FileForm.vue'
import FileItemAction from '@/components/FileItemAction.vue'
import FileIcon from '@/components/FileIcon.vue'
import type { QTableProps } from 'quasar'
import { onMounted, ref, watch } from 'vue'
defineProps<{
viewMode: 'view_list' | 'view_module'
}>()
const { foundFile } = storeToRefs(useSearchDataStore())
const props = withDefaults(
defineProps<{
action: boolean
viewMode: 'view_list' | 'view_module'
}>(),
{
action: false,
},
)
const { foundFile, isActFoundFile } = storeToRefs(useSearchDataStore())
const { getFileInfo, getSize, getType } = useFileInfoStore()
const { updateFile, deleteFile, checkFile } = useTreeDataStore()
const keywordList = ref<string[]>([])
const categoryList = ref<string[]>([])
const selectKeyword = ref<string[]>([])
const selectCategory = ref<string[]>([])
const filterFoundFile = ref<any>()
const fileExistNotification = ref<boolean>(false)
const fileFormError = ref<{ fileExist?: boolean }>({})
const deleteFormType = ref<'deleteFile'>('deleteFile')
const dialogDeleteState = ref<boolean>(false)
const deleteFormPath = ref<string>('')
const fileFormType = ref<'edit'>('edit')
const fileFormState = ref<boolean>(false)
const fileFormPath = ref<string>('')
const fileFormData = ref<{
file?: File
title?: string
description?: string
keyword?: string[]
category?: string[]
}>({})
const columns: QTableProps['columns'] = [
{
name: 'name',
@ -53,6 +84,82 @@ const columns: QTableProps['columns'] = [
},
]
const currentParam = ref<Parameters<typeof submitFileForm>[0]>()
async function submitFileForm(
value: {
mode: 'create' | 'edit'
file?: File
title: string
description: string
keyword: string[]
category: string[]
},
force = false,
) {
currentParam.value = value
if (value.file && checkFile(value.file.name) && !force) {
fileExistNotification.value = true
return
}
if (value.mode === 'edit') {
await updateFile(
fileFormPath.value,
{
title: value.title,
description: value.description,
keyword: value.keyword,
category: value.category,
},
value.file,
)
setTimeout(() => {
isActFoundFile.value = true
}, 300)
}
fileFormData.value = {}
fileFormState.value = false
currentParam.value = undefined
}
function triggerFileEdit(
value: {
title: string
description: string
keyword: string[]
category: string[]
},
pathname: string,
) {
fileFormState.value = true
fileFormType.value = 'edit'
fileFormPath.value = pathname
fileFormData.value = {
title: value.title,
description: value.description,
keyword: value.keyword,
category: value.category,
}
}
function triggerFileDelete(pathname: string) {
deleteFormType.value = 'deleteFile'
deleteFormPath.value = pathname
dialogDeleteState.value = !dialogDeleteState.value
}
function confirmDelete() {
if (deleteFormType) {
deleteFile(deleteFormPath.value)
setTimeout(() => {
isActFoundFile.value = true
}, 300)
}
}
function filterSearch() {
function updateList() {
keywordList.value = []
@ -101,7 +208,7 @@ onMounted(() => {
</script>
<template>
<div class="row grid q-pt-md q-gutter-sm">
<div class="row grid q-py-md q-gutter-sm">
<q-select
outlined
dense
@ -111,6 +218,7 @@ onMounted(() => {
:options="keywordList"
style="width: 100%"
label="คำสำคัญ"
class="custom-selection"
/>
<q-select
outlined
@ -124,7 +232,7 @@ onMounted(() => {
/>
</div>
<div v-if="viewMode === 'view_list' && foundFile.length > 0">
<div v-if="props.viewMode === 'view_list' && foundFile.length > 0">
<div class="grid q-mt-md">
<div v-for="(value, index) in filterFoundFile" :key="value.title">
<div
@ -138,7 +246,8 @@ onMounted(() => {
maxWidth: '100%',
}"
class="box"
@click="() => getFileInfo(filterFoundFile[index])"
@click="() => getFileInfo(foundFile[index])"
:id="`getFileInfoFileSearched${index}`"
>
<div class="q-px-md flex items-center justify-center">
<file-icon
@ -146,6 +255,28 @@ onMounted(() => {
:fileMimeType="value.fileType ? value.fileType : 'unknow'"
/>
</div>
<div
class="absolute"
style="top: 0.5rem; right: 0.5rem"
v-if="props.action"
>
<file-item-action
:nameId="value.pathname"
@edit="
() =>
triggerFileEdit(
{
title: value.title,
description: value.description,
keyword: value.keyword,
category: value.category,
},
value.pathname,
)
"
@delete="() => triggerFileDelete(value.pathname)"
/>
</div>
<div
class="text-overflow-handle block q-px-md text-center"
style="max-width: 100%"
@ -157,7 +288,10 @@ onMounted(() => {
</div>
</div>
<div v-if="viewMode === 'view_module' && foundFile.length > 0">
<div
v-if="props.viewMode === 'view_module' && foundFile.length > 0"
class="q-gutter-sm"
>
<q-table
flat
bordered
@ -183,7 +317,7 @@ onMounted(() => {
</q-td>
</template>
<template v-slot:body-cell-actions="sizeData">
<template v-slot:body-cell-actions="actionData">
<q-td class="justify-center">
<div>
<q-icon class="q-ma-sm" name="info" size="2em" color="primary" />
@ -192,9 +326,29 @@ onMounted(() => {
self="center right"
:offset="[5, 1]"
>
{{ getSize(sizeData.row.fileSize) }}
{{ getSize(actionData.row.fileSize) }}
</q-tooltip>
</div>
<div v-if="props.action">
<q-btn
flat
color="positive"
dense
icon="edit"
@click="
() => triggerFileEdit(actionData.row, actionData.row.pathname)
"
id="listViewFileEdit"
/>
<q-btn
flat
color="negative"
dense
icon="delete"
@click="() => triggerFileDelete(actionData.row.pathname)"
id="listViewFileDelete"
/>
</div>
</q-td>
</template>
</q-table>
@ -203,6 +357,26 @@ onMounted(() => {
<div class="q-mt-md" v-if="foundFile.length == 0">
<span>ไมพบรายการทนหา</span>
</div>
<file-form
:mode="fileFormType"
:error="fileFormError"
v-model:open="fileFormState"
v-model:title="fileFormData.title"
v-model:description="fileFormData.description"
v-model:keyword="fileFormData.keyword"
v-model:category="fileFormData.category"
@filechange="(name: string) => (fileFormError.fileExist = checkFile(name))"
@submit="submitFileForm"
/>
<upload-exist-dialog
v-model:notification="fileExistNotification"
@confirm="() => currentParam && submitFileForm(currentParam, true)"
@cancel="() => (currentParam = undefined)"
/>
<dialog-delete v-model:open="dialogDeleteState" @confirm="confirmDelete" />
</template>
<style scoped lang="scss">
@ -238,4 +412,10 @@ onMounted(() => {
.grid .box {
position: relative;
}
.justify-center {
display: flex;
justify-content: center;
align-items: center;
}
</style>

View file

@ -78,6 +78,7 @@ onUnmounted(() => window.addEventListener('keydown', keydown))
$emit('update:open', !open)
}
"
id="folderFormIconClose"
/>
</q-toolbar>
@ -98,11 +99,12 @@ onUnmounted(() => window.addEventListener('keydown', keydown))
offensiveWord = checkOffensiveWord(v as string)
}
"
id="folderNameInput"
/>
</section>
<section :style="{ display: 'flex', gap: '.5rem' }">
<q-btn label="บันทึก" type="submit" color="primary" />
<q-btn label="บันทึก" type="submit" color="primary" id="FoldeSubmit" />
<q-btn
label="ยกเลิก"
type="reset"
@ -114,6 +116,7 @@ onUnmounted(() => window.addEventListener('keydown', keydown))
$emit('update:open', false), reset()
}
"
id="folderBtnCancel"
/>
</section>
</q-form>

View file

@ -32,6 +32,7 @@ watch(visible, () => {
v-close-popup
label="ปิด"
@click="() => (visible = !visible)"
id="globalErrorClose"
/>
</q-card-actions>
</q-card>

View file

@ -6,13 +6,13 @@ import { useTreeDataStore, type TreeDataFolder } from '@/stores/tree-data'
import { useFileInfoStore } from '@/stores/file-info-data'
import FileIcon from '@/components/FileIcon.vue'
import DialogDelete from '@/components/DialogDelete.vue'
import UploadExistDialog from './UploadExistDialog.vue'
import FileForm from './FileForm.vue'
import FolderForm from './FolderForm.vue'
const { getFormatDate, getSize, getType, getFileInfo } = useFileInfoStore()
const { listDataFile, listDataFolder, currentDept, currentPath } = storeToRefs(
useTreeDataStore()
)
const { listDataFile, listDataFolder, currentDept, currentPath } =
storeToRefs(useTreeDataStore())
const {
createFolder,
editFolder,
@ -34,18 +34,18 @@ const currentLevel = computed(() =>
currentDept.value === 0
? 'ตู้จัดเก็บเอกสาร'
: currentDept.value === 1
? 'ลิ้นชัก'
: currentDept.value === 2
? 'แฟ้ม'
: 'แฟ้มย่อย'
? 'ลิ้นชัก'
: currentDept.value === 2
? 'แฟ้ม'
: 'แฟ้มย่อย',
)
const currentIcon = computed(() =>
currentDept.value === 0
? 'mdi-file-cabinet'
: currentDept.value === 1
? 'inbox'
: 'o_folder_open'
? 'inbox'
: 'o_folder_open',
)
const folderFormState = ref<boolean>(false)
@ -121,7 +121,7 @@ function triggerFileEdit(
keyword: string[]
category: string[]
},
pathname: string
pathname: string,
) {
fileFormState.value = true
fileFormType.value = 'edit'
@ -145,7 +145,7 @@ async function submitFileForm(
keyword: string[]
category: string[]
},
force = false
force = false,
) {
currentParam.value = value
@ -170,7 +170,7 @@ async function submitFileForm(
keyword: value.keyword,
category: value.category,
},
value.file
value.file,
)
}
fileFormData.value = {}
@ -263,14 +263,12 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
<div
class="flex flex-break d justify-between space-between"
v-if="currentDept >= 1 && props.mode == 'admin' && currentDept != 4"
>
<div>
<span class="text-h6">{{ currentLevel }}</span>
</div>
<div>
<q-btn
outline
push
class="q-px-md q-ml-md q-py-sm"
@ -280,6 +278,7 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
dense
icon="add"
@click="() => triggerFolderCreate()"
id="listViewFolderCreate"
/>
</div>
</div>
@ -294,7 +293,7 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
:rows-per-page-options="[0]"
@row-click="onRowClick"
class="cursor"
v-if=" currentDept != 4 "
v-if="currentDept != 4"
>
<template v-slot:body-cell-name="nameRow">
<q-td style="width: 50%">
@ -330,9 +329,10 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
@click.stop="
triggerFolderEdit(
actionsRow.row.name,
actionsRow.row.pathname
actionsRow.row.pathname,
)
"
id="listViewFolderEdit"
/>
<q-btn
flat
@ -340,6 +340,7 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
dense
icon="delete"
@click.stop="triggerFolderDelete(actionsRow.row.pathname)"
id="listViewFolderDelete"
/>
</div>
</q-td>
@ -366,6 +367,7 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
dense
icon="add"
@click="() => triggerFileCreate()"
id="listViewFileCreate"
/>
</div>
</div>
@ -389,6 +391,7 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
: getFolder(nameRow.row.pathname)
}
"
id="listViewGetFileInfo"
>
<file-icon size="list" :fileMimeType="nameRow.row.fileType" />
{{ nameRow.row.fileName }}
@ -424,6 +427,7 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
@click="
() => triggerFileEdit(actionsRow.row, actionsRow.row.pathname)
"
id="listViewFileEdit"
/>
<q-btn
flat
@ -431,6 +435,7 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
dense
icon="delete"
@click="() => triggerFileDelete(actionsRow.row.pathname)"
id="listViewFileDelete"
/>
</div>
</q-td>
@ -460,6 +465,12 @@ const onRowClick = (evt: Event, row: TreeDataFolder, index: number) => {
@submit="submitFolderForm"
/>
<upload-exist-dialog
v-model:notification="fileExistNotification"
@confirm="() => currentParam && submitFileForm(currentParam, true)"
@cancel="() => (currentParam = undefined)"
/>
<dialog-delete
v-model:open="dialogDeleteState"
@confirm="

View file

@ -23,7 +23,6 @@ const { data, currentDept, currentPath } = storeToRefs(useTreeDataStore())
const { createFolder, getCabinet, gotoParent, getFolder } = useTreeDataStore()
const viewMode = ref<'view_list' | 'view_module'>('view_list')
const inputSearch = ref<string>()
const props = defineProps<{
mode: 'admin' | 'user'
}>()
@ -69,8 +68,14 @@ onMounted(getCabinet)
<div class="col-12 col-md-3">
<div class="bg-white rounded-borders shadow-5">
<div
class="q-px-md q-py-sm text-primary bg-grey-1"
class="q-px-md q-py-sm text-primary bg-grey-1 pointer"
id="container-header"
@click="
() => {
currentPath = ''
getFolder(currentPath)
}
"
>
<span class="block q-my-sm text-weight-bold">ดเกบเอกสาร</span>
</div>
@ -95,29 +100,45 @@ onMounted(getCabinet)
flat
dense
class="q-mr-sm q-px-sm"
v-if="currentDept > 0 && isSearch === false"
v-if="isSearch == true || currentDept > 0"
@click="
() => {
folderFormState = false
gotoParent()
isSearch
? (isSearch = false)
: ((folderFormState = false), gotoParent())
}
"
>
<q-icon name="arrow_back" size="1rem" color="primary" />
</q-btn>
<span v-if="isSearch === true">ผลการค้นหา</span>
<q-breadcrumbs v-if="isSearch === false" active-color="primary">
<q-breadcrumbs-el
v-if="currentPath === '/' || !currentPath"
label="ตู้เอกสารทั้งหมด"
/>
<q-breadcrumbs-el
class="text-black"
v-for="fragments in currentPath.split('/').filter(Boolean)"
class="text-primary pointer"
v-for="(fragments, index) in currentPath
.split('/')
.filter(Boolean)"
:label="fragments"
@click="
() => {
currentPath =
currentPath
.split('/')
.filter(Boolean)
.slice(0, index + 1)
.join('/') + '/'
getFolder(currentPath)
}
"
/>
</q-breadcrumbs>
</div>
<span v-if="isSearch === true">ผลการค้นหา</span>
<q-btn
v-if="
mode === 'admin' &&
@ -160,7 +181,11 @@ onMounted(getCabinet)
</div>
</div>
<div>
<file-searched :viewMode="viewMode" v-if="isSearch === true" />
<file-searched
:viewMode="viewMode"
:action="props.mode === 'admin'"
v-if="isSearch === true"
/>
<file-item
:viewMode="viewMode"
:action="props.mode === 'admin'"

View file

@ -1,11 +1,11 @@
divdivdivdivdivdiv
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSearchDataStore } from '@/stores/searched-data'
const { isAdvSearchCall } = storeToRefs(useSearchDataStore())
const { isAdvSearchCall, advSearchDataRow, advSearchDataField } =
storeToRefs(useSearchDataStore())
const optionsField = [
{ label: 'ชื่อเรื่อง (title)', value: 'title' },
{ label: 'คำสำคัญ (keyword)', value: 'keyword' },
@ -16,26 +16,6 @@ const optionsOp = [
{ label: 'และ', value: 'AND' },
{ label: 'หรือ', value: 'OR' },
]
const advSearchDataRow = ref<
{
op: 'AND' | 'OR'
field: 'title' | 'keyword'
value: string
}[]
>([
{
op: 'AND',
field: 'title',
value: '',
},
])
const advSearchDataField = ref<{
keyword: string
description: string
}>({
keyword: '',
description: '',
})
const props = defineProps<{
searchSubmit: Function
submitSearchData: {
@ -50,11 +30,6 @@ const props = defineProps<{
}
}>()
defineExpose({
advSearchDataRow,
advSearchDataField,
})
function addAdvSearchData() {
advSearchDataRow.value.push({
op: 'AND',
@ -75,7 +50,7 @@ function clearAdvSearchData() {
},
]
advSearchDataField.value = {
keyword: '',
keyword: [],
description: '',
}
}
@ -108,6 +83,7 @@ function clearAdvSearchData() {
color="red"
icon="close"
@click="clearAdvSearchData"
id="clearAdvSearchData"
/>
</div>
@ -124,6 +100,7 @@ function clearAdvSearchData() {
icon="mdi-plus"
v-if="index === advSearchDataRow.length - 1"
@click="addAdvSearchData"
id="addAdvSearchData"
/>
</div>
<div class="col-4 col-md-2">
@ -172,6 +149,7 @@ function clearAdvSearchData() {
icon="mdi-trash-can-outline"
color="red"
@click="() => delAdvSearchData(index)"
id="delAdvSearchData"
>
<q-tooltip
class="bg-red"
@ -190,13 +168,17 @@ function clearAdvSearchData() {
<div class="row q-col-gutter-md q-pb-md">
<div class="col-12 col-md-5">
<q-input
id="advSearchKeyword"
dense
<q-select
outlined
placeholder="คำสำคัญ"
@keydown.enter.prevent="searchSubmit()"
dense
v-model="advSearchDataField.keyword"
placeholder="คำสำคัญ"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add"
/>
</div>
<div class="col-12 col-md-grow">
@ -220,6 +202,7 @@ function clearAdvSearchData() {
label="ค้นหา"
icon="mdi-magnify"
@click="() => props.searchSubmit()"
id="advSearchSubmit"
/>
</div>
</div>

View file

@ -9,30 +9,39 @@ import { useFileInfoStore } from '@/stores/file-info-data'
import FileIcon from '@/components/FileIcon.vue'
const { isFilePreview, fileInfo } = storeToRefs(useFileInfoStore())
const { getType, getFormatDate, getSize, getFileNameFormat } =
useFileInfoStore()
const { getType, getFormatDate, getSize } = useFileInfoStore()
async function downloadSubmit(path: any) {
const [cabinet, drawer, folder, file] = path.split('/')
const formatPath = `/cabinet/${cabinet}/drawer/${drawer}/folder/${folder}/file/${file}`
const res = await axiosClient.get<EhrFile & { download: string }>(
`${import.meta.env.VITE_API_ENDPOINT}${formatPath}`,
)
await axios
.get(res.data.download, {
method: 'GET',
responseType: 'blob',
headers: {
'Content-Type': 'application/json',
Accept: res.data.fileType,
},
})
.then((r) => {
const a = document.createElement('a')
a.href = window.URL.createObjectURL(r.data)
a.download = res.data.fileName
a.click()
})
async function downloadSubmit(path: string | undefined) {
if (path) {
let formatPath: string
if (path.split('/').length - 1 === 3) {
const [cabinet, drawer, folder, file] = path.split('/')
formatPath = `cabinet/${cabinet}/drawer/${drawer}/folder/${folder}/file/${file}`
} else {
const [cabinet, drawer, folder, subfolder, file] = path.split('/')
formatPath = `cabinet/${cabinet}/drawer/${drawer}/folder/${folder}/subfolder/${subfolder}/file/${file}`
}
const res = await axiosClient.get<EhrFile & { download: string }>(
`${import.meta.env.VITE_API_ENDPOINT}${formatPath}`,
)
await axios
.get(res.data.download, {
method: 'GET',
responseType: 'blob',
headers: {
'Content-Type': 'application/json',
Accept: res.data.fileType,
},
})
.then((r) => {
const a = document.createElement('a')
a.href = window.URL.createObjectURL(r.data)
a.download = res.data.fileName
a.click()
})
}
}
</script>
@ -46,6 +55,7 @@ async function downloadSubmit(path: any) {
dense
class="q-mr-sm q-px-sm"
@click="() => (isFilePreview = false)"
id="goBackInfo"
>
<q-icon
class="pointer"
@ -95,6 +105,7 @@ async function downloadSubmit(path: any) {
icon="mdi-download"
class="q-py-sm"
@click="() => downloadSubmit(fileInfo?.pathname)"
id="downloadSubmit"
/>
</div>
</div>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import axiosClient from '@/services/HttpService'
@ -10,22 +10,21 @@ import { useLoader } from '@/stores/loader'
import AdvancedSearch from '@/modules/01_user/components/AdvancedSearch.vue'
const loaderStore = useLoader()
const { isSearch, isAdvSearchCall } = storeToRefs(useSearchDataStore())
const {
isSearch,
isAdvSearchCall,
isActFoundFile,
searchData,
advSearchDataField,
advSearchDataRow,
} = storeToRefs(useSearchDataStore())
const { getFoundFile } = useSearchDataStore()
const advSearchComp = ref<InstanceType<typeof AdvancedSearch>>()
const optionsField = [
{ label: 'ชื่อเรื่อง (title)', value: 'title' },
{ label: 'คำสำคัญ (keyword)', value: 'keyword' },
{ label: 'หมวดหมู่ (category)', value: 'category' },
{ label: 'เนื้อหาในไฟล์ (content)', value: 'attachment.content' },
]
const searchData = ref<{
field: string
value: string
}>({
field: 'title',
value: '',
})
const submitSearchData = ref<{
AND: { field: string; value: string }[]
OR: { field: string; value: string }[]
@ -53,9 +52,9 @@ async function searchSubmit() {
value: searchData.value.value,
})
if (isAdvSearchCall.value && advSearchComp.value) {
const advField = advSearchComp.value.advSearchDataField
const advRow = advSearchComp.value.advSearchDataRow
if (isAdvSearchCall.value) {
let advField = advSearchDataField.value
let advRow = advSearchDataRow.value
advRow.forEach((d: { field: string; value: string; op: string }) => {
if (d.field && d.value.trim() !== '') {
@ -63,11 +62,13 @@ async function searchSubmit() {
submitSearchData.value[op].push({ field: d.field, value: d.value })
}
})
if (advField.keyword.trim() !== '') {
submitSearchData.value.AND.push({
field: 'keyword',
value: advField.keyword,
})
if (advField.keyword.length > 0) {
for (let i = 0; i < advField.keyword.length; i++) {
submitSearchData.value.AND.push({
field: 'keyword',
value: advField.keyword[i],
})
}
}
if (advField.description.trim() !== '') {
submitSearchData.value.AND.push({
@ -93,6 +94,18 @@ async function searchSubmit() {
}
}
}
watch(
() => isActFoundFile.value,
(edited) => {
if (edited === true) {
searchSubmit()
setTimeout(() => {
isActFoundFile.value = false
}, 300)
}
},
)
</script>
<template>
@ -107,7 +120,9 @@ async function searchSubmit() {
id="inputSearch"
@keydown.enter.prevent="searchSubmit"
>
<template v-slot:append><q-icon name="search" /></template>
<template v-slot:append
><q-icon name="search" class="pointer" @click="searchSubmit"
/></template>
</q-input>
</div>
@ -141,6 +156,7 @@ async function searchSubmit() {
name="close"
@click="() => ((searchData.value = ''), (isSearch = false))"
class="cursor-pointer"
id="clearSearchData"
/>
</template>
</q-input>
@ -151,7 +167,6 @@ async function searchSubmit() {
<div class="row items-center justify-between q-gutter-y-md q-pt-sm">
<div class="column col-grow">
<advanced-search
ref="advSearchComp"
:searchSubmit="searchSubmit"
:submit-search-data="submitSearchData"
/>
@ -163,6 +178,7 @@ async function searchSubmit() {
label="ค้นหา"
icon="mdi-magnify"
@click="searchSubmit"
id="searchSubmit"
/>
</div>
</div>
@ -171,3 +187,9 @@ async function searchSubmit() {
<q-separator />
</div>
</template>
<style scoped>
.pointer {
cursor: pointer;
}
</style>

View file

@ -4,6 +4,6 @@ export default [
{
path: '/',
name: 'UserHomePage',
component: () => import('@/modules/01_user/views/homePage.vue')
}
component: () => import('@/modules/01_user/views/homePage.vue'),
},
]

View file

@ -4,6 +4,6 @@ export default [
{
path: '/admin',
name: 'AdminHomePage',
component: () => import('@/modules/02_admin/views/homePage.vue')
}
component: () => import('@/modules/02_admin/views/homePage.vue'),
},
]

View file

@ -2,10 +2,42 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { EhrFile } from '@/stores/tree-data'
export interface searchData {
field: string
value: string
}
export interface advSearchDataRow {
op: 'AND' | 'OR'
field: 'title' | 'keyword'
value: string
}
export interface advSearchDataField {
keyword: string[]
description: string
}
export const useSearchDataStore = defineStore('searched', () => {
const foundFile = ref<EhrFile[]>([])
const isAdvSearchCall = ref<boolean>(false)
const isSearch = ref<Boolean>(false)
const isActFoundFile = ref<Boolean>(false)
const searchData = ref<searchData>({
field: 'title',
value: '',
})
const advSearchDataRow = ref<advSearchDataRow[]>([
{
op: 'AND',
field: 'title',
value: '',
},
])
const advSearchDataField = ref<advSearchDataField>({
keyword: [],
description: '',
})
async function getFoundFile(data: EhrFile[]) {
foundFile.value = data
@ -15,6 +47,10 @@ export const useSearchDataStore = defineStore('searched', () => {
foundFile,
isSearch,
isAdvSearchCall,
isActFoundFile,
searchData,
advSearchDataRow,
advSearchDataField,
getFoundFile,
}
})

View file

@ -93,7 +93,7 @@ export const useTreeDataStore = defineStore('changeCabinet', () => {
currentPath.value = pathname
const res = await axiosClient.get<EhrFolder[]>(
`${apiEndpoint}${requestPath}`
`${apiEndpoint}${requestPath}`,
)
const list = res.data.map((v) => ({
@ -245,7 +245,7 @@ export const useTreeDataStore = defineStore('changeCabinet', () => {
description: string
keyword: string[]
category: string[]
}
},
) {
loader.show()
@ -265,7 +265,7 @@ export const useTreeDataStore = defineStore('changeCabinet', () => {
{
file: file.name,
...metadata,
}
},
)
if (res && res.data.upload) {
@ -294,7 +294,7 @@ export const useTreeDataStore = defineStore('changeCabinet', () => {
keyword: string[]
category: string[]
},
file?: File
file?: File,
) {
loader.show()
@ -309,7 +309,7 @@ export const useTreeDataStore = defineStore('changeCabinet', () => {
const res = await axiosClient.patch<{ upload: string }>(
`${apiEndpoint}${requestPath}`,
{ file: file?.name, ...metadata }
{ file: file?.name, ...metadata },
)
if (res && res.data.upload) {

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent } from 'vue'
export default defineComponent({
name: "Error404NotFound",
});
name: 'Error404NotFound',
})
</script>
<template>

View file

@ -2,7 +2,9 @@
import { useLoader } from '@/stores/loader'
import { storeToRefs } from 'pinia'
import profile from '@/components/Profile.vue'
import { useTreeDataStore } from '@/stores/tree-data'
const { currentPath } = storeToRefs(useTreeDataStore())
const { getFolder } = useTreeDataStore()
const loaderStore = useLoader()
const { loader } = storeToRefs(loaderStore)
</script>
@ -12,11 +14,27 @@ const { loader } = storeToRefs(loaderStore)
<q-header class="bg-white text-black" bordered>
<q-toolbar class="q-py-sm">
<q-img
class="pointer"
src="@/assets/logo.png"
spinner-color="white"
style="height: 32px; max-width: 32px"
@click="
() => {
currentPath = ''
getFolder(currentPath)
}
"
/>
<div class="column q-px-md" id="app-toolbar-title">
<div
class="column q-px-md pointer"
id="app-toolbar-title"
@click="
() => {
currentPath = ''
getFolder(currentPath)
}
"
>
<span class="text-body1">ระบบทรพยากรบคคล</span>
<span class="text-caption text-grey">
ดเกบขอมลผลการประเม
@ -41,4 +59,8 @@ const { loader } = storeToRefs(loaderStore)
.q-layout {
background: var(--q-secondary);
}
.pointer {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,437 @@
import { test, expect, type Page } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
});
const TODO_ITEMS = [
'buy some cheese',
'feed the cat',
'book a doctors appointment'
];
test.describe('New Todo', () => {
test('should allow me to add todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create 1st todo.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Make sure the list only has one todo item.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0]
]);
// Create 2nd todo.
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
// Make sure the list now has two todo items.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[1]
]);
await checkNumberOfTodosInLocalStorage(page, 2);
});
test('should clear text input field when an item is added', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create one todo item.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Check that input is empty.
await expect(newTodo).toBeEmpty();
await checkNumberOfTodosInLocalStorage(page, 1);
});
test('should append new items to the bottom of the list', async ({ page }) => {
// Create 3 items.
await createDefaultTodos(page);
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
// Check test using different methods.
await expect(page.getByText('3 items left')).toBeVisible();
await expect(todoCount).toHaveText('3 items left');
await expect(todoCount).toContainText('3');
await expect(todoCount).toHaveText(/3/);
// Check all items in one call.
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
await checkNumberOfTodosInLocalStorage(page, 3);
});
});
test.describe('Mark all as completed', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test.afterEach(async ({ page }) => {
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should allow me to mark all items as completed', async ({ page }) => {
// Complete all todos.
await page.getByLabel('Mark all as complete').check();
// Ensure all todos have 'completed' class.
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
});
test('should allow me to clear the complete state of all items', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
// Check and then immediately uncheck.
await toggleAll.check();
await toggleAll.uncheck();
// Should be no completed classes.
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
});
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
await toggleAll.check();
await expect(toggleAll).toBeChecked();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Uncheck first todo.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').uncheck();
// Reuse toggleAll locator and make sure its not checked.
await expect(toggleAll).not.toBeChecked();
await firstTodo.getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Assert the toggle all is checked again.
await expect(toggleAll).toBeChecked();
});
});
test.describe('Item', () => {
test('should allow me to mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
// Check first item.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').check();
await expect(firstTodo).toHaveClass('completed');
// Check second item.
const secondTodo = page.getByTestId('todo-item').nth(1);
await expect(secondTodo).not.toHaveClass('completed');
await secondTodo.getByRole('checkbox').check();
// Assert completed class.
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).toHaveClass('completed');
});
test('should allow me to un-mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const firstTodo = page.getByTestId('todo-item').nth(0);
const secondTodo = page.getByTestId('todo-item').nth(1);
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
await firstTodoCheckbox.check();
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await firstTodoCheckbox.uncheck();
await expect(firstTodo).not.toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
});
test('should allow me to edit an item', async ({ page }) => {
await createDefaultTodos(page);
const todoItems = page.getByTestId('todo-item');
const secondTodo = todoItems.nth(1);
await secondTodo.dblclick();
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
// Explicitly assert the new text value.
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2]
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
});
test.describe('Editing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should hide other controls when editing', async ({ page }) => {
const todoItem = page.getByTestId('todo-item').nth(1);
await todoItem.dblclick();
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
await expect(todoItem.locator('label', {
hasText: TODO_ITEMS[1],
})).not.toBeVisible();
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should save edits on blur', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should trim entered text', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should remove the item if an empty text string was entered', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[2],
]);
});
test('should cancel edits on escape', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
await expect(todoItems).toHaveText(TODO_ITEMS);
});
});
test.describe('Counter', () => {
test('should display the current number of todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('1');
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('2');
await checkNumberOfTodosInLocalStorage(page, 2);
});
});
test.describe('Clear completed button', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
});
test('should display the correct text', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
});
test('should remove completed items when clicked', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).getByRole('checkbox').check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(todoItems).toHaveCount(2);
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should be hidden when there are no items that are completed', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
});
});
test.describe('Persistence', () => {
test('should persist its data', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const todoItems = page.getByTestId('todo-item');
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
await firstTodoCheck.check();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
// Ensure there is 1 completed item.
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// Now reload.
await page.reload();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
});
});
test.describe('Routing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
// make sure the app had a chance to save updated todos in storage
// before navigating to a new view, otherwise the items can get lost :(
// in some frameworks like Durandal
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
});
test('should allow me to display active items', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await expect(todoItem).toHaveCount(2);
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should respect the back button', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await test.step('Showing all items', async () => {
await page.getByRole('link', { name: 'All' }).click();
await expect(todoItem).toHaveCount(3);
});
await test.step('Showing active items', async () => {
await page.getByRole('link', { name: 'Active' }).click();
});
await test.step('Showing completed items', async () => {
await page.getByRole('link', { name: 'Completed' }).click();
});
await expect(todoItem).toHaveCount(1);
await page.goBack();
await expect(todoItem).toHaveCount(2);
await page.goBack();
await expect(todoItem).toHaveCount(3);
});
test('should allow me to display completed items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Completed' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(1);
});
test('should allow me to display all items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await page.getByRole('link', { name: 'Completed' }).click();
await page.getByRole('link', { name: 'All' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(3);
});
test('should highlight the currently applied filter', async ({ page }) => {
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
//create locators for active and completed links
const activeLink = page.getByRole('link', { name: 'Active' });
const completedLink = page.getByRole('link', { name: 'Completed' });
await activeLink.click();
// Page change - active items.
await expect(activeLink).toHaveClass('selected');
await completedLink.click();
// Page change - completed items.
await expect(completedLink).toHaveClass('selected');
});
});
async function createDefaultTodos(page: Page) {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
}
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).length === e;
}, expected);
}
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
}, expected);
}
async function checkTodosInLocalStorage(page: Page, title: string) {
return await page.waitForFunction(t => {
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
}, title);
}

View file

@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});

View file

@ -1,11 +1,6 @@
{
"extends": ["@tsconfig/node18/tsconfig.json", "@vue/tsconfig/tsconfig.json"],
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*"
],
"include": ["vite.config.*", "vitest.config.*", "playwright.config.*"],
"compilerOptions": {
"composite": true,
"ignoreDeprecations": "5.0",

View file

@ -1,5 +1,5 @@
{
"plugin": ["typedoc-plugin-vue"],
"entryPoints": ["src/main.ts","src/**/*.ts"],
"out": "fe-typedoc"
"plugin": ["typedoc-plugin-vue"],
"entryPoints": ["src/main.ts", "src/**/*.ts"],
"out": "fe-typedoc"
}

View file

@ -13,6 +13,12 @@ Enterprise Document Management (EDM) ส่วน backend
- Attachment Processor สำหรับดึงข้อความเพื่อทำ Index สำหรับ Elasticsearch
- RabbitMQ ใช้ Bucket Notification ของ MiniO ส่งแจ้งเตือนเมื่อมีเอกสารใหม่จะต้องทำ Index ทำงานเป็น คิวเพื่อทำให้รับโหลดเอกสารได้มากยิ่งขึ้น เอกสารที่ทำผ่าน MiniO ก็จะถูกทำ Index โดยที่ Application ไม่ต้องเขียนการจัดการพิเศษ
# การเรียกใช้ผ่าน Service Account
จำเป็นต้องตั้งค่า Keycloak เพื่อให้สามารถขอ Token โดยใช้ Secret Key ที่ได้รับ ผ่านทาง `https://edm-id.frappet.synology.me/realms/EDM/protocol/openid-connect/token` ดังนี้
![image](https://github.com/Frappet/EDM/assets/61303214/4d2529eb-fbdf-4dbf-8cfc-8ae40108e5bb)
# สิทธิในจัดการ
การจัดการสามารถตั้งค่าได้โดยใช้ ENV ที่มีชื่อว่า MANAGEMENT_ROLE ให้ตรงกับที่ตั้งค่าใน Keycloak อีกที
## Note
- ELK (Elasticsearch+Kibana) ใช้แบบ unsecure อยู่หลัง firewall น่าจะเพียงพอ อาจจะ[ปรับเพิ่มเรื่องความปลอดภัย](https://vorapoap.medium.com/%E0%B8%95%E0%B8%AD%E0%B8%99%E0%B8%97%E0%B8%B5%E0%B9%88-3-%E0%B8%95%E0%B8%B4%E0%B8%94%E0%B8%95%E0%B8%B1%E0%B9%89%E0%B8%87-security-%E0%B9%83%E0%B8%AB%E0%B9%89%E0%B8%81%E0%B8%B1%E0%B8%9A-elasticsearch-aa26a71b87ff)ในอนาคน

View file

@ -64,7 +64,7 @@ export class CabinetController extends Controller {
@Post("/")
@Tags("ตู้เอกสาร")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์")
@SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ")
public async createCabinet(
@ -94,7 +94,7 @@ export class CabinetController extends Controller {
*/
@Put("/{cabinetName}")
@Tags("ตู้เอกสาร")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้")
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async editCabinet(
@ -163,7 +163,7 @@ export class CabinetController extends Controller {
*/
@Delete("/{cabinetName}")
@Tags("ตู้เอกสาร")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาด ไม่สามารถลบไฟล์ได้")
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async deleteCabinet(@Path() cabinetName: string) {

View file

@ -71,7 +71,7 @@ export class DrawerController extends Controller {
*/
@Post("/")
@Tags("ลิ้นชัก")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.NOT_FOUND, "ไม่พบลิ้นชัก")
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์")
@SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ")
@ -110,7 +110,7 @@ export class DrawerController extends Controller {
*/
@Put("/{drawerName}")
@Tags("ลิ้นชัก")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้")
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async editDrawer(
@ -181,7 +181,7 @@ export class DrawerController extends Controller {
*/
@Delete("/{drawerName}")
@Tags("ลิ้นชัก")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async deleteDrawer(@Path() cabinetName: string, @Path() drawerName: string) {
await new Promise<void>((resolve, reject) => {

View file

@ -93,7 +93,7 @@ export class FileController extends Controller {
*/
@Post("/")
@Tags("ไฟล์")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(
HttpStatusCode.NOT_FOUND,
"ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ",
@ -180,7 +180,7 @@ export class FileController extends Controller {
const metadata: Partial<StorageFile> = {
pathname,
path: basePath,
fileName: replaceIllegalChars( body.file ),
fileName: replaceIllegalChars(body.file),
fileSize: 0,
fileType: "",
title: body.title ?? "",
@ -218,7 +218,7 @@ export class FileController extends Controller {
*/
@Patch("/{fileName}")
@Tags("ไฟล์")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม")
@Response(HttpStatusCode.NO_CONTENT, "สำเร็จ")
@SuccessResponse(HttpStatusCode.OK, "สำเร็จ")
@ -270,9 +270,11 @@ export class FileController extends Controller {
throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบไฟล์");
}
const { file, ...metadata } = body;
// assume user will probably replace file by re-upload but maybe just rename
if (body.file) {
const destination = `${basePath}${replaceIllegalChars(body.file)}`;
if (file) {
const destination = `${basePath}${replaceIllegalChars(file)}`;
const source = `/${DEFAULT_BUCKET}/${basePath}${fileName}`;
const copy = await minioClient.copyObject(DEFAULT_BUCKET!, destination, source, copyCond);
@ -291,9 +293,10 @@ export class FileController extends Controller {
index,
id,
doc: {
...metadata,
pathname: destination,
path: basePath,
fileName: replaceIllegalChars(body.file),
fileName: replaceIllegalChars(file),
updatedAt: new Date().toISOString(),
updatedBy: request.user.preferred_username ?? "n/a",
},
@ -318,7 +321,7 @@ export class FileController extends Controller {
index,
id,
doc: {
...body,
...metadata,
updatedAt: new Date().toISOString(),
updatedBy: request.user.preferred_username ?? "n/a",
},
@ -345,7 +348,7 @@ export class FileController extends Controller {
*/
@Delete("/{fileName}")
@Tags("ไฟล์")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@SuccessResponse(HttpStatusCode.OK, "สำเร็จ")
public async deleteFile(
@Path() cabinetName: string,

View file

@ -75,7 +75,7 @@ export class FolderController extends Controller {
*/
@Post("/")
@Tags("แฟ้ม")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม")
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์")
@SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ")
@ -116,7 +116,7 @@ export class FolderController extends Controller {
*/
@Put("/{folderName}")
@Tags("แฟ้ม")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้")
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async editFolder(
@ -189,7 +189,7 @@ export class FolderController extends Controller {
*/
@Delete("/{folderName}")
@Tags("แฟ้ม")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async deleteFolder(
@Path() cabinetName: string,

View file

@ -79,7 +79,7 @@ export class SubFolderController extends Controller {
*/
@Post("/")
@Tags("แฟ้มย่อย")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.NOT_FOUND, "ไม่พบของแฟ้ม")
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์")
@SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ")
@ -116,7 +116,7 @@ export class SubFolderController extends Controller {
*/
@Put("/{subFolderName}")
@Tags("แฟ้มย่อย")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้")
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async editFolder(
@ -193,7 +193,7 @@ export class SubFolderController extends Controller {
*/
@Delete("/{subFolderName}")
@Tags("แฟ้มย่อย")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ")
public async deleteFolder(
@Path() cabinetName: string,

View file

@ -98,7 +98,7 @@ export class SubFolderFileController extends Controller {
*/
@Post("/")
@Tags("ไฟล์")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(
HttpStatusCode.NOT_FOUND,
"ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ",
@ -225,7 +225,7 @@ export class SubFolderFileController extends Controller {
*/
@Patch("/{fileName}")
@Tags("ไฟล์")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@Response(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม")
@SuccessResponse(HttpStatusCode.OK, "สำเร็จ")
public async updateFile(
@ -277,9 +277,11 @@ export class SubFolderFileController extends Controller {
throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบไฟล์");
}
const { file, ...metadata } = body;
// assume user will probably replace file by re-upload but maybe just rename
if (body.file) {
const destination = `${basePath}${replaceIllegalChars(body.file)}`;
if (file) {
const destination = `${basePath}${replaceIllegalChars(file)}`;
const source = `/${DEFAULT_BUCKET}/${basePath}${fileName}`;
const copy = await minioClient.copyObject(DEFAULT_BUCKET!, destination, source, copyCond);
@ -298,8 +300,10 @@ export class SubFolderFileController extends Controller {
index,
id,
doc: {
...metadata,
pathname: destination,
fileName: replaceIllegalChars(body.file),
path: basePath,
fileName: replaceIllegalChars(file),
updatedAt: new Date().toISOString(),
updatedBy: request.user.preferred_username ?? "n/a",
},
@ -324,7 +328,7 @@ export class SubFolderFileController extends Controller {
index,
id,
doc: {
...body,
...metadata,
updatedAt: new Date().toISOString(),
updatedBy: request.user.preferred_username ?? "n/a",
},
@ -352,7 +356,7 @@ export class SubFolderFileController extends Controller {
*/
@Delete("/{fileName}")
@Tags("ไฟล์")
@Security("bearerAuth", ["admin"])
@Security("bearerAuth", ["admin", "management-role"])
@SuccessResponse(HttpStatusCode.OK, "สำเร็จ")
public async deleteFile(
@Path() cabinetName: string,
@ -405,7 +409,7 @@ export class SubFolderFileController extends Controller {
...rest,
download: await minioClient.presignedGetObject(
DEFAULT_BUCKET!,
`${cabinetName}/${drawerName}/${folderName}/${fileName}`,
`${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${fileName}`,
),
};
}

View file

@ -101,7 +101,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.post('/cabinet',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(CabinetController)),
...(fetchMiddlewares<RequestHandler>(CabinetController.prototype.createCabinet)),
@ -128,7 +128,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.put('/cabinet/:cabinetName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(CabinetController)),
...(fetchMiddlewares<RequestHandler>(CabinetController.prototype.editCabinet)),
@ -155,7 +155,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.delete('/cabinet/:cabinetName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(CabinetController)),
...(fetchMiddlewares<RequestHandler>(CabinetController.prototype.deleteCabinet)),
@ -207,7 +207,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.post('/cabinet/:cabinetName/drawer',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(DrawerController)),
...(fetchMiddlewares<RequestHandler>(DrawerController.prototype.createDrawer)),
@ -235,7 +235,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.put('/cabinet/:cabinetName/drawer/:drawerName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(DrawerController)),
...(fetchMiddlewares<RequestHandler>(DrawerController.prototype.editDrawer)),
@ -263,7 +263,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.delete('/cabinet/:cabinetName/drawer/:drawerName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(DrawerController)),
...(fetchMiddlewares<RequestHandler>(DrawerController.prototype.deleteDrawer)),
@ -318,7 +318,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(FileController)),
...(fetchMiddlewares<RequestHandler>(FileController.prototype.uploadFile)),
@ -348,7 +348,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.patch('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file/:fileName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(FileController)),
...(fetchMiddlewares<RequestHandler>(FileController.prototype.updateFile)),
@ -379,7 +379,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file/:fileName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(FileController)),
...(fetchMiddlewares<RequestHandler>(FileController.prototype.deleteFile)),
@ -464,7 +464,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.post('/cabinet/:cabinetName/drawer/:drawerName/folder',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(FolderController)),
...(fetchMiddlewares<RequestHandler>(FolderController.prototype.createFolder)),
@ -493,7 +493,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.put('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(FolderController)),
...(fetchMiddlewares<RequestHandler>(FolderController.prototype.editFolder)),
@ -522,7 +522,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(FolderController)),
...(fetchMiddlewares<RequestHandler>(FolderController.prototype.deleteFolder)),
@ -604,7 +604,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(SubFolderController)),
...(fetchMiddlewares<RequestHandler>(SubFolderController.prototype.createFolder)),
@ -634,7 +634,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.put('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(SubFolderController)),
...(fetchMiddlewares<RequestHandler>(SubFolderController.prototype.editFolder)),
@ -664,7 +664,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(SubFolderController)),
...(fetchMiddlewares<RequestHandler>(SubFolderController.prototype.deleteFolder)),
@ -722,7 +722,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(SubFolderFileController)),
...(fetchMiddlewares<RequestHandler>(SubFolderFileController.prototype.uploadFile)),
@ -753,7 +753,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.patch('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file/:fileName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(SubFolderFileController)),
...(fetchMiddlewares<RequestHandler>(SubFolderFileController.prototype.updateFile)),
@ -785,7 +785,7 @@ export function RegisterRoutes(app: Router) {
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file/:fileName',
authenticateMiddleware([{"bearerAuth":["admin"]}]),
authenticateMiddleware([{"bearerAuth":["admin","management-role"]}]),
...(fetchMiddlewares<RequestHandler>(SubFolderFileController)),
...(fetchMiddlewares<RequestHandler>(SubFolderFileController.prototype.deleteFile)),

View file

@ -261,7 +261,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -304,7 +305,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -355,7 +357,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -450,7 +453,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -503,7 +507,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -560,7 +565,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -783,7 +789,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -906,7 +913,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1007,7 +1015,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1286,7 +1295,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1348,7 +1358,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1414,7 +1425,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1584,7 +1596,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1654,7 +1667,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1729,7 +1743,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -1979,7 +1994,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -2108,7 +2124,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],
@ -2218,7 +2235,8 @@
"security": [
{
"bearerAuth": [
"admin"
"admin",
"management-role"
]
}
],

View file

@ -1,12 +1,19 @@
import * as express from "express";
import { createVerifier } from "fast-jwt";
import { createDecoder, createVerifier } from "fast-jwt";
import HttpError from "../interfaces/http-error";
import HttpStatusCode from "../interfaces/http-status";
import { JwtPayload } from "jsonwebtoken";
if (!process.env.PUBLIC_KEY && !process.env.REALM_URL) {
throw new Error("Require public key or realm url.");
}
if (process.env.PUBLIC_KEY && process.env.REALM_URL && !process.env.PREFERRED_AUTH) {
throw new Error("Preferred auth type must be specified if public key and realm url is provided.");
}
if (!process.env.MANAGEMENT_ROLE) {
throw new Error("Management role env is required.");
}
const jwtVerify = createVerifier({
key: async () => {
@ -14,6 +21,8 @@ const jwtVerify = createVerifier({
},
});
const jwtDecode = createDecoder();
export async function expressAuthentication(
request: express.Request,
securityName: string,
@ -29,19 +38,47 @@ export async function expressAuthentication(
if (!token) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "No token provided.");
const payload = await jwtVerify(token).catch((_) => null);
let payload: JwtPayload = {};
if (!payload) {
throw new HttpError(HttpStatusCode.UNAUTHORIZED, "Invalid token provided.");
switch (process.env.PREFERRED_AUTH) {
case "online":
payload = await verifyOnline(token);
break;
case "offline":
payload = await verifyOffline(token);
break;
default:
if (process.env.REALM_URL) payload = await verifyOnline(token);
if (process.env.PUBLIC_KEY) payload = await verifyOffline(token);
break;
}
if (
scopes &&
scopes.length > 0 &&
scopes.some((v) => !payload.resource_access[payload.azp].roles.includes(v))
scopes
.map((v) => (v === "management-role" ? process.env.MANAGEMENT_ROLE : v))
.every((v) => !payload.resource_access[payload.azp].roles.includes(v))
) {
throw new HttpError(HttpStatusCode.FORBIDDEN, "You are not allowed to perform this action.");
}
return payload;
}
async function verifyOffline(token: string) {
const payload = await jwtVerify(token).catch((_) => null);
if (!payload) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "Invalid token provided.");
return payload;
}
async function verifyOnline(token: string) {
const res = await fetch(`${process.env.REALM_URL}/protocol/openid-connect/userinfo`, {
headers: { authorization: `Bearer ${token}` },
}).catch((e) => console.error(e));
if (!res) throw new Error("Cannot connect to auth service.");
if (!res.ok) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "Invalid token provided.");
return await jwtDecode(token);
}