diff --git a/package-lock.json b/package-lock.json index db6b7d05..9ba25a23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "dotenv": "^16.0.3", "eslint": "8.36.0", "eslint-config-next": "13.2.4", - "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.38", + "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.40", "next": "13.2.4", "prettier": "^2.8.7", "react": "18.2.0", @@ -1235,6 +1235,11 @@ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -1482,6 +1487,17 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1637,6 +1653,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2376,6 +2400,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3266,6 +3303,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", diff --git a/package.json b/package.json index 80137aaf..eac9c138 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "dotenv": "^16.0.3", "eslint": "8.36.0", "eslint-config-next": "13.2.4", - "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.38", + "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.40", + "form-data": "^4.0.0", "next": "13.2.4", "prettier": "^2.8.7", "react": "18.2.0", diff --git a/src/front/Api/BaseApiService.ts b/src/front/Api/BaseApiService.ts index 3461c6dd..2667e83d 100644 --- a/src/front/Api/BaseApiService.ts +++ b/src/front/Api/BaseApiService.ts @@ -31,6 +31,7 @@ export default abstract class BaseApiService { } protected buildBody(body: { [key: string]: unknown }): string { + console.log("2 >> body >>>", JSON.stringify(body)); return JSON.stringify(body); } @@ -54,6 +55,17 @@ export default abstract class BaseApiService { ); } + protected async postRequestFormData(url: URL, body: FormData) { + return this.sendRequest( + async () => + await fetch(url, { + method: "POST", + headers: this.buildHeaders(ContentType.FORM_DATA), + body, + }), + ); + } + protected async putRequest(url: URL, body: { [key: string]: unknown } = {}) { const request = async () => await fetch(url, { diff --git a/src/front/Api/LeCoffreApi/SuperAdmin/Documents/Documents.ts b/src/front/Api/LeCoffreApi/SuperAdmin/Documents/Documents.ts index 608bf951..e15c4f7e 100644 --- a/src/front/Api/LeCoffreApi/SuperAdmin/Documents/Documents.ts +++ b/src/front/Api/LeCoffreApi/SuperAdmin/Documents/Documents.ts @@ -13,7 +13,8 @@ export interface IGetDocumentsparams { // TODO Type getbyuid query params export type IPutDocumentsParams = { - document_status?: EDocumentStatus + document_status?: EDocumentStatus; + refused_reason?: string; }; export interface IPostDocumentsParams {} @@ -37,7 +38,8 @@ export default class Documents extends BaseSuperAdmin { public async get(q: IGetDocumentsparams): Promise { const url = new URL(this.baseURl); - Object.entries(q).forEach(([key, value]) => url.searchParams.set(key, JSON.stringify(value))); + const query = { q }; + if (q) Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, JSON.stringify(value))); try { return await this.getRequest(url); } catch (err) { @@ -80,4 +82,14 @@ export default class Documents extends BaseSuperAdmin { return Promise.reject(err); } } + + public async delete(uid: string): Promise { + const url = new URL(this.baseURl.concat(`/${uid}`)); + try { + return await this.deleteRequest(url); + } catch (err) { + this.onError(err); + return Promise.reject(err); + } + } } diff --git a/src/front/Api/LeCoffreApi/SuperAdmin/Files/Files.ts b/src/front/Api/LeCoffreApi/SuperAdmin/Files/Files.ts index 28a49068..63cc47ed 100644 --- a/src/front/Api/LeCoffreApi/SuperAdmin/Files/Files.ts +++ b/src/front/Api/LeCoffreApi/SuperAdmin/Files/Files.ts @@ -35,13 +35,14 @@ export default class Files extends BaseSuperAdmin { public async get(q: IGetFilesparams): Promise { const url = new URL(this.baseURl); - Object.entries(q).forEach(([key, value]) => url.searchParams.set(key, JSON.stringify(value))); + const query = { q }; + if (q) Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, JSON.stringify(value))); try { - const files = await this.getRequest(url); - files.forEach(async(file) => { + const files = await this.getRequest(url); + files.forEach(async (file) => { file.file_path = await this.cryptoService.decrypt(file.file_path!, file.iv); - }) - return files + }); + return files; } catch (err) { this.onError(err); return Promise.reject(err); @@ -54,7 +55,7 @@ export default class Files extends BaseSuperAdmin { public async post(body: any): Promise { const url = new URL(this.baseURl); try { - return await this.postRequest(url, body); + return await this.postRequestFormData(url, body); } catch (err) { this.onError(err); return Promise.reject(err); @@ -84,4 +85,17 @@ export default class Files extends BaseSuperAdmin { return Promise.reject(err); } } + + /** + * @description : Delete a folder only if the folder don't contains customers + */ + public async delete(uid: string): Promise { + const url = new URL(this.baseURl.concat(`/${uid}`)); + try { + return await this.deleteRequest(url); + } catch (err) { + this.onError(err); + return Promise.reject(err); + } + } } diff --git a/src/front/Components/DesignSystem/DepositDocument/index.tsx b/src/front/Components/DesignSystem/DepositDocument/index.tsx index 306a79b3..0f566b4b 100644 --- a/src/front/Components/DesignSystem/DepositDocument/index.tsx +++ b/src/front/Components/DesignSystem/DepositDocument/index.tsx @@ -9,13 +9,15 @@ import Button, { EButtonVariant } from "../Button"; import Tooltip from "../ToolTip"; import Typography, { ITypo, ITypoColor } from "../Typography"; import classes from "./classes.module.scss"; -import { File as FileCustomer } from "le-coffre-resources/dist/Customer"; +import { Document, File as FileCustomer } from "le-coffre-resources/dist/Customer"; +import Files from "@Front/Api/LeCoffreApi/SuperAdmin/Files/Files"; type IProps = { title: string; dateAsked: Date; defaultFiles?: FileCustomer[]; onChange?: (files: File[]) => void; + document: Document; }; type IFile = { @@ -26,6 +28,7 @@ type IFile = { type IState = { files: IFile[]; isDragOver: boolean; + currentFiles?: FileCustomer[]; }; export default class DepositDocument extends React.Component { @@ -38,6 +41,7 @@ export default class DepositDocument extends React.Component { this.state = { files: [], isDragOver: false, + currentFiles: this.props.defaultFiles, }; this.addDocument = this.addDocument.bind(this); @@ -151,7 +155,7 @@ export default class DepositDocument extends React.Component { if (file) this.addFile(file); } - private addFile(file: File) { + private async addFile(file: File) { this.setState({ files: [ ...this.state.files, @@ -163,17 +167,33 @@ export default class DepositDocument extends React.Component { }); if (this.props.onChange) this.props.onChange(this.state.files.map((file) => file.file)); + + const formData = new FormData(); + formData.append("file", file, file.name); + const query = JSON.stringify({ document: { uid: this.props.document.uid } }); + formData.append("q", query); + + const newFile = await Files.getInstance().post(formData); + const files = this.state.currentFiles ? [...this.state.currentFiles, newFile] : [newFile]; + this.setState({ + currentFiles: files, + }); } - private removeFile(e: any) { + private async removeFile(e: any) { const image = e.target as HTMLElement; const indexToRemove = image.getAttribute("data-file"); if (!indexToRemove) return; + // const file = this.state.files.find((file) => file.index === parseInt(indexToRemove)); this.setState({ files: this.state.files.filter((file) => file.index !== parseInt(indexToRemove)), }); if (this.props.onChange) this.props.onChange(this.state.files.map((file) => file.file)); + // TODO Finir la suppression de fichier + // const deletedFileUid = this.props.document.files?.find((file) => file.file_path === newFile.file_path)?.uid; + // console.log({ deletedFileUid }); + // await Files.getInstance().delete(file?.uid); } private async onFileChange() { diff --git a/src/front/Components/DesignSystem/UserFolder/UserFolderHeader/index.tsx b/src/front/Components/DesignSystem/UserFolder/UserFolderHeader/index.tsx index b82b80c2..bdc82961 100644 --- a/src/front/Components/DesignSystem/UserFolder/UserFolderHeader/index.tsx +++ b/src/front/Components/DesignSystem/UserFolder/UserFolderHeader/index.tsx @@ -67,7 +67,6 @@ export default class UserFolderHeader extends React.Component { } private hasPendingFiles() { - console.log(this.props.folder.documents); const documents = this.props.folder.documents?.filter((document) => document.depositor?.contact?.uid === this.props.customer.contact?.uid) ?? []; const notAskedDocuments = documents.filter((document) => document.document_status === EDocumentStatus.DEPOSITED) ?? []; diff --git a/src/front/Components/DesignSystem/UserFolder/index.tsx b/src/front/Components/DesignSystem/UserFolder/index.tsx index d0c528de..289ef64c 100644 --- a/src/front/Components/DesignSystem/UserFolder/index.tsx +++ b/src/front/Components/DesignSystem/UserFolder/index.tsx @@ -15,6 +15,7 @@ import classes from "./classes.module.scss"; import DocumentList from "./DocumentList"; import UserFolderHeader from "./UserFolderHeader"; import { EDocumentStatus } from "le-coffre-resources/dist/Customer/Document"; +import Documents from "@Front/Api/LeCoffreApi/SuperAdmin/Documents/Documents"; type IProps = { customer: Customer; @@ -26,6 +27,7 @@ type IProps = { }; type IState = { isOpenDeletionModal: boolean; + selectedDocumentToDelete: string; }; export default class UserFolder extends React.Component { @@ -39,10 +41,12 @@ export default class UserFolder extends React.Component { super(props); this.state = { isOpenDeletionModal: false, + selectedDocumentToDelete: "", }; this.closeDeletionModal = this.closeDeletionModal.bind(this); this.openDeletionModal = this.openDeletionModal.bind(this); this.changeUserFolder = this.changeUserFolder.bind(this); + this.deleteAskedDocument = this.deleteAskedDocument.bind(this); } public override render(): JSX.Element { const documentsAsked: Document[] | null = this.getDocumentsByStatus("ASKED"); @@ -55,6 +59,7 @@ export default class UserFolder extends React.Component { { this.rootRefElement.current?.style.setProperty("--animation-delay", this.props.animationDelay!.toString().concat("ms")); } + private async deleteAskedDocument(){ + try{ + await Documents.getInstance().delete(this.state.selectedDocumentToDelete); + window.location.reload(); + }catch(e){ + console.error(e); + } + } + private calculateDocumentsPercentageProgress(): number { if (!this.props.customer.documents) return 0; const totalDocuments: number = this.props.customer.documents.length; const numberDocumentsAsked: number = this.getDocumentsByStatus(EDocumentStatus.ASKED)?.length || 0; - return Math.round(((totalDocuments - numberDocumentsAsked) / totalDocuments) * 100); + + const percentage = Math.round(((totalDocuments - numberDocumentsAsked) / totalDocuments) * 100); + if(!percentage) return 0; + return percentage; } private getDocumentsByStatus(status: string): Document[] | null { @@ -159,9 +176,11 @@ export default class UserFolder extends React.Component { } private openDeletionModal(uid?: string): void { - // TODO: call API to delete document + if(!uid) return; + this.setState({ isOpenDeletionModal: true, + selectedDocumentToDelete: uid, }); } private closeDeletionModal(): void { diff --git a/src/front/Components/Layouts/ClientDashboard/index.tsx b/src/front/Components/Layouts/ClientDashboard/index.tsx index 1f147b94..6415bb59 100644 --- a/src/front/Components/Layouts/ClientDashboard/index.tsx +++ b/src/front/Components/Layouts/ClientDashboard/index.tsx @@ -8,10 +8,14 @@ import DefaultTemplate from "@Front/Components/LayoutTemplates/DefaultTemplate"; import React from "react"; import { documentDeposited } from "../DesignSystem/dummyData"; import classes from "./classes.module.scss"; +import Documents, { IGetDocumentsparams } from "@Front/Api/LeCoffreApi/SuperAdmin/Documents/Documents"; +import { Document } from "le-coffre-resources/dist/Customer"; +import Users from "@Front/Api/LeCoffreApi/SuperAdmin/Users/Users"; type IProps = {}; type IState = { isAddDocumentModalVisible: boolean; + documents: Document[]; }; export default class ClientDashboard extends Base { @@ -19,6 +23,7 @@ export default class ClientDashboard extends Base { super(props); this.state = { isAddDocumentModalVisible: false, + documents: [], }; this.onCloseModalAddDocument = this.onCloseModalAddDocument.bind(this); this.onOpenModalAddDocument = this.onOpenModalAddDocument.bind(this); @@ -31,16 +36,15 @@ export default class ClientDashboard extends Base { {this.renderHeader()}
- - - - - - + {this.state.documents?.map((document) => ( + + ))}
Documents supplémentaires (facultatif) @@ -67,7 +71,7 @@ export default class ClientDashboard extends Base { Glissez / Déposez votre document dans la zone prévue à cet effet ou cliquez sur la zone puis sélectionnez le document correspondant. - +
@@ -100,6 +104,21 @@ export default class ClientDashboard extends Base { ); } + public override async componentDidMount() { + // TODO Get documents of the current customer according to userStore + // REMOVE this mock + const mockUsers = (await Users.getInstance().get({}))[2]; + const query: IGetDocumentsparams = { + where: { depositor: mockUsers?.uid }, + include: { + files: true, + }, + }; + const documents: Document[] = await Documents.getInstance().get(query); + console.log({ documents }); + this.setState({ documents }); + } + private onCloseModalAddDocument() { this.setState({ isAddDocumentModalVisible: false }); } diff --git a/src/front/Components/Layouts/Folder/AskDocuments/index.tsx b/src/front/Components/Layouts/Folder/AskDocuments/index.tsx index e79da376..d7b84f44 100644 --- a/src/front/Components/Layouts/Folder/AskDocuments/index.tsx +++ b/src/front/Components/Layouts/Folder/AskDocuments/index.tsx @@ -276,7 +276,6 @@ class AskDocumentsClass extends BasePage { }catch(e){ console.error(e); } - console.log(values["document_types"]); } } diff --git a/src/front/Components/Layouts/Folder/ViewDocuments/index.tsx b/src/front/Components/Layouts/Folder/ViewDocuments/index.tsx index 9d0ec40a..f737b1bc 100644 --- a/src/front/Components/Layouts/Folder/ViewDocuments/index.tsx +++ b/src/front/Components/Layouts/Folder/ViewDocuments/index.tsx @@ -66,6 +66,7 @@ class ViewDocumentsClass extends BasePage { this.hasPrevious = this.hasPrevious.bind(this); this.hasNext = this.hasNext.bind(this); + this.refuseDocument = this.refuseDocument.bind(this); } public override render(): JSX.Element | null { @@ -156,7 +157,7 @@ class ViewDocumentsClass extends BasePage { { return index < this.state.document!.files!.length; } + private async refuseDocument(){ + try{ + await Documents.getInstance().put(this.props.documentUid, { + document_status: EDocumentStatus.REFUSED, + refused_reason: this.state.refuseText + }); + + this.props.router.push( + Module.getInstance() + .get() + .modules.pages.Folder.pages.FolderInformation.props.path.replace("[folderUid]", this.props.folderUid), + ); + }catch(e){ + console.error(e); + } + } + private async validateAnchoring() { this.setState({ hasValidateAnchoring: true, diff --git a/src/front/Services/CryptoService/CryptoService.ts b/src/front/Services/CryptoService/CryptoService.ts index c2d62524..281d7023 100644 --- a/src/front/Services/CryptoService/CryptoService.ts +++ b/src/front/Services/CryptoService/CryptoService.ts @@ -5,7 +5,7 @@ import crypto from "crypto"; @Service() export default class CryptoService { private jwkKey: JsonWebKey; - private subtle: SubtleCrypto = crypto.webcrypto.subtle + private subtle: SubtleCrypto = window.crypto.subtle; constructor(protected variables: FrontendVariables) { this.jwkKey = { kty: "oct", @@ -16,7 +16,7 @@ export default class CryptoService { } private async getKey() { - return await this.subtle.importKey("jwk", this.jwkKey, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]); + return await this.subtle.importKey("jwk", this.jwkKey, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); } /** @@ -36,8 +36,8 @@ export default class CryptoService { encodedData, ); - const cipherText = Buffer.from(cipherData).toString('base64'); - const ivStringified = Buffer.from(iv).toString('base64'); + const cipherText = Buffer.from(cipherData).toString("base64"); + const ivStringified = Buffer.from(iv).toString("base64"); return { cipherText, ivStringified }; } @@ -47,8 +47,8 @@ export default class CryptoService { * @throws {Error} If data cannot be decrypted */ public async decrypt(cipherText: string, ivStringified: string): Promise { - const cipherData = Buffer.from(cipherText, 'base64'); - const iv = Buffer.from(ivStringified, 'base64'); + const cipherData = Buffer.from(cipherText, "base64"); + const iv = Buffer.from(ivStringified, "base64"); const key = await this.getKey(); const decryptedData = await this.subtle.decrypt( { @@ -59,6 +59,6 @@ export default class CryptoService { cipherData, ); - return Buffer.from(decryptedData).toString('utf-8'); + return Buffer.from(decryptedData).toString("utf-8"); } } diff --git a/tsconfig.json b/tsconfig.json index e13e0501..ae259ebf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "incremental": false, + "incremental": false, "target": "es5", "module": "CommonJS", "lib": [ @@ -20,7 +20,7 @@ "allowUnusedLabels": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": true, - "strict": true, + "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, @@ -30,22 +30,38 @@ "alwaysStrict": true, "noPropertyAccessFromIndexSignature": true, /* Additional Checks */ - "noUnusedLocals": true, - "noImplicitReturns": true, + "noUnusedLocals": true, + "noImplicitReturns": true, "noUncheckedIndexedAccess": true, "useUnknownInCatchVariables": true, /* Module Resolution Options */ "moduleResolution": "node", "baseUrl": ".", "paths": { - "@Api/*": ["src/api/*"], - "@Front/*": ["src/front/*"], - "@Assets/*": ["src/front/Assets/*"], - "@Components/*": ["src/front/Components/*"], - "@Themes/*": ["src/front/Themes/*"], - "@Stores/*": ["src/front/Stores/*"], - "@FrontServices/*": ["src/front/services/*"], - "@Page/*": ["src/pages/*"], + "@Api/*": [ + "src/api/*" + ], + "@Front/*": [ + "src/front/*" + ], + "@Assets/*": [ + "src/front/Assets/*" + ], + "@Components/*": [ + "src/front/Components/*" + ], + "@Themes/*": [ + "src/front/Themes/*" + ], + "@Stores/*": [ + "src/front/Stores/*" + ], + "@FrontServices/*": [ + "src/front/services/*" + ], + "@Page/*": [ + "src/pages/*" + ], }, // "rootDirs": [], // "typeRoots": [], @@ -60,19 +76,19 @@ //"inlineSources": false, /* Experimental Options */ "experimentalDecorators": true, - "emitDecoratorMetadata": true, + "emitDecoratorMetadata": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "forceConsistentCasingInFileNames": true, "allowJs": true, "isolatedModules": true }, "include": [ "next-env.d.ts", "**/*.ts", - "**/*.tsx" -, "src/front/next.config.js" ], + "**/*.tsx", + "src/front/next.config.js" + ], "exclude": [ "node_modules" ] -} - +} \ No newline at end of file