upload tài liệu
This commit is contained in:
@@ -43,3 +43,6 @@ SMTP_PASS=
|
|||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
EMAIL_VERIFY_TOKEN_TTL_MINUTES=30
|
EMAIL_VERIFY_TOKEN_TTL_MINUTES=30
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES=30
|
PASSWORD_RESET_TOKEN_TTL_MINUTES=30
|
||||||
|
|
||||||
|
# Maximum size for each uploaded PDF document (1-100 MB).
|
||||||
|
DOCUMENT_MAX_FILE_SIZE_MB=25
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Thay vì lưu rải rác tài khoản ứng dụng ở nhiều nơi, AccManager
|
|||||||
- Quản lý danh sách ứng dụng đang sử dụng trong công ty.
|
- Quản lý danh sách ứng dụng đang sử dụng trong công ty.
|
||||||
- Gán tài khoản truy cập ứng dụng cho từng người dùng.
|
- Gán tài khoản truy cập ứng dụng cho từng người dùng.
|
||||||
- Theo dõi thông tin tài khoản rõ ràng, tránh thất lạc.
|
- Theo dõi thông tin tài khoản rõ ràng, tránh thất lạc.
|
||||||
|
- Lưu trữ, tìm kiếm, xem và tải xuống tài liệu PDF dùng chung.
|
||||||
|
|
||||||
## Ai sẽ sử dụng
|
## Ai sẽ sử dụng
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@ Thay vì lưu rải rác tài khoản ứng dụng ở nhiều nơi, AccManager
|
|||||||
2. Vào mục Người dùng để tạo mới hoặc cập nhật thông tin nhân sự.
|
2. Vào mục Người dùng để tạo mới hoặc cập nhật thông tin nhân sự.
|
||||||
3. Vào mục Ứng dụng để thêm các hệ thống cần quản lý.
|
3. Vào mục Ứng dụng để thêm các hệ thống cần quản lý.
|
||||||
4. Vào mục Tài khoản để gán tài khoản ứng dụng cho đúng người.
|
4. Vào mục Tài khoản để gán tài khoản ứng dụng cho đúng người.
|
||||||
5. Dùng nút Xem chi tiết trong danh sách người dùng để kiểm tra đầy đủ thông tin trước khi chỉnh sửa.
|
5. Vào mục Tài liệu PDF để tải lên hoặc xem tài liệu dùng chung.
|
||||||
|
6. Dùng nút Xem chi tiết trong danh sách người dùng để kiểm tra đầy đủ thông tin trước khi chỉnh sửa.
|
||||||
|
|
||||||
## Quy trình vận hành gợi ý
|
## Quy trình vận hành gợi ý
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ const SMTP_PASS = process.env.SMTP_PASS || '';
|
|||||||
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER || 'no-reply@accmanager.local';
|
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER || 'no-reply@accmanager.local';
|
||||||
const EMAIL_VERIFY_TOKEN_TTL_MINUTES = Number(process.env.EMAIL_VERIFY_TOKEN_TTL_MINUTES || 30);
|
const EMAIL_VERIFY_TOKEN_TTL_MINUTES = Number(process.env.EMAIL_VERIFY_TOKEN_TTL_MINUTES || 30);
|
||||||
const PASSWORD_RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES || 30);
|
const PASSWORD_RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES || 30);
|
||||||
|
const configuredDocumentMaxFileSizeMb = Number(process.env.DOCUMENT_MAX_FILE_SIZE_MB || 25);
|
||||||
|
const DOCUMENT_MAX_FILE_SIZE_MB = Number.isFinite(configuredDocumentMaxFileSizeMb)
|
||||||
|
? Math.min(100, Math.max(1, configuredDocumentMaxFileSizeMb))
|
||||||
|
: 25;
|
||||||
|
const DOCUMENT_MAX_FILE_SIZE_BYTES = DOCUMENT_MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
let mailTransporter;
|
let mailTransporter;
|
||||||
|
|
||||||
@@ -2221,6 +2226,56 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const documentUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: DOCUMENT_MAX_FILE_SIZE_BYTES,
|
||||||
|
files: 1,
|
||||||
|
fields: 5,
|
||||||
|
parts: 7
|
||||||
|
},
|
||||||
|
fileFilter(req, file, callback) {
|
||||||
|
const extension = require('path').extname(String(file.originalname || '')).toLowerCase();
|
||||||
|
const mimeType = String(file.mimetype || '').toLowerCase();
|
||||||
|
if (extension !== '.pdf' || mimeType !== 'application/pdf') {
|
||||||
|
return callback(new Error('Chỉ chấp nhận file PDF'));
|
||||||
|
}
|
||||||
|
callback(null, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPdfBuffer(value) {
|
||||||
|
if (!Buffer.isBuffer(value) || value.length < 10 || value.subarray(0, 5).toString('ascii') !== '%PDF-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const trailerStart = Math.max(0, value.length - 2048);
|
||||||
|
return value.subarray(trailerStart).includes(Buffer.from('%%EOF', 'ascii'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDocumentFileName(value) {
|
||||||
|
const cleaned = String(value || 'document.pdf')
|
||||||
|
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const baseName = cleaned || 'document.pdf';
|
||||||
|
return baseName.toLowerCase().endsWith('.pdf')
|
||||||
|
? baseName
|
||||||
|
: `${baseName.slice(0, 251)}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDocumentContentDisposition(fileName, download = false) {
|
||||||
|
const safeFileName = sanitizeDocumentFileName(fileName);
|
||||||
|
const asciiFileName = safeFileName
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^\x20-\x7e]/g, '')
|
||||||
|
.replace(/["\\]/g, '_')
|
||||||
|
.trim() || 'document.pdf';
|
||||||
|
const encodedFileName = encodeURIComponent(safeFileName)
|
||||||
|
.replace(/['()*]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||||
|
return `${download ? 'attachment' : 'inline'}; filename="${asciiFileName}"; filename*=UTF-8''${encodedFileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Serve static files from /public
|
// Serve static files from /public
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const publicDir = path.join(__dirname, '..', 'public');
|
const publicDir = path.join(__dirname, '..', 'public');
|
||||||
@@ -2676,6 +2731,24 @@ async function createTables() {
|
|||||||
)
|
)
|
||||||
END`,
|
END`,
|
||||||
|
|
||||||
|
// PDF Documents Table
|
||||||
|
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Documents')
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE Documents (
|
||||||
|
DocumentId INT PRIMARY KEY IDENTITY(1,1),
|
||||||
|
Title NVARCHAR(255) NOT NULL,
|
||||||
|
OriginalFileName NVARCHAR(255) NOT NULL,
|
||||||
|
MimeType NVARCHAR(100) NOT NULL DEFAULT 'application/pdf',
|
||||||
|
FileSize BIGINT NOT NULL,
|
||||||
|
FileData VARBINARY(MAX) NOT NULL,
|
||||||
|
UploadedBy INT NULL,
|
||||||
|
UploadedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
|
||||||
|
FOREIGN KEY (UploadedBy) REFERENCES Users(UserId) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IX_Documents_UploadedDate ON Documents(UploadedDate DESC);
|
||||||
|
CREATE INDEX IX_Documents_UploadedBy ON Documents(UploadedBy);
|
||||||
|
END`,
|
||||||
|
|
||||||
// Asset Inventory Table
|
// Asset Inventory Table
|
||||||
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory')
|
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory')
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -8837,6 +8910,143 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// API ROUTES - PDF Documents
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
app.get('/api/documents', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const search = String(req.query.search || '').trim().slice(0, 255);
|
||||||
|
const result = await pool.request()
|
||||||
|
.input('search', sql.NVarChar(255), search)
|
||||||
|
.input('currentUserId', sql.Int, req.user.UserId)
|
||||||
|
.input('isAdmin', sql.Bit, normalizeRole(req.user.Role) === 'admin')
|
||||||
|
.query(`
|
||||||
|
SELECT
|
||||||
|
d.DocumentId,
|
||||||
|
d.Title,
|
||||||
|
d.OriginalFileName,
|
||||||
|
d.MimeType,
|
||||||
|
d.FileSize,
|
||||||
|
d.UploadedBy,
|
||||||
|
COALESCE(NULLIF(LTRIM(RTRIM(u.FullName)), ''), u.Username, N'Người dùng đã xóa') AS UploadedByName,
|
||||||
|
d.UploadedDate,
|
||||||
|
CAST(CASE WHEN @isAdmin = 1 OR d.UploadedBy = @currentUserId THEN 1 ELSE 0 END AS BIT) AS CanDelete
|
||||||
|
FROM Documents d
|
||||||
|
LEFT JOIN Users u ON u.UserId = d.UploadedBy
|
||||||
|
WHERE @search = N''
|
||||||
|
OR d.Title LIKE N'%' + @search + N'%'
|
||||||
|
OR d.OriginalFileName LIKE N'%' + @search + N'%'
|
||||||
|
OR COALESCE(u.FullName, u.Username, N'') LIKE N'%' + @search + N'%'
|
||||||
|
ORDER BY d.UploadedDate DESC, d.DocumentId DESC;
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.recordset,
|
||||||
|
maxFileSizeMb: DOCUMENT_MAX_FILE_SIZE_MB
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
sendInternalError(res, err, 'Không thể tải danh sách tài liệu');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/documents', documentUpload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Vui lòng chọn file PDF' });
|
||||||
|
}
|
||||||
|
if (!isPdfBuffer(req.file.buffer)) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Nội dung file không phải là PDF hợp lệ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFileName = sanitizeDocumentFileName(req.file.originalname);
|
||||||
|
const requestedTitle = String(req.body.title || '').trim();
|
||||||
|
const defaultTitle = originalFileName.replace(/\.pdf$/i, '').trim() || 'Tài liệu PDF';
|
||||||
|
const title = (requestedTitle || defaultTitle).slice(0, 255);
|
||||||
|
const result = await pool.request()
|
||||||
|
.input('title', sql.NVarChar(255), title)
|
||||||
|
.input('originalFileName', sql.NVarChar(255), originalFileName)
|
||||||
|
.input('mimeType', sql.NVarChar(100), 'application/pdf')
|
||||||
|
.input('fileSize', sql.BigInt, req.file.size)
|
||||||
|
.input('fileData', sql.VarBinary(sql.MAX), req.file.buffer)
|
||||||
|
.input('uploadedBy', sql.Int, req.user.UserId)
|
||||||
|
.query(`
|
||||||
|
INSERT INTO Documents (Title, OriginalFileName, MimeType, FileSize, FileData, UploadedBy)
|
||||||
|
OUTPUT INSERTED.DocumentId, INSERTED.Title, INSERTED.OriginalFileName,
|
||||||
|
INSERTED.MimeType, INSERTED.FileSize, INSERTED.UploadedBy, INSERTED.UploadedDate
|
||||||
|
VALUES (@title, @originalFileName, @mimeType, @fileSize, @fileData, @uploadedBy);
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Đã lưu tài liệu PDF',
|
||||||
|
data: result.recordset[0]
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
sendInternalError(res, err, 'Không thể lưu tài liệu');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/documents/:id/file', async (req, res) => {
|
||||||
|
const documentId = Number.parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isInteger(documentId) || documentId <= 0) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Mã tài liệu không hợp lệ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.request()
|
||||||
|
.input('documentId', sql.Int, documentId)
|
||||||
|
.query(`
|
||||||
|
SELECT OriginalFileName, MimeType, FileSize, FileData
|
||||||
|
FROM Documents
|
||||||
|
WHERE DocumentId = @documentId;
|
||||||
|
`);
|
||||||
|
const document = result.recordset[0];
|
||||||
|
if (!document) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Không tìm thấy tài liệu' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDownload = String(req.query.download || '') === '1';
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Length', String(document.FileSize));
|
||||||
|
res.setHeader('Content-Disposition', getDocumentContentDisposition(document.OriginalFileName, shouldDownload));
|
||||||
|
res.send(document.FileData);
|
||||||
|
} catch (err) {
|
||||||
|
sendInternalError(res, err, 'Không thể mở tài liệu');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/documents/:id', async (req, res) => {
|
||||||
|
const documentId = Number.parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isInteger(documentId) || documentId <= 0) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Mã tài liệu không hợp lệ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingResult = await pool.request()
|
||||||
|
.input('documentId', sql.Int, documentId)
|
||||||
|
.query('SELECT DocumentId, UploadedBy FROM Documents WHERE DocumentId = @documentId');
|
||||||
|
const document = existingResult.recordset[0];
|
||||||
|
if (!document) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Không tìm thấy tài liệu' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = Number(document.UploadedBy) === Number(req.user.UserId);
|
||||||
|
const isAdmin = normalizeRole(req.user.Role) === 'admin';
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
return res.status(403).json({ success: false, message: 'Bạn không có quyền xóa tài liệu này' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.request()
|
||||||
|
.input('documentId', sql.Int, documentId)
|
||||||
|
.query('DELETE FROM Documents WHERE DocumentId = @documentId');
|
||||||
|
res.json({ success: true, message: 'Đã xóa tài liệu' });
|
||||||
|
} catch (err) {
|
||||||
|
sendInternalError(res, err, 'Không thể xóa tài liệu');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// API ROUTES - Database Info
|
// API ROUTES - Database Info
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -8882,7 +9092,15 @@ app.get('/api/database/info', requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error('Unhandled request error:', err.message);
|
console.error('Unhandled request error:', err.message);
|
||||||
if (err instanceof multer.MulterError || err.message === 'Only .xls and .xlsx files are allowed') {
|
if (err instanceof multer.MulterError) {
|
||||||
|
const message = err.code === 'LIMIT_FILE_SIZE'
|
||||||
|
? (req.path.startsWith('/api/documents')
|
||||||
|
? `File vượt quá dung lượng cho phép (${DOCUMENT_MAX_FILE_SIZE_MB} MB)`
|
||||||
|
: 'File exceeds the 10 MB upload limit')
|
||||||
|
: err.message;
|
||||||
|
return res.status(400).json({ success: false, message });
|
||||||
|
}
|
||||||
|
if (err.message === 'Only .xls and .xlsx files are allowed' || err.message === 'Chỉ chấp nhận file PDF') {
|
||||||
return res.status(400).json({ success: false, message: err.message });
|
return res.status(400).json({ success: false, message: err.message });
|
||||||
}
|
}
|
||||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
@@ -8936,5 +9154,8 @@ module.exports = {
|
|||||||
encryptSensitiveValue,
|
encryptSensitiveValue,
|
||||||
decryptSensitiveValue,
|
decryptSensitiveValue,
|
||||||
hashSessionToken,
|
hashSessionToken,
|
||||||
normalizeOptionalHttpUrl
|
normalizeOptionalHttpUrl,
|
||||||
|
isPdfBuffer,
|
||||||
|
sanitizeDocumentFileName,
|
||||||
|
getDocumentContentDisposition
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,6 +102,27 @@ BEGIN
|
|||||||
ALTER TABLE Accounts ALTER COLUMN AccountPassword NVARCHAR(2048) NULL;
|
ALTER TABLE Accounts ALTER COLUMN AccountPassword NVARCHAR(2048) NULL;
|
||||||
END
|
END
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- 4. CREATE PDF DOCUMENTS TABLE
|
||||||
|
-- ===========================================
|
||||||
|
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Documents')
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE Documents (
|
||||||
|
DocumentId INT PRIMARY KEY IDENTITY(1,1),
|
||||||
|
Title NVARCHAR(255) NOT NULL,
|
||||||
|
OriginalFileName NVARCHAR(255) NOT NULL,
|
||||||
|
MimeType NVARCHAR(100) NOT NULL DEFAULT 'application/pdf',
|
||||||
|
FileSize BIGINT NOT NULL,
|
||||||
|
FileData VARBINARY(MAX) NOT NULL,
|
||||||
|
UploadedBy INT NULL,
|
||||||
|
UploadedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
|
||||||
|
FOREIGN KEY (UploadedBy) REFERENCES Users(UserId) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IX_Documents_UploadedDate ON Documents(UploadedDate DESC);
|
||||||
|
CREATE INDEX IX_Documents_UploadedBy ON Documents(UploadedBy);
|
||||||
|
PRINT 'Table Documents created successfully.';
|
||||||
|
END
|
||||||
|
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
-- 4. CREATE ASSET INVENTORY TABLE
|
-- 4. CREATE ASSET INVENTORY TABLE
|
||||||
-- ===========================================
|
-- ===========================================
|
||||||
|
|||||||
@@ -38,3 +38,4 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
|
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
|
||||||
|
DOCUMENT_MAX_FILE_SIZE_MB: ${DOCUMENT_MAX_FILE_SIZE_MB:-25}
|
||||||
|
|||||||
@@ -40,3 +40,4 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
||||||
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
|
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
|
||||||
|
DOCUMENT_MAX_FILE_SIZE_MB: ${DOCUMENT_MAX_FILE_SIZE_MB:-25}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
330
public/js/app.js
330
public/js/app.js
@@ -65,6 +65,7 @@ class AccountManager {
|
|||||||
this.users = [];
|
this.users = [];
|
||||||
this.assets = [];
|
this.assets = [];
|
||||||
this.consumables = [];
|
this.consumables = [];
|
||||||
|
this.documents = [];
|
||||||
this.roles = [];
|
this.roles = [];
|
||||||
this.accountPage = 1;
|
this.accountPage = 1;
|
||||||
this.accountPageSize = 9;
|
this.accountPageSize = 9;
|
||||||
@@ -98,6 +99,8 @@ class AccountManager {
|
|||||||
this.consumableExportRecipientFilter = '';
|
this.consumableExportRecipientFilter = '';
|
||||||
this.consumableExportProjectFilter = '';
|
this.consumableExportProjectFilter = '';
|
||||||
this.consumableExportDateFilter = '';
|
this.consumableExportDateFilter = '';
|
||||||
|
this.documentSearchTerm = '';
|
||||||
|
this.documentMaxFileSizeMb = 25;
|
||||||
this.assetBorrows = [];
|
this.assetBorrows = [];
|
||||||
this.assetBorrowSearchTerm = '';
|
this.assetBorrowSearchTerm = '';
|
||||||
this.assetBorrowTypeFilter = '';
|
this.assetBorrowTypeFilter = '';
|
||||||
@@ -259,6 +262,7 @@ class AccountManager {
|
|||||||
await this.fetchAssetBorrows();
|
await this.fetchAssetBorrows();
|
||||||
await this.fetchAssetDepartments();
|
await this.fetchAssetDepartments();
|
||||||
await this.fetchAssetProjects();
|
await this.fetchAssetProjects();
|
||||||
|
await this.fetchDocuments();
|
||||||
|
|
||||||
if (this.canCurrentUserManageAssets()) {
|
if (this.canCurrentUserManageAssets()) {
|
||||||
await this.fetchUsers();
|
await this.fetchUsers();
|
||||||
@@ -338,6 +342,9 @@ class AccountManager {
|
|||||||
this.setupAddButtonListeners();
|
this.setupAddButtonListeners();
|
||||||
this.setupFilters();
|
this.setupFilters();
|
||||||
this.setupAccountPagerListeners();
|
this.setupAccountPagerListeners();
|
||||||
|
} else if (page === 'documents') {
|
||||||
|
mainContent.innerHTML = this.getDocumentsContent();
|
||||||
|
this.setupDocumentListeners();
|
||||||
} else if (page === 'users') {
|
} else if (page === 'users') {
|
||||||
// Check if user is admin
|
// Check if user is admin
|
||||||
if (!this.isCurrentUserAdmin()) {
|
if (!this.isCurrentUserAdmin()) {
|
||||||
@@ -481,6 +488,25 @@ class AccountManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchDocuments(search = '') {
|
||||||
|
try {
|
||||||
|
const query = String(search || '').trim();
|
||||||
|
const url = query
|
||||||
|
? `${this.apiBase}/documents?search=${encodeURIComponent(query)}`
|
||||||
|
: `${this.apiBase}/documents`;
|
||||||
|
const response = await fetch(url, { cache: 'no-store' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.message || 'Không thể tải danh sách tài liệu');
|
||||||
|
}
|
||||||
|
this.documents = Array.isArray(data.data) ? data.data : [];
|
||||||
|
this.documentMaxFileSizeMb = Number(data.maxFileSizeMb) || 25;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch documents error:', err);
|
||||||
|
this.documents = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fetchAccountSecret(accountId) {
|
async fetchAccountSecret(accountId) {
|
||||||
const response = await fetch(`${this.apiBase}/accounts/${accountId}/secret`, { cache: 'no-store' });
|
const response = await fetch(`${this.apiBase}/accounts/${accountId}/secret`, { cache: 'no-store' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -2737,6 +2763,310 @@ class AccountManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatFileSize(value) {
|
||||||
|
const bytes = Number(value) || 0;
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilteredDocuments() {
|
||||||
|
const keyword = String(this.documentSearchTerm || '').trim().toLocaleLowerCase('vi');
|
||||||
|
if (!keyword) return this.documents;
|
||||||
|
return this.documents.filter(document => [
|
||||||
|
document?.Title,
|
||||||
|
document?.OriginalFileName,
|
||||||
|
document?.UploadedByName
|
||||||
|
].some(value => String(value || '').toLocaleLowerCase('vi').includes(keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocumentsContent() {
|
||||||
|
const documents = this.getFilteredDocuments();
|
||||||
|
const totalSize = this.documents.reduce((sum, document) => sum + (Number(document?.FileSize) || 0), 0);
|
||||||
|
return `
|
||||||
|
<div class="documents-page p-4 md:p-6 w-full h-full overflow-y-auto">
|
||||||
|
<div class="page-header flex items-end justify-between gap-4 mb-5">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 text-red-600 mb-1">
|
||||||
|
<span class="material-symbols-outlined">picture_as_pdf</span>
|
||||||
|
<span class="text-xs font-extrabold uppercase tracking-widest">Kho tài liệu</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-extrabold text-slate-900 dark:text-slate-50">Tài liệu PDF</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Lưu trữ, xem và tải xuống tài liệu dùng chung.</p>
|
||||||
|
</div>
|
||||||
|
<div class="document-header-actions flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<div class="inline-flex h-10 items-center gap-2 whitespace-nowrap px-1 text-xs text-slate-500" aria-label="${this.documents.length} tài liệu, tổng dung lượng ${this.formatFileSize(totalSize)}">
|
||||||
|
<span class="material-symbols-outlined text-lg text-slate-400">folder</span>
|
||||||
|
<span><strong class="font-extrabold text-slate-700">${this.documents.length}</strong> tài liệu</span>
|
||||||
|
<span class="text-slate-300" aria-hidden="true">•</span>
|
||||||
|
<span class="font-semibold text-slate-600">${this.formatFileSize(totalSize)}</span>
|
||||||
|
</div>
|
||||||
|
<button id="openDocumentUploadDialog" type="button" class="inline-flex items-center justify-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-red-700 active:scale-95">
|
||||||
|
<span class="material-symbols-outlined text-base">upload_file</span>
|
||||||
|
<span>Thêm tài liệu</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||||
|
<div class="page-filters flex items-center gap-3 border-b border-slate-200 p-4">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
||||||
|
<input id="documentSearch" type="search" value="${this.escapeHtml(this.documentSearchTerm)}" placeholder="Tìm theo tên tài liệu, tên file hoặc người tải lên..."
|
||||||
|
class="w-full rounded-lg border-slate-200 py-2 pl-10 pr-3 text-sm focus:border-primary focus:ring-primary" />
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold text-slate-500 whitespace-nowrap">${documents.length} kết quả</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${documents.length ? `
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 p-4">
|
||||||
|
${documents.map(document => {
|
||||||
|
const documentId = Number(document?.DocumentId);
|
||||||
|
const canDelete = Boolean(document?.CanDelete);
|
||||||
|
return `
|
||||||
|
<article class="flex min-w-0 gap-4 rounded-xl border border-slate-200 p-4 transition hover:border-red-200 hover:shadow-sm">
|
||||||
|
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-red-100 text-red-600">
|
||||||
|
<span class="material-symbols-outlined text-3xl">picture_as_pdf</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="truncate font-extrabold text-slate-800" title="${this.escapeHtml(document?.Title || '')}">${this.escapeHtml(document?.Title || 'Tài liệu PDF')}</h2>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-slate-500" title="${this.escapeHtml(document?.OriginalFileName || '')}">${this.escapeHtml(document?.OriginalFileName || '-')}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||||
|
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">hard_drive</span>${this.formatFileSize(document?.FileSize)}</span>
|
||||||
|
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">person</span>${this.escapeHtml(document?.UploadedByName || '-')}</span>
|
||||||
|
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">schedule</span>${this.formatDateTime(document?.UploadedDate)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<a href="${this.apiBase}/documents/${documentId}/file" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-white hover:bg-primary-dim">
|
||||||
|
<span class="material-symbols-outlined text-base">visibility</span>Xem PDF
|
||||||
|
</a>
|
||||||
|
<a href="${this.apiBase}/documents/${documentId}/file?download=1" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-slate-700 hover:bg-slate-50">
|
||||||
|
<span class="material-symbols-outlined text-base">download</span>Tải xuống
|
||||||
|
</a>
|
||||||
|
${canDelete ? `
|
||||||
|
<button type="button" class="delete-document ml-auto inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs font-bold text-red-600 hover:bg-red-50" data-document-id="${documentId}" data-document-title="${this.escapeHtml(document?.Title || document?.OriginalFileName || 'Tài liệu')}">
|
||||||
|
<span class="material-symbols-outlined text-base">delete</span>Xóa
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
|
||||||
|
<span class="material-symbols-outlined text-4xl">folder_open</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-4 font-extrabold text-slate-700">${this.documentSearchTerm ? 'Không tìm thấy tài liệu' : 'Chưa có tài liệu PDF'}</h2>
|
||||||
|
<p class="mt-1 max-w-md text-sm text-slate-500">${this.documentSearchTerm ? 'Hãy thử một từ khóa khác.' : 'Bấm “Thêm tài liệu” để lưu tài liệu đầu tiên.'}</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="documentUploadDialog" aria-labelledby="documentUploadDialogTitle" class="m-auto w-[min(92vw,34rem)] max-w-none overflow-hidden rounded-2xl border-0 bg-white p-0 text-slate-800 shadow-2xl backdrop:bg-slate-950/50 backdrop:backdrop-blur-sm">
|
||||||
|
<form id="documentUploadForm">
|
||||||
|
<div class="flex items-start justify-between gap-4 border-b border-slate-200 bg-red-50 px-5 py-4">
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-100 text-red-600">
|
||||||
|
<span class="material-symbols-outlined">upload_file</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 id="documentUploadDialogTitle" class="text-lg font-extrabold text-slate-900">Thêm tài liệu PDF</h2>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Tải tài liệu lên kho dùng chung.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-close-document-dialog class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-800 disabled:cursor-not-allowed disabled:opacity-50" aria-label="Đóng hộp thoại">
|
||||||
|
<span class="material-symbols-outlined">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 px-5 py-5">
|
||||||
|
<div>
|
||||||
|
<label for="documentFile" class="mb-1.5 block text-sm font-bold text-slate-700">Chọn file PDF <span class="text-red-600">*</span></label>
|
||||||
|
<input id="documentFile" name="file" type="file" accept="application/pdf,.pdf" required
|
||||||
|
class="block h-11 w-full rounded-lg border border-slate-200 bg-white p-0 pr-3 text-sm file:mr-4 file:h-full file:border-0 file:bg-slate-100 file:px-4 file:font-bold file:text-slate-700 hover:file:bg-slate-200" />
|
||||||
|
<p class="mt-1.5 text-xs text-slate-500">Chỉ nhận định dạng PDF, tối đa ${this.documentMaxFileSizeMb} MB.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="documentTitle" class="mb-1.5 block text-sm font-bold text-slate-700">Tên hiển thị <span class="font-normal text-slate-400">(không bắt buộc)</span></label>
|
||||||
|
<input id="documentTitle" name="title" maxlength="255" placeholder="Mặc định lấy theo tên file"
|
||||||
|
class="h-11 w-full rounded-lg border-slate-200 bg-white text-sm focus:border-red-400 focus:ring-red-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col-reverse gap-2 border-t border-slate-200 bg-slate-50 px-5 py-4 sm:flex-row sm:justify-end">
|
||||||
|
<button type="button" data-close-document-dialog class="inline-flex h-10 items-center justify-center rounded-lg border border-slate-300 bg-white px-4 text-sm font-bold text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50">Hủy</button>
|
||||||
|
<button id="documentUploadSubmit" type="submit" class="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-red-600 px-5 text-sm font-bold text-white shadow-sm transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
<span class="material-symbols-outlined text-base">upload_file</span>
|
||||||
|
<span>Tải lên</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDocumentListeners() {
|
||||||
|
const dialog = document.getElementById('documentUploadDialog');
|
||||||
|
const form = document.getElementById('documentUploadForm');
|
||||||
|
const openDialogButton = document.getElementById('openDocumentUploadDialog');
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
if (!dialog || form?.dataset.uploading === 'true') return;
|
||||||
|
if (typeof dialog.close === 'function' && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
} else {
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (openDialogButton && dialog && !openDialogButton.dataset.boundClick) {
|
||||||
|
openDialogButton.addEventListener('click', () => {
|
||||||
|
if (typeof dialog.showModal === 'function') {
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
} else {
|
||||||
|
dialog.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => document.getElementById('documentFile')?.focus());
|
||||||
|
});
|
||||||
|
openDialogButton.dataset.boundClick = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-close-document-dialog]').forEach(button => {
|
||||||
|
if (button.dataset.boundClick) return;
|
||||||
|
button.addEventListener('click', closeDialog);
|
||||||
|
button.dataset.boundClick = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dialog && !dialog.dataset.boundDialog) {
|
||||||
|
dialog.addEventListener('cancel', event => {
|
||||||
|
if (form?.dataset.uploading === 'true') event.preventDefault();
|
||||||
|
});
|
||||||
|
dialog.addEventListener('close', () => form?.reset());
|
||||||
|
dialog.addEventListener('click', event => {
|
||||||
|
if (event.target !== dialog || form?.dataset.uploading === 'true') return;
|
||||||
|
const bounds = dialog.getBoundingClientRect();
|
||||||
|
const outside = event.clientX < bounds.left
|
||||||
|
|| event.clientX > bounds.right
|
||||||
|
|| event.clientY < bounds.top
|
||||||
|
|| event.clientY > bounds.bottom;
|
||||||
|
if (outside) closeDialog();
|
||||||
|
});
|
||||||
|
dialog.dataset.boundDialog = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form && !form.dataset.boundSubmit) {
|
||||||
|
form.addEventListener('submit', event => this.handleDocumentUpload(event));
|
||||||
|
form.dataset.boundSubmit = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('documentSearch');
|
||||||
|
if (searchInput && !searchInput.dataset.boundInput) {
|
||||||
|
searchInput.addEventListener('input', event => {
|
||||||
|
const cursor = event.target.selectionStart ?? event.target.value.length;
|
||||||
|
this.documentSearchTerm = event.target.value;
|
||||||
|
this.renderView('documents');
|
||||||
|
const nextInput = document.getElementById('documentSearch');
|
||||||
|
nextInput?.focus();
|
||||||
|
nextInput?.setSelectionRange(cursor, cursor);
|
||||||
|
});
|
||||||
|
searchInput.dataset.boundInput = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.delete-document').forEach(button => {
|
||||||
|
if (button.dataset.boundClick) return;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
this.deleteDocument(button.dataset.documentId, button.dataset.documentTitle);
|
||||||
|
});
|
||||||
|
button.dataset.boundClick = 'true';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDocumentUpload(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const fileInput = form.querySelector('#documentFile');
|
||||||
|
const titleInput = form.querySelector('#documentTitle');
|
||||||
|
const submitButton = form.querySelector('#documentUploadSubmit');
|
||||||
|
const file = fileInput?.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
this.notifyWarning('Vui lòng chọn file PDF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionIsPdf = String(file.name || '').toLowerCase().endsWith('.pdf');
|
||||||
|
if (!extensionIsPdf || (file.type && file.type !== 'application/pdf')) {
|
||||||
|
this.notifyFailure('Chỉ chấp nhận file PDF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > this.documentMaxFileSizeMb * 1024 * 1024) {
|
||||||
|
this.notifyFailure(`File vượt quá ${this.documentMaxFileSizeMb} MB`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.append('file', file);
|
||||||
|
payload.append('title', String(titleInput?.value || '').trim());
|
||||||
|
form.dataset.uploading = 'true';
|
||||||
|
form.querySelectorAll('[data-close-document-dialog]').forEach(button => {
|
||||||
|
button.disabled = true;
|
||||||
|
});
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.querySelector('span:last-child').textContent = 'Đang tải...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiBase}/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.message || 'Không thể tải tài liệu lên');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.documentSearchTerm = '';
|
||||||
|
await this.fetchDocuments();
|
||||||
|
this.renderView('documents');
|
||||||
|
this.notifySuccess(data.message || 'Đã lưu tài liệu PDF');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload document error:', err);
|
||||||
|
this.notifyFailure(err.message || 'Không thể tải tài liệu lên');
|
||||||
|
delete form.dataset.uploading;
|
||||||
|
form.querySelectorAll('[data-close-document-dialog]').forEach(button => {
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.querySelector('span:last-child').textContent = 'Tải lên';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDocument(documentId, title) {
|
||||||
|
const parsedId = Number.parseInt(documentId, 10);
|
||||||
|
if (!Number.isInteger(parsedId) || parsedId <= 0) return;
|
||||||
|
if (!window.confirm(`Xóa tài liệu “${String(title || 'Tài liệu')}”? Thao tác này không thể hoàn tác.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiBase}/documents/${parsedId}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.message || 'Không thể xóa tài liệu');
|
||||||
|
}
|
||||||
|
await this.fetchDocuments();
|
||||||
|
this.renderView('documents');
|
||||||
|
this.notifySuccess(data.message || 'Đã xóa tài liệu');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete document error:', err);
|
||||||
|
this.notifyFailure(err.message || 'Không thể xóa tài liệu');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getApplicationsContent() {
|
getApplicationsContent() {
|
||||||
const filteredApps = this.getFilteredApplications();
|
const filteredApps = this.getFilteredApplications();
|
||||||
const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize);
|
const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||||
<!-- Material Symbols -->
|
<!-- Material Symbols -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
|
||||||
<link rel="stylesheet" href="../css/main.css" />
|
<link rel="stylesheet" href="../css/main.css?v=20260806-4" />
|
||||||
<!-- Notiflix Notify -->
|
<!-- Notiflix Notify -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- Primary Nav -->
|
<!-- Primary Nav -->
|
||||||
<nav class="flex-1 px-3 space-y-4">
|
<nav class="flex-1 px-3 space-y-4 overflow-y-auto">
|
||||||
<div>
|
<div>
|
||||||
<div class="tree-label">Tài khoản</div>
|
<div class="tree-label">Tài khoản</div>
|
||||||
<div class="tree-branch">
|
<div class="tree-branch">
|
||||||
@@ -340,6 +340,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="tree-label">Tài liệu</div>
|
||||||
|
<div class="tree-branch">
|
||||||
|
<a href="#documents" data-nav="documents" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
|
||||||
|
<span class="material-symbols-outlined">picture_as_pdf</span>
|
||||||
|
<span>Tài liệu PDF</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="usersSection" class="pt-2 border-t border-outline-variant/10" style="display: none;">
|
<div id="usersSection" class="pt-2 border-t border-outline-variant/10" style="display: none;">
|
||||||
<a id="usersNav" href="#users" data-nav="users" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-lg" style="display: none;">
|
<a id="usersNav" href="#users" data-nav="users" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-lg" style="display: none;">
|
||||||
<span class="material-symbols-outlined">people</span>
|
<span class="material-symbols-outlined">people</span>
|
||||||
@@ -390,6 +400,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="../js/app.js?v=20260717-1"></script>
|
<script src="../js/app.js?v=20260806-4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ const {
|
|||||||
encryptSensitiveValue,
|
encryptSensitiveValue,
|
||||||
decryptSensitiveValue,
|
decryptSensitiveValue,
|
||||||
hashSessionToken,
|
hashSessionToken,
|
||||||
normalizeOptionalHttpUrl
|
normalizeOptionalHttpUrl,
|
||||||
|
isPdfBuffer,
|
||||||
|
sanitizeDocumentFileName,
|
||||||
|
getDocumentContentDisposition
|
||||||
} = require('../backend/server');
|
} = require('../backend/server');
|
||||||
|
|
||||||
async function withTestServer(run) {
|
async function withTestServer(run) {
|
||||||
@@ -55,6 +58,26 @@ test('application URLs accept only HTTP and HTTPS protocols', () => {
|
|||||||
assert.match(normalizeOptionalHttpUrl('https://example.com/path'), /^https:\/\/example\.com\/path/);
|
assert.match(normalizeOptionalHttpUrl('https://example.com/path'), /^https:\/\/example\.com\/path/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PDF uploads require a PDF header and end-of-file marker', () => {
|
||||||
|
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody\n%%EOF')), true);
|
||||||
|
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody without trailer')), false);
|
||||||
|
assert.equal(isPdfBuffer(Buffer.from('<html>not a pdf</html>')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document filenames and response headers cannot inject control characters', () => {
|
||||||
|
const safeName = sanitizeDocumentFileName('bao/cao\r\nInjected: value');
|
||||||
|
const longName = sanitizeDocumentFileName('a'.repeat(300));
|
||||||
|
const disposition = getDocumentContentDisposition('Báo cáo tháng 8.pdf');
|
||||||
|
|
||||||
|
assert.equal(safeName, 'bao_caoInjected_ value.pdf');
|
||||||
|
assert.equal(longName.length, 255);
|
||||||
|
assert.match(longName, /\.pdf$/);
|
||||||
|
assert.match(disposition, /^inline; filename="/);
|
||||||
|
assert.match(disposition, /filename\*=UTF-8''/);
|
||||||
|
assert.equal(disposition.includes('\r'), false);
|
||||||
|
assert.equal(disposition.includes('\n'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('forged legacy identity headers cannot bypass protected APIs', async () => {
|
test('forged legacy identity headers cannot bypass protected APIs', async () => {
|
||||||
await withTestServer(async baseUrl => {
|
await withTestServer(async baseUrl => {
|
||||||
const response = await fetch(`${baseUrl}/api/users`, {
|
const response = await fetch(`${baseUrl}/api/users`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user