Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6bdd104d5 | ||
|
|
9bf0f400fe | ||
|
|
84f6a00572 | ||
|
|
638eeb077f | ||
|
|
d6cb1e9595 | ||
|
|
dbf43792ad | ||
|
|
23cd481dfb | ||
|
|
793639cc98 | ||
|
|
391e321c15 | ||
|
|
e1819f1b36 |
@@ -42,6 +42,7 @@ schemaspy/output/
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
tfplan
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
override.tf
|
||||
|
||||
@@ -9,22 +9,27 @@
|
||||
## Architecture details that matter
|
||||
- Frontend route split is auth-state driven in `client/src/App.tsx` + `client/src/routes/ProtectedRoute/index.tsx`; private screens render through `client/src/layout/PrivateLayout/index.tsx`.
|
||||
- Home view (`client/src/views/Home/index.tsx`) is a key integration surface: loads tasks/notes/tags, applies client-side filtering, and drives note sharing (`/rest/notes/{id}/share` + `/public/notes/{token}`).
|
||||
- Three React Contexts are active at runtime: `AuthProvider` (JWT lifecycle), `FilterProvider` (search text + option persisted to `localStorage` as `FILTER_TEXT`/`FILTER_OPTION`), and `SidebarProvider` (sidebar open/close state) — all under `client/src/context/`.
|
||||
- Backend follows controller -> service -> repository layering (`server/src/main/java/br/com/tasknoteapp/server/{controller,service,repository}`).
|
||||
- `HomeController` (`/rest/home`) exposes aggregated data such as `/rest/home/tasks/tags`; it is separate from `TaskController` and `NoteController`.
|
||||
- Global API error shape comes from `server/src/main/java/br/com/tasknoteapp/server/controller/RestExceptionController.java`; frontend expects `message` or `fields[].fieldMessage` (`client/src/api-service/api.ts`).
|
||||
- Email and password-reset flows are Mailgun-backed (`server/src/main/java/br/com/tasknoteapp/server/service/MailgunEmailService.java`) and use templates under `server/src/main/java/br/com/tasknoteapp/server/templates/` + `mailgun-templates/`.
|
||||
- Backend targets **Java 25** and **Spring Boot 4.x** (`server/pom.xml`).
|
||||
|
||||
## Developer workflows (use these first)
|
||||
- Frontend quality gate: `bash tools/check-frontend.sh` (runs `npm ci`, `lint:fix`, `build`, `test:no-watch`).
|
||||
- Backend quality gate: `bash tools/check-backend.sh` (runs checkstyle, compile, then `clean verify -P tests`).
|
||||
- Backend dependency freshness check: `bash tools/check-be-deps.sh` (verifies that `failsafe`, `surefire`, `jacoco`, `checkstyle`, and Spring Boot plugin versions in `pom.xml` match the latest releases on Maven Central; must be run from the repo root or `server/`).
|
||||
- Important Maven default: tests/checkstyle/jacoco are skipped unless profile `-P tests` is enabled (`server/pom.xml`).
|
||||
- Dev stack via Docker Compose/Taskfile (`Taskfile.yml`, `docker-compose.dev.yml`): app `5000`, API `8585`, Java debug port `5005`, Postgres `5432`.
|
||||
- Frontend local dev: run from `client/` with `npm start`; backend local dev: run from `server/` with `./mvnw spring-boot:run`.
|
||||
|
||||
## CI/CD and release behavior
|
||||
- PR workflows (`.github/workflows/client-ci.yml`, `.github/workflows/server-ci.yml`) run checks then push `:candidate` images to GHCR.
|
||||
- Main workflows (`.github/workflows/main-client.yml`, `.github/workflows/main-server.yml`) push versioned tags + `latest`; backend workflow also increments `server/pom.xml` version.
|
||||
- Deploy workflows (`.github/workflows/deploy-stg.yml`, `.github/workflows/deploy.yml`) apply Terraform in `terraform-stg/` and `terraform/` to Kubernetes.
|
||||
- Infra wiring (secrets, services, ingress, image vars) is defined in `terraform/main.tf`.
|
||||
- PR workflows (`.github/workflows/ci-pr-frontend.yml`, `.github/workflows/ci-pr-backend.yml`) run checks then push `:candidate` and `:pr-<N>` images to GHCR.
|
||||
- Main workflows (`.github/workflows/ci-main-frontend.yml`, `.github/workflows/ci-main-backend.yml`) push versioned tags (`app-v<date>.<run>` / `api-v<pom-version>`) + `latest`; backend workflow also increments `server/pom.xml` version.
|
||||
- Staging deploy workflow (`.github/workflows/cd-pr.yml`) triggers on completion of either PR CI workflow and applies Terraform in `terraform-stg/` using a plan→apply split.
|
||||
- Production deploy workflow (`.github/workflows/cd-main.yml`) triggers on completion of either Main CI workflow and applies Terraform in `terraform/` using a plan→apply split; `apply` can be skipped if there are no Terraform changes.
|
||||
- Infra wiring (secrets, services, ingress, image vars) is defined in `terraform/main.tf`; an alternative GCP target is under `terraform-gcp/`.
|
||||
|
||||
## Project conventions to preserve
|
||||
- Frontend style is ESLint flat config + stylistic rules (2-space indent, single quotes, semicolons) in `client/eslint.config.mjs`.
|
||||
@@ -37,4 +42,3 @@
|
||||
- Keep API contract compatibility with frontend `ApiConfig` paths; changing endpoint paths requires synchronized client updates.
|
||||
- When changing auth/session behavior, update both backend filters/controllers and frontend `AuthProvider` token lifecycle.
|
||||
- If you change CI/build/deploy commands, update both `README.md` and this `AGENTS.md` to keep workflows discoverable.
|
||||
|
||||
|
||||
Generated
-510
@@ -496,448 +496,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
|
||||
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
|
||||
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
|
||||
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
|
||||
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
|
||||
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
|
||||
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
|
||||
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
|
||||
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
|
||||
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
|
||||
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -4943,49 +4501,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
|
||||
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.1",
|
||||
"@esbuild/android-arm": "0.27.1",
|
||||
"@esbuild/android-arm64": "0.27.1",
|
||||
"@esbuild/android-x64": "0.27.1",
|
||||
"@esbuild/darwin-arm64": "0.27.1",
|
||||
"@esbuild/darwin-x64": "0.27.1",
|
||||
"@esbuild/freebsd-arm64": "0.27.1",
|
||||
"@esbuild/freebsd-x64": "0.27.1",
|
||||
"@esbuild/linux-arm": "0.27.1",
|
||||
"@esbuild/linux-arm64": "0.27.1",
|
||||
"@esbuild/linux-ia32": "0.27.1",
|
||||
"@esbuild/linux-loong64": "0.27.1",
|
||||
"@esbuild/linux-mips64el": "0.27.1",
|
||||
"@esbuild/linux-ppc64": "0.27.1",
|
||||
"@esbuild/linux-riscv64": "0.27.1",
|
||||
"@esbuild/linux-s390x": "0.27.1",
|
||||
"@esbuild/linux-x64": "0.27.1",
|
||||
"@esbuild/netbsd-arm64": "0.27.1",
|
||||
"@esbuild/netbsd-x64": "0.27.1",
|
||||
"@esbuild/openbsd-arm64": "0.27.1",
|
||||
"@esbuild/openbsd-x64": "0.27.1",
|
||||
"@esbuild/openharmony-arm64": "0.27.1",
|
||||
"@esbuild/sunos-x64": "0.27.1",
|
||||
"@esbuild/win32-arm64": "0.27.1",
|
||||
"@esbuild/win32-ia32": "0.27.1",
|
||||
"@esbuild/win32-x64": "0.27.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -11149,31 +10664,6 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-declaration-location": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz",
|
||||
"integrity": "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/rebeccastevens"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/ts-declaration-location"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-toolbelt": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz",
|
||||
|
||||
+2
-2
@@ -103,7 +103,7 @@ function App(): React.ReactNode {
|
||||
|
||||
/**
|
||||
* Determines the appropriate router based on the user's authentication status.
|
||||
* @returns {Router} The configured router.
|
||||
* @returns The configured router.
|
||||
*/
|
||||
const getBrowserRouter = () => {
|
||||
if (signed) {
|
||||
@@ -137,6 +137,6 @@ function App(): React.ReactNode {
|
||||
{!loading && <RouterProvider router={browserRouter} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('AlertError Component tests', () => {
|
||||
const dataTestId: string = "alert-error-msg-tests";
|
||||
const renderAlert = (message?: string) => {
|
||||
return render(
|
||||
<AlertError errorMessage={message} dataTestid={dataTestId} />
|
||||
<AlertError errorMessage={message} dataTestId={dataTestId} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -140,4 +140,26 @@ describe('LoginForm Component test', () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should display resend button when login fails with "Email not confirmed!"', async () => {
|
||||
authContextMock.signIn.mockRejectedValue(new Error('Email not confirmed!'));
|
||||
renderFn("login");
|
||||
|
||||
const emailInput = screen.getByTestId('login_email_input');
|
||||
const passwordInput = screen.getByTestId('account-password-login');
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
const errorMessage = await screen.findByText(/email not confirmed/i);
|
||||
expect(errorMessage).toBeDefined();
|
||||
|
||||
const resendButton = screen.getByRole('button', { name: /resend confirmation email/i });
|
||||
expect(resendButton).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('SidebarProvider', () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('page').textContent).toBe('/another')
|
||||
expect(getByTestId('page').textContent).toBe('/home')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Alert } from 'react-bootstrap';
|
||||
|
||||
type Props = {
|
||||
errorMessage?: string;
|
||||
dataTestid?: string;
|
||||
dataTestId?: string;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
*
|
||||
* @param {Props} props the AlertError props with the message to be displayed.
|
||||
* @param {string} [props.errorMessage] Optional error message.
|
||||
* @param {string} [props.dataTestid] Optional data-testid property.
|
||||
* @param {string} [props.dataTestId] Optional data-testId property.
|
||||
* @param {Function} [props.onClose] OnClose function to be called.
|
||||
* @returns {React.ReactNode} the AlertError rendered component.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ const AlertError: React.FC<Props> = (props: Props): React.ReactNode => {
|
||||
<Alert
|
||||
variant="danger"
|
||||
dismissible
|
||||
data-testid={props.dataTestid}
|
||||
data-testid={props.dataTestId}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{ props.errorMessage }
|
||||
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
valueDate?: Date | null;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onChangeDate?: (date: Date | null) => void;
|
||||
data_testid?: string;
|
||||
dataTestId?: string;
|
||||
pwdHideText?: string;
|
||||
pwdShowText?: string;
|
||||
pwdHelperTxt?: string;
|
||||
@@ -107,7 +107,7 @@ function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
placeholder={props.placeholder ? props.placeholder : ''}
|
||||
value={props?.value}
|
||||
onChange={props.onChange}
|
||||
data-testid={props.data_testid}
|
||||
data-testid={props.dataTestId}
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
@@ -34,6 +34,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
const [formInvalid, setFormInvalid] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [successMessage, setSuccessMessage] = useState<string>('');
|
||||
const [showResend, setShowResend] = useState<boolean>(false);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [passwordAgain, setPasswordAgain] = useState<string>('');
|
||||
@@ -53,16 +54,16 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
/**
|
||||
* Handles the form submit button click event.
|
||||
*
|
||||
* When clicked, checks if the form is valid. It it's not, raise an error
|
||||
* message in an Alert. Otherwise, it call the signIn API and navigates to the home page.
|
||||
* When clicked, checks if the form is valid. If it's not, raise an error
|
||||
* message in an Alert. Otherwise, it calls the signIn API and navigates to the home page.
|
||||
*/
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setValidated(true);
|
||||
|
||||
const form = event.currentTarget;
|
||||
if (form.checkValidity() === false) {
|
||||
if (!form.checkValidity()) {
|
||||
setFormInvalid(true);
|
||||
if (prefix === 'complete_reset') {
|
||||
setErrorMessage(translateServerResponse('Please fill in the new password', i18n.language));
|
||||
@@ -82,6 +83,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
}
|
||||
|
||||
setFormInvalid(false);
|
||||
setShowResend(false);
|
||||
try {
|
||||
if (prefix === 'login') {
|
||||
await signIn(email, password);
|
||||
@@ -93,6 +95,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
setPassword('');
|
||||
setPasswordAgain('');
|
||||
setSuccessMessage(translateServerResponse('Please confirm your email before proceeding', i18n.language));
|
||||
setShowResend(true);
|
||||
setSecondsLeft(30);
|
||||
setIsResendEnabled(false);
|
||||
}
|
||||
@@ -107,15 +110,19 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e);
|
||||
setFormInvalid(true);
|
||||
let msg = '';
|
||||
if (typeof e === 'string') {
|
||||
console.log('string');
|
||||
setErrorMessage(translateServerResponse(e, i18n.language));
|
||||
msg = e;
|
||||
}
|
||||
else if (e instanceof Error) {
|
||||
console.log(e.message);
|
||||
setErrorMessage(translateServerResponse(e.message, i18n.language));
|
||||
msg = e.message;
|
||||
}
|
||||
|
||||
setErrorMessage(translateServerResponse(msg, i18n.language));
|
||||
if (msg === 'Email not confirmed!') {
|
||||
setShowResend(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -124,7 +131,9 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
try {
|
||||
await api.postJSON(ApiConfig.resendConfirmUrl, { email });
|
||||
|
||||
setSuccessMessage('Confirmation email re-sent! Please check the spam folder.');
|
||||
setSuccessMessage(translateServerResponse('Confirmation email re-sent! Please check the spam folder.', i18n.language));
|
||||
setFormInvalid(false);
|
||||
setShowResend(true);
|
||||
setSecondsLeft(30);
|
||||
setIsResendEnabled(false);
|
||||
}
|
||||
@@ -164,36 +173,48 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
|
||||
{formInvalid
|
||||
? (
|
||||
<Alert variant="danger">
|
||||
{ errorMessage }
|
||||
</Alert>
|
||||
<>
|
||||
<Alert variant="danger">{errorMessage}</Alert>
|
||||
{showResend && (
|
||||
<div className="text-center mt-2">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
onClick={handleResend}
|
||||
disabled={!isResendEnabled}
|
||||
>
|
||||
{isResendEnabled
|
||||
? `${t('register_resent_email')}`
|
||||
: `${t('register_resent_email_secs')} ${secondsLeft}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
||||
{successMessage.length > 1 && prefix === 'register' && (
|
||||
{successMessage.length > 1 && (
|
||||
<>
|
||||
<Alert variant="success">
|
||||
{ successMessage }
|
||||
</Alert>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={handleResend}
|
||||
disabled={!isResendEnabled}
|
||||
>
|
||||
{isResendEnabled ? `${t('register_resent_email')}` : `${t('register_resent_email_secs')} ${secondsLeft}`}
|
||||
</Button>
|
||||
</div>
|
||||
{showResend && (
|
||||
<div className="text-center mb-3">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={handleResend}
|
||||
disabled={!isResendEnabled}
|
||||
>
|
||||
{isResendEnabled
|
||||
? `${t('register_resent_email')}`
|
||||
: `${t('register_resent_email_secs')} ${secondsLeft}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{successMessage.length > 1 && prefix !== 'register' && (
|
||||
<Alert variant="success">
|
||||
{ successMessage }
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form noValidate validated={validated} onSubmit={handleSubmit}>
|
||||
{prefix !== 'complete_reset' && (
|
||||
<FormInput
|
||||
@@ -202,7 +223,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
required={true}
|
||||
name="email"
|
||||
placeholder={t(`${prefix}_email_placeholder`)}
|
||||
data_testid={`${prefix}_email_input`}
|
||||
dataTestId={`${prefix}_email_input`}
|
||||
value={email}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value);
|
||||
@@ -225,7 +246,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
data_testid="account-password-login"
|
||||
dataTestId="account-password-login"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -244,7 +265,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPasswordAgain(e.target.value);
|
||||
}}
|
||||
data_testid={`account-repeat-password-${prefix}`}
|
||||
dataTestId={`account-repeat-password-${prefix}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -259,7 +280,10 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
|
||||
<div className="text-center mt-3">
|
||||
{t(`${prefix}_account`)}
|
||||
<Link to={prefix === 'login' ? '/register' : '/login'} className="text-decoration-none ms-2">
|
||||
<Link
|
||||
to={prefix === 'login' ? '/register' : '/login'}
|
||||
className="text-decoration-none ms-2"
|
||||
>
|
||||
{t(`${prefix}_go_other`)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -271,7 +295,10 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
</div>
|
||||
<div className="text-center mt-3">
|
||||
{prefix === 'login' && (
|
||||
<Link to="/reset-password" className="text-decoration-none ms-2">
|
||||
<Link
|
||||
to="/reset-password"
|
||||
className="text-decoration-none ms-2"
|
||||
>
|
||||
{t('login_forgot_password')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Nav } from 'react-bootstrap';
|
||||
import { NavLink } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -22,6 +22,7 @@ interface Props {
|
||||
function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
const { signOut, user } = useContext(AuthContext);
|
||||
const { currentPage, setNewPage } = useContext(SidebarContext);
|
||||
const [lastSeen, setLastSeen] = useState('');
|
||||
const { t } = useTranslation();
|
||||
const build = `Build: ${env.VITE_BUILD}`;
|
||||
|
||||
@@ -46,7 +47,25 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
return '';
|
||||
};
|
||||
|
||||
useEffect(() => {}, [user, currentPage]);
|
||||
useEffect(() => {
|
||||
if (user && user.lastLogin) {
|
||||
const utcString = user.lastLogin.endsWith('Z') ? user.lastLogin : `${user.lastLogin}Z`;
|
||||
const date = new Date(utcString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
setLastSeen('');
|
||||
return;
|
||||
}
|
||||
const fmtted = date.toLocaleString(navigator.language, {
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
setLastSeen(fmtted);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -95,9 +114,12 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
|
||||
{/* Footer at the bottom */}
|
||||
<div className="mt-auto text-center text-muted py-3">
|
||||
<small data-testid="footer-text">
|
||||
{build}
|
||||
</small>
|
||||
{lastSeen && (
|
||||
<div>
|
||||
<small>{t('sidebar_last_seen', { time: lastSeen })}</small>
|
||||
</div>
|
||||
)}
|
||||
<small data-testid="footer-text">{build}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -187,6 +187,7 @@ const enTranslations = {
|
||||
account_delete_btn: 'Yes, delete everything',
|
||||
|
||||
footer_my_account: 'My Account ',
|
||||
sidebar_last_seen: 'Last seen {{time}}',
|
||||
|
||||
logout: 'Logout'
|
||||
};
|
||||
|
||||
@@ -90,6 +90,8 @@ export const serverResponsesTranslations: Record<string, string> = {
|
||||
WRONG_IDENTIFICATION_pt_br: 'Identificação incorreta ou faltando',
|
||||
WRONG_OR_MISSING_INFO_pt_br: 'Informação errada ou incompleta!',
|
||||
NOTHING_TO_UPDATE_pt_br: 'Nada para atualizar!',
|
||||
EMAIL_NOT_CONFIRMED_pt_br: 'E-mail não confirmado!',
|
||||
CONFIRMATION_RESENT_pt_br: 'E-mail de confirmação reenviado! Por favor, verifique a pasta de spam.',
|
||||
|
||||
BAD_PASSWORD_8_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres',
|
||||
BAD_PASSWORD_7_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 carácter especial',
|
||||
@@ -117,6 +119,8 @@ export const serverResponsesTranslations: Record<string, string> = {
|
||||
WRONG_IDENTIFICATION_es: 'Identificación incorrecta o faltante',
|
||||
WRONG_OR_MISSING_INFO_es: '¡Información incorrecta o incompleta!',
|
||||
NOTHING_TO_UPDATE_es: '¡Nada que actualizar!',
|
||||
EMAIL_NOT_CONFIRMED_es: '¡Correo no confirmado!',
|
||||
CONFIRMATION_RESENT_es: '¡Correo de confirmación reenviado! Por favor, revisa la carpeta de spam.',
|
||||
|
||||
BAD_PASSWORD_8_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов.',
|
||||
BAD_PASSWORD_7_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 специальный символ.',
|
||||
@@ -143,5 +147,7 @@ export const serverResponsesTranslations: Record<string, string> = {
|
||||
UNKNOWN_ru: 'Неизвестная ошибка',
|
||||
WRONG_IDENTIFICATION_ru: 'Неправильная или отсутствующая идентификация',
|
||||
WRONG_OR_MISSING_INFO_ru: 'Неверная или отсутствующая информация!',
|
||||
NOTHING_TO_UPDATE_ru: 'Нечего обновлять!'
|
||||
NOTHING_TO_UPDATE_ru: 'Нечего обновлять!',
|
||||
EMAIL_NOT_CONFIRMED_ru: 'Электронная почта не подтверждена!',
|
||||
CONFIRMATION_RESENT_ru: 'Письмо с подтверждением отправлено еще раз! Пожалуйста, проверьте папку со спамом.'
|
||||
};
|
||||
|
||||
@@ -188,6 +188,7 @@ const ptBrTranslations = {
|
||||
account_delete_btn: 'Sim, deletar tudo',
|
||||
|
||||
footer_my_account: 'Minha Conta ',
|
||||
sidebar_last_seen: 'Último acesso {{time}}',
|
||||
|
||||
logout: 'Sair'
|
||||
};
|
||||
|
||||
@@ -187,6 +187,7 @@ const ruTranslations = {
|
||||
account_delete_btn: 'Да, удалить все',
|
||||
|
||||
footer_my_account: 'Мой аккаунт ',
|
||||
sidebar_last_seen: 'Последний вход {{time}}',
|
||||
|
||||
logout: 'Выйти'
|
||||
};
|
||||
|
||||
@@ -24,5 +24,7 @@ export const serverResponses: Record<string, string> = {
|
||||
'Wrong or missing identification': 'WRONG_IDENTIFICATION',
|
||||
'Wrong or missing information!': 'WRONG_OR_MISSING_INFO',
|
||||
'Invalid credentials': 'INVALID_CREDENTIALS',
|
||||
'Nothing to update!': 'NOTHING_TO_UPDATE'
|
||||
'Nothing to update!': 'NOTHING_TO_UPDATE',
|
||||
'Email not confirmed!': 'EMAIL_NOT_CONFIRMED',
|
||||
'Confirmation email re-sent! Please check the spam folder.': 'CONFIRMATION_RESENT'
|
||||
};
|
||||
|
||||
@@ -187,6 +187,7 @@ const esTranslations = {
|
||||
account_delete_btn: 'Sí, borra todo',
|
||||
|
||||
footer_my_account: 'Mi Cuenta ',
|
||||
sidebar_last_seen: 'Último acceso {{time}}',
|
||||
|
||||
logout: 'Cerrar sesión'
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import AuthContext, { AuthContextData } from './AuthContext';
|
||||
import { API_TOKEN, REDIRECT_PATH, USER_DATA } from '../app-constants/app-constants';
|
||||
import { SigninResponse } from '../types/SigninResponse';
|
||||
import { SignInResponse } from '../types/SigninResponse';
|
||||
import api from '../api-service/api';
|
||||
import ApiConfig from '../api-service/apiConfig';
|
||||
import { UserResponse } from '../types/UserResponse';
|
||||
@@ -17,13 +17,13 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
const [user, setUser] = useState<UserResponse | undefined>();
|
||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
||||
|
||||
const fetchCurrentSession = async (pathname: string): Promise<SigninResponse | undefined> => {
|
||||
const fetchCurrentSession = async (pathname: string): Promise<SignInResponse | undefined> => {
|
||||
const token = localStorage.getItem(API_TOKEN);
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const bearerToken: SigninResponse = await api.getJSON(ApiConfig.refreshTokenUrl);
|
||||
const bearerToken: SignInResponse = await api.getJSON(ApiConfig.refreshTokenUrl);
|
||||
setSigned(true);
|
||||
return bearerToken;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
};
|
||||
|
||||
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
|
||||
const bearerToken: SigninResponse | undefined = await fetchCurrentSession(pathname);
|
||||
const bearerToken: SignInResponse | undefined = await fetchCurrentSession(pathname);
|
||||
if (bearerToken && bearerToken.token) {
|
||||
const userLocal = updateUserSession(null, bearerToken.token);
|
||||
if (userLocal) {
|
||||
@@ -89,7 +89,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
const signIn = async (email: string, password: string): Promise<string> => {
|
||||
try {
|
||||
const payload = { email, password };
|
||||
const registerResponse: SigninResponse = await api.postJSON(ApiConfig.signInUrl, payload);
|
||||
const registerResponse: SignInResponse = await api.postJSON(ApiConfig.signInUrl, payload);
|
||||
const currentUser: UserResponse = {
|
||||
userId: registerResponse.userId,
|
||||
name: registerResponse.name,
|
||||
@@ -97,7 +97,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
admin: registerResponse.admin,
|
||||
createdAt: new Date(registerResponse.createdAt),
|
||||
gravatarImageUrl: registerResponse.gravatarImageUrl,
|
||||
lang: registerResponse.lang
|
||||
lang: registerResponse.lang,
|
||||
lastLogin: registerResponse.lastLogin
|
||||
};
|
||||
|
||||
setSigned(true);
|
||||
|
||||
@@ -19,6 +19,6 @@ function ProtectedRoute(): React.ReactNode {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
};
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SigninResponse = {
|
||||
export type SignInResponse = {
|
||||
userId: number;
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -7,4 +7,5 @@ export type SigninResponse = {
|
||||
token: string;
|
||||
gravatarImageUrl: string;
|
||||
lang: string;
|
||||
lastLogin: string;
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export type SummaryResponse = {
|
||||
pendingTaskCount: number;
|
||||
doneTaskCount: number;
|
||||
notesCount: number;
|
||||
};
|
||||
@@ -6,4 +6,5 @@ export type UserResponse = {
|
||||
createdAt: Date;
|
||||
gravatarImageUrl: string;
|
||||
lang: string;
|
||||
lastLogin: string;
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ function Account(): React.ReactNode {
|
||||
await patchUserInfo(patchPayload);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event?: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
const handleSubmit = async (event?: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
setValidated(true);
|
||||
@@ -164,7 +164,7 @@ function Account(): React.ReactNode {
|
||||
/>
|
||||
|
||||
<Form noValidate validated={validated} onSubmit={handleSubmit} className="mt-4">
|
||||
{/* User name */}
|
||||
{/* Username */}
|
||||
<FormInput
|
||||
labelText={t('account_form_first_name_label')}
|
||||
iconName="Person"
|
||||
@@ -205,7 +205,7 @@ function Account(): React.ReactNode {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUserPassword(e.target.value);
|
||||
}}
|
||||
data_testid="account-password-one"
|
||||
dataTestId="account-password-one"
|
||||
/>
|
||||
|
||||
{/* User password again */}
|
||||
|
||||
@@ -74,7 +74,7 @@ const EmailConfirmation: React.FC = () => {
|
||||
<h1 className="mb-4" data-testid="email-confirm-success-msg">{t('email_confirmation_success')}</h1>
|
||||
|
||||
<p className="mb-4">{t('email_confirmation_success_text')}</p>
|
||||
<Link to="/login" className="btn btn-success px-4 py-2"style={{ fontWeight: 'bold', borderRadius: '6px' }}>
|
||||
<Link to="/login" className="btn btn-success px-4 py-2" style={{ fontWeight: 'bold', borderRadius: '6px' }}>
|
||||
{t('email_confirmation_btn')}
|
||||
</Link>
|
||||
</>
|
||||
|
||||
@@ -82,7 +82,7 @@ function Home(): React.ReactNode {
|
||||
const markAsDone = async (task: TaskResponse): Promise<void> => {
|
||||
try {
|
||||
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${task.id}`);
|
||||
loadAllTasks();
|
||||
await loadAllTasks();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -97,7 +97,7 @@ function Home(): React.ReactNode {
|
||||
const deleteNote = async (noteIdParam: number) => {
|
||||
try {
|
||||
await api.deleteNoContent(`${ApiConfig.notesUrl}/${noteIdParam}`);
|
||||
loadAllNotes();
|
||||
await loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -113,7 +113,7 @@ function Home(): React.ReactNode {
|
||||
try {
|
||||
const action = note.shared ? 'unshare' : 'share';
|
||||
await api.putJSON(`${ApiConfig.notesUrl}/${note.id}/${action}`, {});
|
||||
loadAllNotes();
|
||||
await loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -176,10 +176,9 @@ function Home(): React.ReactNode {
|
||||
}
|
||||
else {
|
||||
let filteredTasks = allTasks.filter((task: TaskResponse) => {
|
||||
const shouldFilter = task.description.toLowerCase().includes(text.toLowerCase())
|
||||
return task.description.toLowerCase().includes(text.toLowerCase())
|
||||
|| task.tag.toLowerCase().includes(text.toLowerCase())
|
||||
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
|
||||
return shouldFilter;
|
||||
});
|
||||
|
||||
if (tagToFilter === 'untagged') {
|
||||
|
||||
@@ -12,7 +12,7 @@ import './styles.scss';
|
||||
* Landing page component.
|
||||
*
|
||||
* This component displays the landing page of the application,
|
||||
* providing navigation to login or register.
|
||||
* providing navigation to log in or register.
|
||||
* It also checks for an existing user session.
|
||||
*
|
||||
* @returns {React.ReactNode} The Landing page component.
|
||||
|
||||
@@ -122,13 +122,13 @@ function NoteAdd(): React.ReactNode {
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setValidated(true);
|
||||
|
||||
const form = event.currentTarget;
|
||||
if (form.checkValidity() === false) {
|
||||
if (!form.checkValidity()) {
|
||||
setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import LoginForm from '../../components/LoginForm';
|
||||
* Register page component.
|
||||
*
|
||||
* This component displays the register page of the application,
|
||||
* providing navigation to login or back to home.
|
||||
* providing navigation to log in or back to home.
|
||||
*
|
||||
* @returns {React.ReactNode} The Register page component.
|
||||
*/
|
||||
|
||||
@@ -125,13 +125,13 @@ function TaskAdd(): React.ReactNode {
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setValidated(true);
|
||||
|
||||
const form = event.currentTarget;
|
||||
if (form.checkValidity() === false) {
|
||||
if (!form.checkValidity()) {
|
||||
setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,7 @@ docker run -d --rm \
|
||||
-e POSTGRES_HOST=$POSTGRES_HOST \
|
||||
-e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \
|
||||
-e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \
|
||||
-e MAILGUN_APIKEY=$MAILGUN_APIKEY \
|
||||
-e SECURITY_KEY=$SECURITY_KEY \
|
||||
-e BUILD=ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR \
|
||||
ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR
|
||||
|
||||
+1
-5
@@ -11,7 +11,7 @@
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>23</version>
|
||||
<version>25</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
@@ -100,10 +100,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package br.com.tasknoteapp.server.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -22,7 +23,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||
*
|
||||
* @param registry CorsRegistry instance.
|
||||
*/
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
public void addCorsMappings(@NonNull CorsRegistry registry) {
|
||||
if (allowedOrigins != null && allowedOrigins.length > 0) {
|
||||
logger.info("CORS policy allowed origins: {}", Arrays.asList(allowedOrigins));
|
||||
|
||||
|
||||
@@ -39,10 +39,9 @@ public class SecurityConfig {
|
||||
*
|
||||
* @param http instance of HttpSecurity containing the request.
|
||||
* @return SecurityFilterChain with allowed endpoints and all configuration.
|
||||
* @throws Exception due to bad configuration possibilities.
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) {
|
||||
http.cors(Customizer.withDefaults())
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(
|
||||
@@ -99,11 +98,9 @@ public class SecurityConfig {
|
||||
*
|
||||
* @param config AuthenticationConfiguration instance
|
||||
* @return AuthenticationManager instance
|
||||
* @throws Exception if issues occur.
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
|
||||
throws Exception {
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.sql.Connection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -15,9 +14,14 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@Autowired private DataSource dataSource;
|
||||
private final DataSource dataSource;
|
||||
|
||||
@Autowired private AppVersionService appVersionService;
|
||||
private final AppVersionService appVersionService;
|
||||
|
||||
HealthController(DataSource dataSource, AppVersionService appVersionService) {
|
||||
this.dataSource = dataSource;
|
||||
this.appVersionService = appVersionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint to check the health of the application and its database connection.
|
||||
|
||||
@@ -33,7 +33,7 @@ public class NoteController {
|
||||
/**
|
||||
* Get all notes.
|
||||
*
|
||||
* @return List of NoteResponse with all found notes and its urls, if any.
|
||||
* @return List of NoteResponse with all found notes and its URLs, if any.
|
||||
*/
|
||||
@GetMapping
|
||||
public List<NoteResponse> getAllNotes() {
|
||||
@@ -44,7 +44,7 @@ public class NoteController {
|
||||
* Get a note by its ID.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse with note data and its urls, if any.
|
||||
* @return NoteResponse with note data and its URLs, if any.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@@ -56,7 +56,7 @@ public class NoteController {
|
||||
* Patch a note.
|
||||
*
|
||||
* @param id The note id to be patched.
|
||||
* @param noteRequest Note data to be patched, including optionally its urls.
|
||||
* @param noteRequest Note data to be patched, including optionally its URLs.
|
||||
* @return NoteResponse containing data that was updated.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@@ -70,8 +70,8 @@ public class NoteController {
|
||||
/**
|
||||
* Create a note.
|
||||
*
|
||||
* @param noteRequest Note data to be created, including optionally its urls. Following RESTful
|
||||
* API pattern from https://restfulapi.net/rest-put-vs-post/.
|
||||
* @param noteRequest Note data to be created, including optionally its URLs. Following RESTful
|
||||
* API pattern from <a href="https://restfulapi.net/rest-put-vs-post/">docs</a>.
|
||||
* @return NoteResponse containing data that was created.
|
||||
*/
|
||||
@PostMapping
|
||||
|
||||
@@ -4,6 +4,7 @@ import br.com.tasknoteapp.server.exception.BaseBadRequestException;
|
||||
import br.com.tasknoteapp.server.exception.BaseNotFoundException;
|
||||
import br.com.tasknoteapp.server.exception.BaseServiceUnavailableException;
|
||||
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
|
||||
import br.com.tasknoteapp.server.exception.EmailNotConfirmedException;
|
||||
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
|
||||
import br.com.tasknoteapp.server.exception.UserForbiddenException;
|
||||
import br.com.tasknoteapp.server.response.ValidationExceptionResponse;
|
||||
@@ -47,6 +48,13 @@ public class RestExceptionController {
|
||||
.body(new ValidationExceptionResponse("access", ex.getReason()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmailNotConfirmedException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleEmailNotConfirmedException(
|
||||
EmailNotConfirmedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new ValidationExceptionResponse("access", ex.getReason()));
|
||||
}
|
||||
|
||||
/* 404 - Not Found */
|
||||
@ExceptionHandler(BaseNotFoundException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleNotFoundException(BaseNotFoundException ex) {
|
||||
|
||||
@@ -32,7 +32,7 @@ public class TaskController {
|
||||
/**
|
||||
* Get all tasks.
|
||||
*
|
||||
* @return List of TaskResponse with all found tasks and its urls, if any.
|
||||
* @return List of TaskResponse with all found tasks and its URLs, if any.
|
||||
*/
|
||||
@GetMapping
|
||||
public List<TaskResponse> getAllTasks() {
|
||||
@@ -43,7 +43,7 @@ public class TaskController {
|
||||
* Get a task by its ID.
|
||||
*
|
||||
* @param id Task identification.
|
||||
* @return TaskResponse with task data and its urls, if any.
|
||||
* @return TaskResponse with task data and its URLs, if any.
|
||||
* @throws TaskNotFoundException when task not found.
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@@ -55,7 +55,7 @@ public class TaskController {
|
||||
* Patch a task.
|
||||
*
|
||||
* @param id The task id to be patched.
|
||||
* @param taskRequest Task data to be patched, including optionally its urls.
|
||||
* @param taskRequest Task data to be patched, including optionally its URLs.
|
||||
* @return TaskResponse containing data that was updated.
|
||||
* @throws TaskNotFoundException when task not found.
|
||||
*/
|
||||
@@ -68,8 +68,8 @@ public class TaskController {
|
||||
/**
|
||||
* Create a task.
|
||||
*
|
||||
* @param taskRequest Task data to be created, including optionally its urls. Following RESTful
|
||||
* API pattern from https://restfulapi.net/rest-put-vs-post/.
|
||||
* @param taskRequest Task data to be created, including optionally its URLs. Following RESTful
|
||||
* API pattern from <a href="https://restfulapi.net/rest-put-vs-post/">docs</a>.
|
||||
* @return TaskResponse containing data that was created.
|
||||
*/
|
||||
@PostMapping
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ public class UserSessionController {
|
||||
/**
|
||||
* Delete all the user data and information from the server.
|
||||
*
|
||||
* @returns {@link UserResponse} with the user information.
|
||||
* @return {@link UserResponse} with the user information.
|
||||
*/
|
||||
@DeleteMapping("/delete-account")
|
||||
public ResponseEntity<UserResponse> deleteAccount() {
|
||||
|
||||
@@ -29,7 +29,7 @@ public class NoteEntity {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private UserEntity user;
|
||||
|
||||
@Column(name = "tag", nullable = true, length = 30)
|
||||
@Column(name = "tag", length = 30)
|
||||
private String tag;
|
||||
|
||||
@Column(name = "last_update")
|
||||
|
||||
@@ -39,7 +39,7 @@ public class TaskEntity {
|
||||
@Column(name = "high_priority")
|
||||
private Boolean highPriority;
|
||||
|
||||
@Column(name = "tag", nullable = true, length = 30)
|
||||
@Column(name = "tag", length = 30)
|
||||
private String tag;
|
||||
|
||||
public Long getId() {
|
||||
|
||||
@@ -17,14 +17,6 @@ public class TaskUrlEntityPk {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public Long getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(Long taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -34,30 +34,33 @@ public class UserEntity implements UserDetails {
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "inactivated_at", nullable = true)
|
||||
@Column(name = "inactivated_at")
|
||||
private LocalDateTime inactivatedAt;
|
||||
|
||||
@Column(name = "name", length = 20)
|
||||
private String name;
|
||||
|
||||
@Column(name = "email_confirmed_at", nullable = true)
|
||||
@Column(name = "email_confirmed_at")
|
||||
private LocalDateTime emailConfirmedAt;
|
||||
|
||||
@Column(name = "email_uuid", columnDefinition = "uuid", nullable = true, unique = true)
|
||||
@Column(name = "email_uuid", columnDefinition = "uuid", unique = true)
|
||||
private UUID emailUuid;
|
||||
|
||||
@Column(name = "reset_password_expiration", nullable = true)
|
||||
@Column(name = "reset_password_expiration")
|
||||
private LocalDateTime resetPasswordExpiration;
|
||||
|
||||
@Column(name = "reset_token", nullable = true, length = 35)
|
||||
@Column(name = "reset_token", length = 35)
|
||||
private String resetToken;
|
||||
|
||||
@Column(name = "lang", nullable = true, length = 6)
|
||||
@Column(name = "lang", length = 6)
|
||||
private String lang;
|
||||
|
||||
@Column(name = "last_password_change", nullable = false)
|
||||
private LocalDateTime lastPasswordChange;
|
||||
|
||||
@Column(name = "last_login")
|
||||
private LocalDateTime lastLogin;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of();
|
||||
@@ -69,26 +72,6 @@ public class UserEntity implements UserDetails {
|
||||
return email;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -193,4 +176,12 @@ public class UserEntity implements UserDetails {
|
||||
public void setLastPasswordChange(LocalDateTime lastPasswordChange) {
|
||||
this.lastPasswordChange = lastPasswordChange;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastLogin() {
|
||||
return lastLogin;
|
||||
}
|
||||
|
||||
public void setLastLogin(LocalDateTime lastLogin) {
|
||||
this.lastLogin = lastLogin;
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents an Email Not Confirmed exception. */
|
||||
@ResponseStatus(code = HttpStatus.FORBIDDEN)
|
||||
public class EmailNotConfirmedException extends ResponseStatusException {
|
||||
|
||||
public EmailNotConfirmedException() {
|
||||
super(HttpStatus.FORBIDDEN, "Email not confirmed!");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
/** This exception represents an error when sending email messages. */
|
||||
public class MailServiceException extends BaseServiceUnavailableException {
|
||||
|
||||
public MailServiceException(String error) {
|
||||
super("mail", error);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@@ -14,11 +14,15 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
@Component
|
||||
public class HeaderVersionFilter extends OncePerRequestFilter {
|
||||
|
||||
@Autowired private AppVersionService appVersionService;
|
||||
private final AppVersionService appVersionService;
|
||||
|
||||
HeaderVersionFilter(AppVersionService appVersionService) {
|
||||
this.appVersionService = appVersionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
@NonNull HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
@@ -8,7 +8,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -21,15 +21,20 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
@Autowired private UserService userService;
|
||||
private final UserService userService;
|
||||
|
||||
@Autowired private JwtService jwtService;
|
||||
private final JwtService jwtService;
|
||||
|
||||
JwtAuthenticationFilter(UserService userService, JwtService jwtService) {
|
||||
this.userService = userService;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
final String authorizationHeader = request.getHeader("Authorization");
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
package br.com.tasknoteapp.server.hint;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.springframework.aot.hint.ProxyHints;
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
@@ -9,7 +10,7 @@ import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
public class HttpServletRequestRuntimeHint implements RuntimeHintsRegistrar {
|
||||
|
||||
@Override
|
||||
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
|
||||
public void registerHints(@NonNull RuntimeHints hints, ClassLoader classLoader) {
|
||||
try {
|
||||
ProxyHints proxies = hints.proxies();
|
||||
proxies.registerJdkProxy(HttpServletRequest.class);
|
||||
|
||||
@@ -2,7 +2,6 @@ package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserPwdLimitEntity;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
@@ -10,8 +9,6 @@ import org.springframework.data.jpa.repository.Query;
|
||||
/** This interface represents a user password limit repository, for database access. */
|
||||
public interface UserPwdLimitRepository extends JpaRepository<UserPwdLimitEntity, Long> {
|
||||
|
||||
List<UserPwdLimitEntity> findAllByUser_id(Long userId, Sort sort);
|
||||
|
||||
List<UserPwdLimitEntity> findTop3ByUser_idOrderByWhenHappenedDesc(Long userId);
|
||||
|
||||
@Modifying
|
||||
|
||||
@@ -10,11 +10,9 @@ public class LoginRequest {
|
||||
|
||||
@NotNull private String password;
|
||||
|
||||
private String passwordAgain;
|
||||
private final String passwordAgain;
|
||||
|
||||
private String lang;
|
||||
|
||||
public LoginRequest() {}
|
||||
private final String lang;
|
||||
|
||||
/**
|
||||
* Constructs a LoginRequest with the specified email and password.
|
||||
@@ -51,18 +49,10 @@ public class LoginRequest {
|
||||
return passwordAgain;
|
||||
}
|
||||
|
||||
public void setPasswordAgain(String passwordAgain) {
|
||||
this.passwordAgain = passwordAgain;
|
||||
}
|
||||
|
||||
public String lang() {
|
||||
return lang;
|
||||
}
|
||||
|
||||
public void setLang(String lang) {
|
||||
this.lang = lang;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
|
||||
@@ -8,6 +8,6 @@ public record NotePatchRequest(
|
||||
String description,
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
|
||||
@@ -9,6 +9,6 @@ public record NoteRequest(
|
||||
@NotNull String description,
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
/** This record represents a Note Url payload to be patched. */
|
||||
public record NoteUrlPatchRequest(Long id, String url) {}
|
||||
@@ -8,12 +8,6 @@ import jakarta.validation.constraints.NotNull;
|
||||
public class ResendConfirmationRequest {
|
||||
@Email @NotNull private String email;
|
||||
|
||||
public ResendConfirmationRequest() {}
|
||||
|
||||
public ResendConfirmationRequest(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String email() {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public record TaskPatchRequest(
|
||||
List<
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
message = "URL must start with https:// or #")
|
||||
String>
|
||||
urls,
|
||||
String dueDate,
|
||||
|
||||
@@ -11,7 +11,7 @@ public record TaskRequest(
|
||||
List<
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
message = "URL must start with https:// or #")
|
||||
String>
|
||||
urls,
|
||||
String dueDate,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
/** This record represents a Task Url payload to be patched. */
|
||||
public record TaskUrlPatchRequest(Long id, String url) {}
|
||||
@@ -3,16 +3,16 @@ package br.com.tasknoteapp.server.response;
|
||||
import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.util.TimeAgoUtil;
|
||||
|
||||
/** This record represents a task and its urls object to be returned. */
|
||||
/** This record represents a task and its URLs object to be returned. */
|
||||
public record NoteResponse(
|
||||
Long id, String title, String description, String url, String lastUpdate, String tag,
|
||||
boolean shared, String shareToken) {
|
||||
|
||||
/**
|
||||
* Creates a NoteResponse given a NoteEntity and its Urls.
|
||||
* Creates a NoteResponse given a NoteEntity and its Urals.
|
||||
*
|
||||
* @param entity The NoteEntity source data.
|
||||
* @return NoteResponse instance with all note data and urls, if any.
|
||||
* @return NoteResponse instance with all note data and URLs, if any.
|
||||
*/
|
||||
public static NoteResponse fromEntity(NoteEntity entity, String url) {
|
||||
String timeAgoFmt = TimeAgoUtil.format(entity.getLastUpdate());
|
||||
|
||||
@@ -18,10 +18,10 @@ public record TaskResponse(
|
||||
List<String> urls) {
|
||||
|
||||
/**
|
||||
* Creates a TaskResponse given a TaskEntity and its Urls.
|
||||
* Creates a TaskResponse given a TaskEntity and its URLs.
|
||||
*
|
||||
* @param entity The TaskEntity source data.
|
||||
* @return TaskResponse instance with all task data and urls, if any.
|
||||
* @return TaskResponse instance with all task data and URLs, if any.
|
||||
*/
|
||||
public static TaskResponse fromEntity(TaskEntity entity, List<String> urls) {
|
||||
String timeAgoFmt = TimeAgoUtil.format(entity.getLastUpdate());
|
||||
|
||||
@@ -2,7 +2,6 @@ package br.com.tasknoteapp.server.response;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
/** This record represents a User Response object. */
|
||||
public record UserResponse(
|
||||
@@ -12,6 +11,7 @@ public record UserResponse(
|
||||
Boolean admin,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime inactivatedAt,
|
||||
LocalDateTime lastLogin,
|
||||
String gravatarImageUrl) {
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ public record UserResponse(
|
||||
* @param user The user entity instance with user info to be used as source.
|
||||
* @return UserResponse instance.
|
||||
*/
|
||||
public static UserResponse fromEntity(UserEntity user, Optional<String> gravatarUrl) {
|
||||
public static UserResponse fromEntity(UserEntity user, String gravatarUrl) {
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getName(),
|
||||
@@ -28,6 +28,7 @@ public record UserResponse(
|
||||
user.getAdmin(),
|
||||
user.getCreatedAt(),
|
||||
user.getInactivatedAt(),
|
||||
gravatarUrl.orElse(null));
|
||||
user.getLastLogin(),
|
||||
gravatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package br.com.tasknoteapp.server.response;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
/** This record represents a User Response object. */
|
||||
public record UserResponseWithToken(
|
||||
@@ -12,6 +11,7 @@ public record UserResponseWithToken(
|
||||
Boolean admin,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime inactivatedAt,
|
||||
LocalDateTime lastLogin,
|
||||
String gravatarImageUrl,
|
||||
String token,
|
||||
String lang) {
|
||||
@@ -24,7 +24,7 @@ public record UserResponseWithToken(
|
||||
* @return UserResponse instance.
|
||||
*/
|
||||
public static UserResponseWithToken fromEntity(
|
||||
UserEntity user, String token, Optional<String> gravatarUrl) {
|
||||
UserEntity user, String token, String gravatarUrl) {
|
||||
return new UserResponseWithToken(
|
||||
user.getId(),
|
||||
user.getName(),
|
||||
@@ -32,7 +32,8 @@ public record UserResponseWithToken(
|
||||
user.getAdmin(),
|
||||
user.getCreatedAt(),
|
||||
user.getInactivatedAt(),
|
||||
gravatarUrl.orElse(null),
|
||||
user.getLastLogin(),
|
||||
gravatarUrl,
|
||||
token,
|
||||
user.getLang());
|
||||
}
|
||||
|
||||
+11
@@ -30,10 +30,21 @@ public class ValidationExceptionResponse {
|
||||
this.errorMessage = String.format(MESSAGE_TEMPLATE, fields.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Method used by Spring internals to build the final JSON response.
|
||||
*
|
||||
* @return String describing the error message
|
||||
*/
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method used by Spring internals to build the final JSON response with failed fields.
|
||||
*
|
||||
* @return List of FieldIssueResponse containing the field name and the error message related to
|
||||
* it
|
||||
*/
|
||||
public List<FieldIssueResponse> getFields() {
|
||||
return fields;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import br.com.tasknoteapp.server.exception.BadLanguageException;
|
||||
import br.com.tasknoteapp.server.exception.BadPasswordException;
|
||||
import br.com.tasknoteapp.server.exception.BadUuidException;
|
||||
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
|
||||
import br.com.tasknoteapp.server.exception.EmailNotConfirmedException;
|
||||
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
|
||||
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
|
||||
import br.com.tasknoteapp.server.exception.ResetExpiredException;
|
||||
@@ -19,6 +20,7 @@ import br.com.tasknoteapp.server.request.UserPatchRequest;
|
||||
import br.com.tasknoteapp.server.response.UserResponse;
|
||||
import br.com.tasknoteapp.server.response.UserResponseWithToken;
|
||||
import br.com.tasknoteapp.server.util.AuthUtil;
|
||||
import br.com.tasknoteapp.server.util.SecurityUtil;
|
||||
import br.com.tasknoteapp.server.util.TokenUtil;
|
||||
import br.com.tasknoteapp.server.util.UuidUtil;
|
||||
import jakarta.transaction.Transactional;
|
||||
@@ -27,6 +29,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -105,7 +108,7 @@ public class AuthService {
|
||||
*/
|
||||
@Transactional
|
||||
public UserResponseWithToken signUpNewUser(LoginRequest newUser) {
|
||||
logger.info("Signing up new user: {}", newUser.email());
|
||||
logger.info("Signing up new user: {}", SecurityUtil.redactEmail(newUser.email()));
|
||||
|
||||
if (findByEmail(newUser.email()).isPresent()) {
|
||||
throw new EmailAlreadyExistsException();
|
||||
@@ -142,8 +145,9 @@ public class AuthService {
|
||||
mailgunEmailService.sendNewUser(user);
|
||||
}
|
||||
|
||||
logger.info("User created! ID " + user.getId());
|
||||
return UserResponseWithToken.fromEntity(user, null, getGravatarImageUrl(newUser.email()));
|
||||
logger.info("User created! ID {}", user.getId());
|
||||
return UserResponseWithToken.fromEntity(
|
||||
user, null, getGravatarImageUrl(newUser.email()).orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +183,7 @@ public class AuthService {
|
||||
*/
|
||||
@Transactional
|
||||
public UserResponseWithToken signInUser(LoginRequest login) {
|
||||
logger.info("Signing in user: " + login.email());
|
||||
logger.info("Signing in user: {}", SecurityUtil.redactEmail(login.email()));
|
||||
|
||||
Optional<UserEntity> userOptional = findByEmail(login.email());
|
||||
if (userOptional.isEmpty()) {
|
||||
@@ -189,6 +193,12 @@ public class AuthService {
|
||||
checkLoginAttemptLimit(userOptional.get().getId());
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
|
||||
if (Objects.isNull(user.getEmailConfirmedAt())) {
|
||||
logger.warn("User {} tried to login but email is not confirmed", user.getId());
|
||||
throw new EmailNotConfirmedException();
|
||||
}
|
||||
|
||||
user.setResetToken(null);
|
||||
user.setResetPasswordExpiration(null);
|
||||
|
||||
@@ -198,11 +208,25 @@ public class AuthService {
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
logger.info("User authenticated! Token " + token);
|
||||
logger.info("User authenticated! Token {}", token.substring(0, 6) + "...");
|
||||
|
||||
final LocalDateTime previousLastLogin = user.getLastLogin();
|
||||
|
||||
user.setLastLogin(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS));
|
||||
userPwdLimitRepository.deleteAllForUser(user.getId());
|
||||
userRepository.save(user);
|
||||
return UserResponseWithToken.fromEntity(user, token, getGravatarImageUrl(login.email()));
|
||||
|
||||
return new UserResponseWithToken(
|
||||
user.getId(),
|
||||
user.getName(),
|
||||
user.getEmail(),
|
||||
user.getAdmin(),
|
||||
user.getCreatedAt(),
|
||||
user.getInactivatedAt(),
|
||||
previousLastLogin,
|
||||
getGravatarImageUrl(login.email()).orElse(null),
|
||||
token,
|
||||
user.getLang());
|
||||
} catch (BadCredentialsException e) {
|
||||
logger.error(
|
||||
"BadCredentialsException when logging in user {}: {}", user.getId(), e.getMessage());
|
||||
@@ -246,7 +270,9 @@ public class AuthService {
|
||||
List<UserEntity> users = userRepository.findAll();
|
||||
List<UserResponse> usersResponse = new ArrayList<>(users.size());
|
||||
users.forEach(
|
||||
u -> usersResponse.add(UserResponse.fromEntity(u, getGravatarImageUrl(u.getEmail()))));
|
||||
u ->
|
||||
usersResponse.add(
|
||||
UserResponse.fromEntity(u, getGravatarImageUrl(u.getEmail()).orElse(null))));
|
||||
logger.info("{} user(s) found!", usersResponse.size());
|
||||
|
||||
return usersResponse;
|
||||
@@ -286,7 +312,7 @@ public class AuthService {
|
||||
userPwdLimitRepository.deleteAllForUser(currentUser.getId());
|
||||
userRepository.delete(currentUser);
|
||||
|
||||
return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email));
|
||||
return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email).orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,17 +364,18 @@ public class AuthService {
|
||||
shouldUpdate = true;
|
||||
}
|
||||
|
||||
if (shouldUpdate && currentUser != null) {
|
||||
if (shouldUpdate) {
|
||||
userRepository.save(currentUser);
|
||||
}
|
||||
|
||||
if (emailChanged && hasValidMailgunApiKey()) {
|
||||
// send email to older and new account
|
||||
logger.info("Email changed from " + email + " to " + patchRequest.email());
|
||||
logger.info(
|
||||
"Email changed from {} to {}", email, SecurityUtil.redactEmail(patchRequest.email()));
|
||||
mailgunEmailService.sendEmailChangedNotification(currentUser, email);
|
||||
}
|
||||
|
||||
return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email));
|
||||
return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email).orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,7 +402,7 @@ public class AuthService {
|
||||
@Transactional
|
||||
public void confirmUserAccount(String identification) {
|
||||
logger.info("Confirming user email account");
|
||||
UUID uuid = null;
|
||||
UUID uuid;
|
||||
|
||||
try {
|
||||
uuid = UUID.fromString(identification);
|
||||
@@ -392,11 +419,11 @@ public class AuthService {
|
||||
user.setEmailConfirmedAt(LocalDateTime.now());
|
||||
|
||||
userRepository.save(user);
|
||||
logger.info("User email address confirmed: " + identification);
|
||||
logger.info("User email address confirmed: {}", identification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-send the confirm email to the user.
|
||||
* Re-send the confirmation email to the user.
|
||||
*
|
||||
* @param email The email to re-send.
|
||||
*/
|
||||
@@ -424,11 +451,11 @@ public class AuthService {
|
||||
*/
|
||||
@Transactional
|
||||
public void resetPasswordForUser(String email) {
|
||||
logger.info("Requesting password reset for email " + email);
|
||||
logger.info("Requesting password reset for email {}", email);
|
||||
|
||||
Optional<UserEntity> userOptional = userRepository.findByEmail(email);
|
||||
if (userOptional.isEmpty()) {
|
||||
logger.info("No user found with this email " + email);
|
||||
logger.info("No user found with email {}", email);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -454,7 +481,7 @@ public class AuthService {
|
||||
*/
|
||||
@Transactional
|
||||
public void confirmResetPasswordForUser(PasswordResetRequest request) {
|
||||
logger.info("Saving new password for token " + request.token());
|
||||
logger.info("Saving new password for token {}", request.token());
|
||||
|
||||
Optional<UserEntity> userOptional = userRepository.findByResetToken(request.token());
|
||||
if (userOptional.isEmpty()) {
|
||||
@@ -488,12 +515,12 @@ public class AuthService {
|
||||
mailgunEmailService.sendPasswordResetConfirmation(user);
|
||||
}
|
||||
|
||||
logger.info("New password set for token " + request.token());
|
||||
logger.info("New password set for token {}", request.token());
|
||||
}
|
||||
|
||||
private Optional<String> getGravatarImageUrl(String email) {
|
||||
email = email.toLowerCase().trim();
|
||||
logger.info("Current user email: " + email);
|
||||
logger.info("Current user email: {}", SecurityUtil.redactEmail(email));
|
||||
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
@@ -525,7 +552,7 @@ public class AuthService {
|
||||
|
||||
// if it's more than 3 times in the last 10 minutes, raise timer of 3 hours.
|
||||
if (userPwdList.size() >= 3) {
|
||||
UserPwdLimitEntity mostRecent = userPwdList.get(0);
|
||||
UserPwdLimitEntity mostRecent = userPwdList.getFirst();
|
||||
logger.warn("Oldest: {}", mostRecent.getWhenHappened());
|
||||
Duration duration = Duration.between(mostRecent.getWhenHappened(), LocalDateTime.now());
|
||||
if (duration.toMinutes() <= 3L) {
|
||||
@@ -540,4 +567,5 @@ public class AuthService {
|
||||
return Optional.ofNullable(apiKey).isPresent()
|
||||
&& !"invalid-api-key-only-placeholder".equals(apiKey);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
/** This interface contains methods for handling user JWT tokens. */
|
||||
public interface JwtService {
|
||||
|
||||
public String getEmailFromToken(String token);
|
||||
String getEmailFromToken(String token);
|
||||
|
||||
public LocalDateTime extractExpiration(String token);
|
||||
LocalDateTime extractExpiration(String token);
|
||||
|
||||
public String generateToken(UserEntity user);
|
||||
String generateToken(UserEntity user);
|
||||
|
||||
public String createToken(Map<String, Object> claims, String email);
|
||||
String createToken(Map<String, Object> claims, String email);
|
||||
|
||||
public boolean isTokenExpired(String token);
|
||||
boolean isTokenExpired(String token);
|
||||
|
||||
public boolean validateTokenAndUser(String token, UserDetails user);
|
||||
boolean validateTokenAndUser(String token, UserDetails user);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
package br.com.tasknoteapp.server.service;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.exception.MailServiceException;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplate;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateEmailChanged;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwd;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwdConfirm;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateSignUp;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** This service handles email messages for Mailgun. */
|
||||
@Service
|
||||
public class MailgunEmailService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MailgunEmailService.class.getName());
|
||||
private final RestTemplate restTemplate;
|
||||
private final RestClient restClient;
|
||||
private final String targetEnv;
|
||||
private final String domain;
|
||||
private String senderEmail;
|
||||
private final String senderEmail;
|
||||
|
||||
/**
|
||||
* Creates an instance of the Mail service class.
|
||||
@@ -42,22 +35,38 @@ public class MailgunEmailService {
|
||||
* @param domain The domain to send email from.
|
||||
* @param sender The from option.
|
||||
* @param targetEnv The environment.
|
||||
* @param templateBuilder The template builder.
|
||||
* @param restClientBuilder The rest client builder.
|
||||
*/
|
||||
public MailgunEmailService(
|
||||
@Value("${mailgun.api-key}") String apiKey,
|
||||
@Value("${mailgun.domain}") String domain,
|
||||
@Value("${mailgun.sender-email}") String sender,
|
||||
@Value("${br.com.tasknote.server.target-env}") String targetEnv,
|
||||
RestTemplateBuilder templateBuilder) {
|
||||
RestClient.Builder restClientBuilder) {
|
||||
this.domain = domain;
|
||||
this.senderEmail = sender;
|
||||
this.targetEnv = targetEnv;
|
||||
this.restTemplate =
|
||||
templateBuilder
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.readTimeout(Duration.ofSeconds(10))
|
||||
.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth("api", apiKey))
|
||||
|
||||
if (apiKey != null && apiKey.length() > 6) {
|
||||
logger.info(
|
||||
"Mailgun API Key loaded: {}...{}",
|
||||
apiKey.substring(0, 3),
|
||||
apiKey.substring(apiKey.length() - 3));
|
||||
} else {
|
||||
logger.warn("Mailgun API Key is missing or too short!");
|
||||
}
|
||||
|
||||
this.restClient =
|
||||
restClientBuilder
|
||||
.baseUrl("https://api.mailgun.net/v3/" + domain)
|
||||
.defaultStatusHandler(
|
||||
HttpStatusCode::isError,
|
||||
(request, response) ->
|
||||
logger.error(
|
||||
"Mailgun API Error: {} {}",
|
||||
response.getStatusCode(),
|
||||
response.getStatusText()))
|
||||
.defaultHeaders(headers -> headers.setBasicAuth("api", apiKey))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -141,16 +150,11 @@ public class MailgunEmailService {
|
||||
*
|
||||
* @param to The target email address.
|
||||
* @param subject The message subject.
|
||||
* @param textBody The message text body to be displayed.
|
||||
* @param htmlBody The message html body to be rendered.
|
||||
* @param template The Mailgun template.
|
||||
*/
|
||||
private void sendEmail(String to, String subject, MailgunTemplate template) {
|
||||
String url = "https://api.mailgun.net/v3/" + domain + "/messages";
|
||||
String from = "TaskNote App <" + senderEmail + ">";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> mailData = new LinkedMultiValueMap<>();
|
||||
mailData.add("from", from);
|
||||
mailData.add("to", to);
|
||||
@@ -164,26 +168,23 @@ public class MailgunEmailService {
|
||||
logger.info("JSON template variables: {}", template.getVariableValuesJson());
|
||||
}
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(mailData, headers);
|
||||
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new MailServiceException("Failed to send email: " + response.getStatusCode());
|
||||
}
|
||||
restClient
|
||||
.post()
|
||||
.uri("/messages")
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(mailData)
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
|
||||
logger.info("Email message send successfully.");
|
||||
} catch (HttpClientErrorException ex) {
|
||||
logger.error("Unable to send email: {} - {}", ex.getMessage(), ex.getCause());
|
||||
logger.error("Unable to send email: {} - {}", ex.getMessage(), ex.getStatusCode());
|
||||
} catch (Exception ex) {
|
||||
logger.error("Unexpected error sending email: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String basicAuth(String username, String password) {
|
||||
String auth = username + ":" + password;
|
||||
return "Basic " + Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String getBaseUrl() {
|
||||
if ("development".equals(targetEnv) || Objects.isNull(targetEnv)) {
|
||||
return "http://localhost:5000";
|
||||
|
||||
@@ -63,10 +63,10 @@ public class NoteService {
|
||||
public List<NoteResponse> getAllNotes() {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Get all notes to user " + user.getId());
|
||||
logger.info("Get all notes to user ID {}", user.getId());
|
||||
|
||||
List<NoteEntity> notes = noteRepository.findAllByUser_id(user.getId());
|
||||
logger.info(notes.size() + " notes found!");
|
||||
logger.info("{} notes found!", notes.size());
|
||||
|
||||
return getNotesUrl(notes);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ public class NoteService {
|
||||
*/
|
||||
public NoteResponse getNoteById(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get note " + noteId + " to user " + user.getId());
|
||||
logger.info("Get note ID {} to user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> note = noteRepository.findById(noteId);
|
||||
if (note.isEmpty()) {
|
||||
@@ -90,7 +90,7 @@ public class NoteService {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
logger.info("Note found! Id " + noteId);
|
||||
logger.info("Note found! ID {}", noteId);
|
||||
return NoteResponse.fromEntity(note.get(), getNoteUrl(noteId));
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ public class NoteService {
|
||||
public NoteResponse createNote(NoteRequest noteRequest) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Creating note to user " + user.getId());
|
||||
logger.info("Creating note to user ID {}", user.getId());
|
||||
|
||||
NoteEntity note = new NoteEntity();
|
||||
note.setTitle(noteRequest.title());
|
||||
@@ -113,7 +113,7 @@ public class NoteService {
|
||||
note.setUser(user);
|
||||
NoteEntity created = noteRepository.save(note);
|
||||
|
||||
logger.info("Note created! Id " + created.getId());
|
||||
logger.info("Note created! ID {}", created.getId());
|
||||
|
||||
String savedUrl = null;
|
||||
if (!Objects.isNull(noteRequest.url()) && !noteRequest.url().isEmpty()) {
|
||||
@@ -136,7 +136,7 @@ public class NoteService {
|
||||
public NoteResponse patchNote(Long noteId, NotePatchRequest patch) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Patching task " + noteId + " to user " + user.getId());
|
||||
logger.info("Patching task ID {} to user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> note = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (note.isEmpty()) {
|
||||
@@ -157,18 +157,18 @@ public class NoteService {
|
||||
|
||||
noteUrlRepository.deleteByNote_id(noteId);
|
||||
noteUrlRepository.flush();
|
||||
logger.info("URL deleted from task " + noteId);
|
||||
logger.info("URL deleted from note ID {} during patch", noteId);
|
||||
|
||||
if (!Objects.isNull(patch.url()) && !patch.url().isBlank()) {
|
||||
saveUrl(noteEntity, patch.url());
|
||||
} else {
|
||||
logger.info("No urls to patch for task " + noteId);
|
||||
logger.info("No URLs to patch for note ID {}", noteId);
|
||||
}
|
||||
|
||||
NoteEntity patchedNote = noteRepository.save(noteEntity);
|
||||
noteRepository.flush();
|
||||
|
||||
logger.info("Note patched! Id " + patchedNote.getId());
|
||||
logger.info("Note patched! ID {}", patchedNote.getId());
|
||||
|
||||
return NoteResponse.fromEntity(patchedNote, getNoteUrl(patchedNote.getId()));
|
||||
}
|
||||
@@ -182,7 +182,7 @@ public class NoteService {
|
||||
public void deleteNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Deleting note " + noteId + " to user " + user.getId());
|
||||
logger.info("Deleting note ID {} to user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> note = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (note.isEmpty()) {
|
||||
@@ -192,11 +192,11 @@ public class NoteService {
|
||||
NoteEntity noteEntity = note.get();
|
||||
|
||||
noteUrlRepository.deleteByNote_id(noteId);
|
||||
logger.info("URL deleted from task " + noteId);
|
||||
logger.info("URL deleted from note ID {}", noteId);
|
||||
|
||||
noteRepository.delete(noteEntity);
|
||||
|
||||
logger.info("Note deleted! Id " + noteId);
|
||||
logger.info("Note deleted! ID {}", noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,11 +208,11 @@ public class NoteService {
|
||||
public List<NoteResponse> searchNotes(String searchTerm) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Searching notes to user " + user.getId());
|
||||
logger.info("Searching notes for user ID {}", user.getId());
|
||||
|
||||
List<NoteEntity> notes =
|
||||
noteRepository.findAllBySearchTerm(searchTerm.toUpperCase(), user.getId());
|
||||
logger.info(notes.size() + " tasks found!");
|
||||
logger.info("{} tasks found!", notes.size());
|
||||
return getNotesUrl(notes);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ public class NoteService {
|
||||
@Transactional
|
||||
public NoteResponse shareNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Sharing note " + noteId + " for user " + user.getId());
|
||||
logger.info("Sharing note ID {} for user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
@@ -238,7 +238,7 @@ public class NoteService {
|
||||
noteEntity.setShared(true);
|
||||
noteEntity.setShareToken(UUID.randomUUID().toString());
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note " + noteId + " shared with token " + noteEntity.getShareToken());
|
||||
logger.info("Note ID {} shared with token {}", noteId, noteEntity.getShareToken());
|
||||
}
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
@@ -252,7 +252,7 @@ public class NoteService {
|
||||
*/
|
||||
public NoteResponse unshareNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Unsharing note " + noteId + " for user " + user.getId());
|
||||
logger.info("Unsharing note ID {} for user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
@@ -263,7 +263,7 @@ public class NoteService {
|
||||
noteEntity.setShared(false);
|
||||
noteEntity.setShareToken(null);
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note " + noteId + " unshared.");
|
||||
logger.info("Note ID {} unshared", noteId);
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
}
|
||||
@@ -275,7 +275,7 @@ public class NoteService {
|
||||
* @return {@link NoteResponse} containing the shared note.
|
||||
*/
|
||||
public NoteResponse getSharedNote(String shareToken) {
|
||||
logger.info("Fetching shared note with token " + shareToken);
|
||||
logger.info("Fetching shared note with token {}", shareToken);
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByShareToken(shareToken);
|
||||
if (noteOpt.isEmpty() || !noteOpt.get().isShared()) {
|
||||
@@ -313,7 +313,7 @@ public class NoteService {
|
||||
noteUrl.setNote(noteEntity);
|
||||
|
||||
NoteUrlEntity savedUrl = noteUrlRepository.save(noteUrl);
|
||||
logger.info("URL saved to note " + noteEntity.getId());
|
||||
logger.info("URL saved to note ID {}", noteEntity.getId());
|
||||
|
||||
return savedUrl;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,10 @@ public class TaskService {
|
||||
*/
|
||||
public List<TaskResponse> getAllTasks() {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get all tasks to user " + user.getId());
|
||||
logger.info("Get all tasks to user ID {}", user.getId());
|
||||
|
||||
List<TaskEntity> tasks = taskRepository.findAllByUser_id(user.getId());
|
||||
logger.info(tasks.size() + " tasks found!");
|
||||
logger.info("{} tasks found in getAllTasks!", tasks.size());
|
||||
|
||||
return tasks.stream()
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
@@ -81,14 +81,14 @@ public class TaskService {
|
||||
*/
|
||||
public TaskResponse getTaskById(Long taskId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get task " + taskId + " to user " + user.getId());
|
||||
logger.info("Get task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
if (task.isEmpty()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
|
||||
logger.info("Task found! Id " + taskId);
|
||||
logger.info("Task found! ID {}", taskId);
|
||||
return TaskResponse.fromEntity(task.get(), getAllTasksUrls(taskId));
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public class TaskService {
|
||||
public TaskResponse createTask(TaskRequest taskRequest) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Creating task to user " + user.getId());
|
||||
logger.info("Creating task to user ID {}", user.getId());
|
||||
|
||||
TaskEntity task = new TaskEntity();
|
||||
task.setDescription(taskRequest.description());
|
||||
@@ -118,7 +118,7 @@ public class TaskService {
|
||||
saveUrls(task, taskRequest.urls());
|
||||
}
|
||||
|
||||
logger.info("Task created! Id " + created.getId());
|
||||
logger.info("Task created! ID {}", created.getId());
|
||||
return TaskResponse.fromEntity(created, getAllTasksUrls(created.getId()));
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ public class TaskService {
|
||||
public TaskResponse patchTask(Long taskId, TaskPatchRequest patch) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Patching task " + taskId + " to user " + user.getId());
|
||||
logger.info("Patching task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
if (task.isEmpty()) {
|
||||
@@ -165,7 +165,7 @@ public class TaskService {
|
||||
|
||||
TaskEntity patchedTask = taskRepository.save(taskEntity);
|
||||
|
||||
logger.info("Task patched! Id " + patchedTask.getId());
|
||||
logger.info("Task patched! ID {}", patchedTask.getId());
|
||||
|
||||
return TaskResponse.fromEntity(patchedTask, getAllTasksUrls(taskId));
|
||||
}
|
||||
@@ -179,7 +179,7 @@ public class TaskService {
|
||||
public void deleteTask(Long taskId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Deleting task " + taskId + " to user " + user.getId());
|
||||
logger.info("Deleting task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
if (task.isEmpty()) {
|
||||
@@ -191,14 +191,14 @@ public class TaskService {
|
||||
List<TaskUrlEntity> urlsToDelete = taskUrlRepository.findAllById_taskId(taskEntity.getId());
|
||||
if (!urlsToDelete.isEmpty()) {
|
||||
taskUrlRepository.deleteAllById_taskId(taskId);
|
||||
logger.info("Deleted " + urlsToDelete.size() + " urls from task " + taskId);
|
||||
logger.info("Deleted {} URLs from task ID {} in deleteTask", urlsToDelete.size(), taskId);
|
||||
} else {
|
||||
logger.info("No urls to delete for task " + taskId);
|
||||
logger.info("No URLs to delete for task ID {} in deleteTask", taskId);
|
||||
}
|
||||
|
||||
taskRepository.delete(taskEntity);
|
||||
|
||||
logger.info("Task deleted! Id " + taskId);
|
||||
logger.info("Task deleted! ID {}", taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +210,7 @@ public class TaskService {
|
||||
public List<TaskResponse> searchTasks(String searchTerm) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Searching tasks to user " + user.getId());
|
||||
logger.info("Searching tasks to user ID {}", user.getId());
|
||||
|
||||
if (Objects.isNull(searchTerm) || searchTerm.isBlank()) {
|
||||
return List.of();
|
||||
@@ -218,7 +218,7 @@ public class TaskService {
|
||||
|
||||
List<TaskEntity> tasks =
|
||||
taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), user.getId());
|
||||
logger.info(tasks.size() + " tasks found!");
|
||||
logger.info("{} tasks found!", tasks.size());
|
||||
|
||||
return tasks.stream()
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
@@ -242,30 +242,27 @@ public class TaskService {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (filter.equals("all")) {
|
||||
return allTasks.stream()
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (filter.equals("high")) {
|
||||
return allTasks.stream()
|
||||
.filter(TaskEntity::getHighPriority)
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (filter.equals("untagged")) {
|
||||
return allTasks.stream()
|
||||
.filter(t -> t.getTag() == null || t.getTag().isBlank())
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return allTasks.stream()
|
||||
.filter(t -> t.getTag().equals(filter))
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
return switch (filter) {
|
||||
case "all" ->
|
||||
allTasks.stream()
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
case "high" ->
|
||||
allTasks.stream()
|
||||
.filter(TaskEntity::getHighPriority)
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
case "untagged" ->
|
||||
allTasks.stream()
|
||||
.filter(t -> t.getTag() == null || t.getTag().isBlank())
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
default ->
|
||||
allTasks.stream()
|
||||
.filter(t -> t.getTag().equals(filter))
|
||||
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
|
||||
.toList();
|
||||
};
|
||||
}
|
||||
|
||||
private UserEntity getCurrentUser() {
|
||||
@@ -289,7 +286,7 @@ public class TaskService {
|
||||
}
|
||||
|
||||
taskUrlRepository.saveAll(tasksUrl);
|
||||
logger.info("Added " + tasksUrl.size() + " urls from task " + taskEntity.getId());
|
||||
logger.info("Added {} URLs from task ID {}", tasksUrl.size(), taskEntity.getId());
|
||||
}
|
||||
|
||||
private void patchDueDate(TaskEntity taskEntity, TaskPatchRequest patch) {
|
||||
@@ -298,8 +295,7 @@ public class TaskService {
|
||||
try {
|
||||
taskEntity.setDueDate(LocalDate.parse(patch.dueDate()));
|
||||
} catch (DateTimeParseException e) {
|
||||
logger.error(
|
||||
"Unable to parse the provided date: " + patch.dueDate() + ": " + e.getMessage());
|
||||
logger.error("Unable to parse the provided date: {}: {}", patch.dueDate(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,9 +305,9 @@ public class TaskService {
|
||||
List<TaskUrlEntity> urlsToDelete = taskUrlRepository.findAllById_taskId(taskId);
|
||||
if (!urlsToDelete.isEmpty()) {
|
||||
taskUrlRepository.deleteAllById_taskId(taskId);
|
||||
logger.info("Deleted " + urlsToDelete.size() + " urls from task " + taskId);
|
||||
logger.info("Deleted {} URLs from task ID {}", urlsToDelete.size(), taskId);
|
||||
} else {
|
||||
logger.info("No urls to delete for task " + taskId);
|
||||
logger.info("No URLs to delete for task ID {}", taskId);
|
||||
}
|
||||
|
||||
if (!Objects.isNull(patch.urls())) {
|
||||
@@ -319,7 +315,7 @@ public class TaskService {
|
||||
patch.urls().stream().filter(u -> !u.isBlank()).map(String::trim).toList();
|
||||
saveUrls(taskEntity, urlListToAdd);
|
||||
} else {
|
||||
logger.info("No urls to add for task " + taskId);
|
||||
logger.info("No URLs to add for task ID {}", taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@ import br.com.tasknoteapp.server.response.JwtAuthenticationResponse;
|
||||
import br.com.tasknoteapp.server.response.NoteResponse;
|
||||
import br.com.tasknoteapp.server.response.TaskResponse;
|
||||
import br.com.tasknoteapp.server.response.UserResponse;
|
||||
import br.com.tasknoteapp.server.util.SecurityUtil;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** This class contains methods to handle user session and account deletion. */
|
||||
@Service
|
||||
public class UserSessionService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserSessionService.class.getName());
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
private final TaskService taskService;
|
||||
@@ -57,6 +62,11 @@ public class UserSessionService {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Delete current user account for user ID {} email {}",
|
||||
userOptional.get().getId(),
|
||||
SecurityUtil.redactEmail(userOptional.get().getEmail()));
|
||||
|
||||
List<TaskResponse> tasks = taskService.getAllTasks();
|
||||
for (TaskResponse task : tasks) {
|
||||
Long taskId = task != null ? task.id() : null;
|
||||
@@ -73,6 +83,8 @@ public class UserSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
return authService.deleteUserAccount();
|
||||
UserResponse response = authService.deleteUserAccount();
|
||||
logger.info("User account deleted for user ID {}", userOptional.get().getId());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +115,7 @@ class JwtServiceImpl implements JwtService {
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Optional<Claims> claims = extractAllClaims(token);
|
||||
if (claims.isPresent()) {
|
||||
return claimsResolver.apply(claims.get());
|
||||
}
|
||||
return null;
|
||||
return claims.map(claimsResolver).orElse(null);
|
||||
}
|
||||
|
||||
private Optional<Claims> extractAllClaims(String token) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package br.com.tasknoteapp.server.service.impl;
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.repository.UserRepository;
|
||||
import br.com.tasknoteapp.server.service.UserService;
|
||||
import br.com.tasknoteapp.server.util.SecurityUtil;
|
||||
import java.util.Optional;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -18,7 +19,7 @@ class UserServiceImpl implements UserService {
|
||||
email -> {
|
||||
Optional<UserEntity> user = userRepository.findByEmail(email);
|
||||
if (user.isEmpty()) {
|
||||
throw new RuntimeException("User not found: " + email);
|
||||
throw new RuntimeException("User not found: " + SecurityUtil.redactEmail(email));
|
||||
}
|
||||
return user.get();
|
||||
};
|
||||
|
||||
@@ -3,12 +3,12 @@ package br.com.tasknoteapp.server.templates;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** This interface represents a mailgun template structure. */
|
||||
/** This interface represents a Mailgun template structure. */
|
||||
public interface MailgunTemplate {
|
||||
|
||||
static final String STRING_SCAPE = "\"";
|
||||
static final String COLON = ":";
|
||||
static final String COMMA = ",";
|
||||
String STRING_SCAPE = "\"";
|
||||
String COLON = ":";
|
||||
String COMMA = ",";
|
||||
|
||||
String getName();
|
||||
|
||||
|
||||
+6
-7
@@ -7,9 +7,8 @@ import java.util.Optional;
|
||||
/** This class represents a template for the email change workflow. */
|
||||
public class MailgunTemplateEmailChanged implements MailgunTemplate {
|
||||
|
||||
private String templateName = "email_changed";
|
||||
private String carbonCopy;
|
||||
private final Map<String, Object> props;
|
||||
private String carbonCopy;
|
||||
|
||||
public MailgunTemplateEmailChanged() {
|
||||
this.props = new HashMap<>();
|
||||
@@ -25,7 +24,7 @@ public class MailgunTemplateEmailChanged implements MailgunTemplate {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return templateName;
|
||||
return "email_changed";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -33,12 +32,12 @@ public class MailgunTemplateEmailChanged implements MailgunTemplate {
|
||||
return props;
|
||||
}
|
||||
|
||||
public void setCarbonCopy(String carbonCopy) {
|
||||
this.carbonCopy = carbonCopy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getCarbonCopy() {
|
||||
return Optional.ofNullable(carbonCopy);
|
||||
}
|
||||
|
||||
public void setCarbonCopy(String carbonCopy) {
|
||||
this.carbonCopy = carbonCopy;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@ import java.util.Map;
|
||||
/** This class represents a template for the password reset workflow. */
|
||||
public class MailgunTemplateResetPwd implements MailgunTemplate {
|
||||
|
||||
private String templateName = "password_reset";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateResetPwd() {
|
||||
@@ -19,7 +18,7 @@ public class MailgunTemplateResetPwd implements MailgunTemplate {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return templateName;
|
||||
return "password_reset";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@ import java.util.Map;
|
||||
/** This class represents a template for the password reset confirmation workflow. */
|
||||
public class MailgunTemplateResetPwdConfirm implements MailgunTemplate {
|
||||
|
||||
private String templateName = "password_change_confirmation";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateResetPwdConfirm() {
|
||||
@@ -15,7 +14,7 @@ public class MailgunTemplateResetPwdConfirm implements MailgunTemplate {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return templateName;
|
||||
return "password_change_confirmation";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -3,10 +3,9 @@ package br.com.tasknoteapp.server.templates;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** This class represents a template for the sign up workflow. */
|
||||
/** This class represents a template for the sign-up workflow. */
|
||||
public class MailgunTemplateSignUp implements MailgunTemplate {
|
||||
|
||||
private String templateName = "sign_up_confirmation";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateSignUp() {
|
||||
@@ -19,7 +18,7 @@ public class MailgunTemplateSignUp implements MailgunTemplate {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return templateName;
|
||||
return "sign_up_confirmation";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
/** This class contains useful methods to handle security routines. */
|
||||
public class SecurityUtil {
|
||||
|
||||
/**
|
||||
* Redacts an email address by replacing the characters before the "@" symbol with "...". If the
|
||||
* email is null or blank, it returns the original email. For example, "
|
||||
*
|
||||
* @param email The email address
|
||||
* @return Redacted email address
|
||||
*/
|
||||
public static String redactEmail(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
return email;
|
||||
}
|
||||
int posAt = email.indexOf("@");
|
||||
int halfPos = posAt / 2;
|
||||
return email.substring(0, halfPos) + "..." + email.substring(posAt);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public class TimeAgoUtil {
|
||||
/**
|
||||
* Format a time in the time ago format.
|
||||
*
|
||||
* @param pastTime The past time to be formatted.
|
||||
* @param pastTime The pastime to be formatted.
|
||||
* @return String with formatted time.
|
||||
*/
|
||||
public static String format(LocalDateTime pastTime) {
|
||||
@@ -91,7 +91,7 @@ public class TimeAgoUtil {
|
||||
int year = futureDate.getYear();
|
||||
String dateFmt = String.format(" (%s %d%s, %s %d)", dayOfWeek, dayOfMonth, suffix, month, year);
|
||||
|
||||
return sb.toString() + dateFmt;
|
||||
return sb + dateFmt;
|
||||
}
|
||||
|
||||
private static String getDaySuffix(int day) {
|
||||
|
||||
@@ -15,7 +15,7 @@ public class TokenUtil {
|
||||
*/
|
||||
public String generateToken() {
|
||||
// 24 bytes ~ 32 chars length after Base64
|
||||
// Base64 encoding expands that by a factor of 4/3.
|
||||
// encoding expands that by a factor of 4/3.
|
||||
// 24 bytes * 4/3 = 32 characters
|
||||
byte[] randomBytes = new byte[24];
|
||||
secureRandom.nextBytes(randomBytes);
|
||||
|
||||
@@ -33,7 +33,7 @@ public class UuidUtil {
|
||||
sha1[6] &= 0x0f;
|
||||
sha1[6] |= 0x50;
|
||||
sha1[8] &= 0x3f;
|
||||
sha1[8] |= 0x80;
|
||||
sha1[8] |= (byte) 0x80;
|
||||
|
||||
return bytesToUuid(sha1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasknote.users ADD COLUMN last_login TIMESTAMP;
|
||||
+50
-13
@@ -32,12 +32,22 @@ class AuthenticationControllerTest {
|
||||
@Test
|
||||
@DisplayName("Sign up happy path should succeed")
|
||||
void signup_happyPath_shouldSucceed() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
LoginRequest request =
|
||||
new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
final String token = "xaxbxcxdx1x2x3A@";
|
||||
|
||||
UserResponseWithToken response =
|
||||
new UserResponseWithToken(
|
||||
123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en");
|
||||
123L,
|
||||
null,
|
||||
request.email(),
|
||||
false,
|
||||
LocalDateTime.now(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
token,
|
||||
"en");
|
||||
when(authService.signUpNewUser(request)).thenReturn(response);
|
||||
|
||||
String jsonString =
|
||||
@@ -45,7 +55,8 @@ class AuthenticationControllerTest {
|
||||
{
|
||||
"email": "user@domain.com",
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456"
|
||||
"passwordAgain": "abcde123456",
|
||||
"timezone": "UTC"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -63,12 +74,22 @@ class AuthenticationControllerTest {
|
||||
@Test
|
||||
@DisplayName("Sign up bad email request should fail")
|
||||
void signup_badEmailRequest_shouldFail() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456", "abcde123456", "en");
|
||||
LoginRequest request =
|
||||
new LoginRequest("user@domain..com", "abcde123456", "abcde123456", "en");
|
||||
final String token = "xaxbxcxdx1x2x3@A";
|
||||
|
||||
UserResponseWithToken response =
|
||||
new UserResponseWithToken(
|
||||
123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en");
|
||||
123L,
|
||||
null,
|
||||
request.email(),
|
||||
false,
|
||||
LocalDateTime.now(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
token,
|
||||
"en");
|
||||
when(authService.signUpNewUser(request)).thenReturn(response);
|
||||
|
||||
String jsonString =
|
||||
@@ -76,7 +97,8 @@ class AuthenticationControllerTest {
|
||||
{
|
||||
"email": "user@domain..com",
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456"
|
||||
"passwordAgain": "abcde123456",
|
||||
"timezone": "UTC"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -94,7 +116,8 @@ class AuthenticationControllerTest {
|
||||
@Test
|
||||
@DisplayName("Sign up email already exists should fail")
|
||||
void signup_userAlreadyExists_shouldFail() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
LoginRequest request =
|
||||
new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
|
||||
when(authService.signUpNewUser(request)).thenThrow(new EmailAlreadyExistsException());
|
||||
|
||||
@@ -104,7 +127,8 @@ class AuthenticationControllerTest {
|
||||
"email": "user@domain.com",
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456",
|
||||
"lang": "en"
|
||||
"lang": "en",
|
||||
"timezone": "UTC"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -122,12 +146,22 @@ class AuthenticationControllerTest {
|
||||
@Test
|
||||
@DisplayName("Sign in happy path should succeed")
|
||||
void signin_happyPath_shouldSucceed() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
LoginRequest request =
|
||||
new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
final String token = "xaxbxcxdx1x2x3A@";
|
||||
|
||||
UserResponseWithToken response =
|
||||
new UserResponseWithToken(
|
||||
123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en");
|
||||
123L,
|
||||
null,
|
||||
request.email(),
|
||||
false,
|
||||
LocalDateTime.now(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
token,
|
||||
"en");
|
||||
when(authService.signInUser(request)).thenReturn(response);
|
||||
|
||||
String jsonString =
|
||||
@@ -136,7 +170,8 @@ class AuthenticationControllerTest {
|
||||
"email": "user@domain.com",
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456",
|
||||
"lang": "en"
|
||||
"lang": "en",
|
||||
"timezone": "UTC"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -158,7 +193,8 @@ class AuthenticationControllerTest {
|
||||
@Test
|
||||
@DisplayName("Sign in invalid credentials should fail")
|
||||
void signIn_invalidCredentials_shouldFail() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
LoginRequest request =
|
||||
new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
|
||||
|
||||
when(authService.signInUser(request)).thenReturn(null);
|
||||
|
||||
@@ -166,7 +202,8 @@ class AuthenticationControllerTest {
|
||||
"""
|
||||
{
|
||||
"email": "user@domain.com",
|
||||
"password": "abcde123456"
|
||||
"password": "abcde123456",
|
||||
"timezone": "UTC"
|
||||
}
|
||||
""";
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class UserControllerTest {
|
||||
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
|
||||
void getAllUsers_happyPath_shouldSucceed() throws Exception {
|
||||
UserResponse userResponse =
|
||||
new UserResponse(1L, "John", "email@test.com", false, null, null, null);
|
||||
new UserResponse(1L, "John", "email@test.com", false, null, null, null, null);
|
||||
when(authService.getAllUsers()).thenReturn(List.of(userResponse));
|
||||
|
||||
mockMvc
|
||||
@@ -68,7 +68,7 @@ class UserControllerTest {
|
||||
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
|
||||
void patchUserInfo_happyPath_shouldSucceed() throws Exception {
|
||||
UserResponse response =
|
||||
new UserResponse(1L, "John", "email@example.com", false, null, null, null);
|
||||
new UserResponse(1L, "John", "email@example.com", false, null, null, null, null);
|
||||
UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null, null);
|
||||
when(authService.patchUserInfo(request)).thenReturn(response);
|
||||
|
||||
|
||||
+2
-1
@@ -63,7 +63,8 @@ class UserSessionControllerTest {
|
||||
@DisplayName("Delete account happy path should succeed")
|
||||
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
|
||||
void deleteAccount_happyPath_shouldSucceed() throws Exception {
|
||||
UserResponse response = new UserResponse(1L, "John", "email@test.com", false, null, null, null);
|
||||
UserResponse response =
|
||||
new UserResponse(1L, "John", "email@test.com", false, null, null, null, null);
|
||||
when(userSessionService.deleteCurrentUserAccount()).thenReturn(response);
|
||||
|
||||
mockMvc
|
||||
|
||||
@@ -11,6 +11,7 @@ import br.com.tasknoteapp.server.entity.UserPwdLimitEntity;
|
||||
import br.com.tasknoteapp.server.exception.BadPasswordException;
|
||||
import br.com.tasknoteapp.server.exception.BadUuidException;
|
||||
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
|
||||
import br.com.tasknoteapp.server.exception.EmailNotConfirmedException;
|
||||
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
|
||||
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
|
||||
import br.com.tasknoteapp.server.exception.ResetExpiredException;
|
||||
@@ -99,6 +100,7 @@ class AuthServiceTest {
|
||||
Assertions.assertNull(token.token());
|
||||
Assertions.assertEquals(entity.getEmail(), token.email());
|
||||
Assertions.assertNotNull(entity.getEmailUuid());
|
||||
verify(userRepository).save(any(UserEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -193,10 +195,13 @@ class AuthServiceTest {
|
||||
@DisplayName("SignIn user happy path should succeed")
|
||||
void signInUser_happyPath_shouldSucceed() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en");
|
||||
LocalDateTime oldLastLogin = LocalDateTime.of(2026, 5, 1, 10, 20, 30);
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
existing.setEmail(request.email());
|
||||
existing.setEmailConfirmedAt(LocalDateTime.now());
|
||||
existing.setLastLogin(oldLastLogin);
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
@@ -210,6 +215,28 @@ class AuthServiceTest {
|
||||
|
||||
Assertions.assertNotNull(token);
|
||||
Assertions.assertEquals("a1b2c3", token.token());
|
||||
Assertions.assertEquals(oldLastLogin, token.lastLogin());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SignIn user not confirmed should fail")
|
||||
void signInUser_notConfirmed_shouldFail() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en");
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
existing.setEmail(request.email());
|
||||
existing.setEmailConfirmedAt(null);
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
Assertions.assertThrows(
|
||||
EmailNotConfirmedException.class,
|
||||
() -> {
|
||||
authService.signInUser(request);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -233,6 +260,7 @@ class AuthServiceTest {
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
existing.setEmailConfirmedAt(LocalDateTime.now());
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
UserPwdLimitEntity limit1 = new UserPwdLimitEntity();
|
||||
@@ -256,6 +284,7 @@ class AuthServiceTest {
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
existing.setEmailConfirmedAt(LocalDateTime.now());
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
|
||||
+31
-26
@@ -2,7 +2,6 @@ package br.com.tasknoteapp.server.service;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -13,34 +12,43 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Test class generated by Copilot. I had to tweak it, to make it work. */
|
||||
/** Test class for MailgunEmailService using RestClient. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MailgunEmailServiceTest {
|
||||
|
||||
@Mock private RestTemplate restTemplate;
|
||||
@Mock private RestTemplateBuilder restTemplateBuilder;
|
||||
@Mock private RestClient restClient;
|
||||
@Mock private RestClient.Builder restClientBuilder;
|
||||
@Mock private RestClient.RequestBodyUriSpec requestBodyUriSpec;
|
||||
@Mock private RestClient.RequestBodySpec requestBodySpec;
|
||||
@Mock private RestClient.ResponseSpec responseSpec;
|
||||
|
||||
private MailgunEmailService mailgunEmailService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(restTemplateBuilder.connectTimeout(any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.readTimeout(any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.defaultHeader(any(), any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.build()).thenReturn(restTemplate);
|
||||
when(restClientBuilder.baseUrl(anyString())).thenReturn(restClientBuilder);
|
||||
when(restClientBuilder.defaultStatusHandler(any(), any())).thenReturn(restClientBuilder);
|
||||
when(restClientBuilder.defaultHeaders(any())).thenReturn(restClientBuilder);
|
||||
when(restClientBuilder.build()).thenReturn(restClient);
|
||||
|
||||
String apiKey = "abx123";
|
||||
String domain = "domain.com";
|
||||
String sender = "no-reply@domain.com";
|
||||
String target = "development";
|
||||
mailgunEmailService =
|
||||
new MailgunEmailService(apiKey, domain, sender, target, restTemplateBuilder);
|
||||
new MailgunEmailService(apiKey, domain, sender, target, restClientBuilder);
|
||||
}
|
||||
|
||||
private void setupMockChain() {
|
||||
when(restClient.post()).thenReturn(requestBodyUriSpec);
|
||||
when(requestBodyUriSpec.uri(anyString())).thenReturn(requestBodySpec);
|
||||
when(requestBodySpec.contentType(any())).thenReturn(requestBodySpec);
|
||||
when(requestBodySpec.body(any())).thenReturn(requestBodySpec);
|
||||
when(requestBodySpec.retrieve()).thenReturn(responseSpec);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -49,12 +57,11 @@ class MailgunEmailServiceTest {
|
||||
user.setEmail("test@example.com");
|
||||
user.setResetToken("reset-token");
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("Success"));
|
||||
setupMockChain();
|
||||
|
||||
mailgunEmailService.sendResetPassword(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
verify(restClient, times(1)).post();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -62,12 +69,11 @@ class MailgunEmailServiceTest {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmail("test@example.com");
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("Success"));
|
||||
setupMockChain();
|
||||
|
||||
mailgunEmailService.sendPasswordResetConfirmation(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
verify(restClient, times(1)).post();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,12 +82,11 @@ class MailgunEmailServiceTest {
|
||||
user.setEmail("test@example.com");
|
||||
user.setEmailUuid(java.util.UUID.randomUUID());
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("Success"));
|
||||
setupMockChain();
|
||||
|
||||
mailgunEmailService.sendNewUser(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
verify(restClient, times(1)).post();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,12 +95,13 @@ class MailgunEmailServiceTest {
|
||||
user.setEmail("test@example.com");
|
||||
user.setResetToken("reset-token");
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
setupMockChain();
|
||||
when(responseSpec.toBodilessEntity())
|
||||
.thenThrow(new HttpClientErrorException(HttpStatusCode.valueOf(400)));
|
||||
|
||||
mailgunEmailService.sendResetPassword(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
verify(restClient, times(1)).post();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -103,13 +109,12 @@ class MailgunEmailServiceTest {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmail("test@example.com");
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("Success"));
|
||||
setupMockChain();
|
||||
|
||||
String oldEmail = "old@example.com";
|
||||
|
||||
mailgunEmailService.sendEmailChangedNotification(user, oldEmail);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
verify(restClient, times(1)).post();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ class UserSessionServiceTest {
|
||||
when(noteService.getAllNotes()).thenReturn(List.of(note));
|
||||
when(authService.deleteUserAccount())
|
||||
.thenReturn(
|
||||
new UserResponse(user.getId(), "user", user.getEmail(), false, null, null, null));
|
||||
new UserResponse(
|
||||
user.getId(), "user", user.getEmail(), false, null, null, null, null));
|
||||
|
||||
// Act
|
||||
UserResponse response = userSessionService.deleteCurrentUserAccount();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SecurityUtilTest {
|
||||
|
||||
@Test
|
||||
void redactEmailWithNullTest() {
|
||||
String result = SecurityUtil.redactEmail(null);
|
||||
|
||||
Assertions.assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithBlankTest() {
|
||||
String result = SecurityUtil.redactEmail("");
|
||||
|
||||
Assertions.assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithWhitespaceTest() {
|
||||
String result = SecurityUtil.redactEmail(" ");
|
||||
|
||||
Assertions.assertEquals(" ", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithStandardEmailTest() {
|
||||
String result = SecurityUtil.redactEmail("john.doe@example.com");
|
||||
|
||||
Assertions.assertEquals("john...@example.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithShortEmailTest() {
|
||||
String result = SecurityUtil.redactEmail("ab@cd.com");
|
||||
|
||||
Assertions.assertEquals("a...@cd.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithSingleCharacterBeforeAtTest() {
|
||||
String result = SecurityUtil.redactEmail("a@example.com");
|
||||
|
||||
Assertions.assertEquals("...@example.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithLongLocalPartTest() {
|
||||
String result = SecurityUtil.redactEmail("verylongemailaddress@example.com");
|
||||
|
||||
Assertions.assertEquals("verylongem...@example.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithNumbersAndSymbolsTest() {
|
||||
String result = SecurityUtil.redactEmail("user.name+tag123@example.co.uk");
|
||||
|
||||
Assertions.assertEquals("user.nam...@example.co.uk", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithMultipleDotsTest() {
|
||||
String result = SecurityUtil.redactEmail("first.middle.last@example.com");
|
||||
|
||||
Assertions.assertEquals("first.mi...@example.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactEmailWithUppercaseTest() {
|
||||
String result = SecurityUtil.redactEmail("John.Doe@Example.COM");
|
||||
|
||||
Assertions.assertEquals("John...@Example.COM", result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ resource "google_cloud_run_v2_service" "backend" {
|
||||
service_account = google_service_account.cloudrun_sa.email
|
||||
vpc_access {
|
||||
connector = google_vpc_access_connector.connector.id
|
||||
egress = "ALL_TRAFFIC"
|
||||
egress = "PRIVATE_RANGES_ONLY"
|
||||
}
|
||||
|
||||
containers {
|
||||
@@ -54,7 +54,7 @@ resource "google_cloud_run_v2_service" "backend" {
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = google_secret_manager_secret.security_key.secret_id
|
||||
version = "latest"
|
||||
version = google_secret_manager_secret_version.security_key_version.version
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ resource "google_cloud_run_v2_service" "backend" {
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = google_secret_manager_secret.mailgun_apikey.secret_id
|
||||
version = "latest"
|
||||
version = google_secret_manager_secret_version.mailgun_apikey_version.version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
PROJECT_ID="replace-me"
|
||||
VERSION="23"
|
||||
SOURCE_IMG="ghcr.io/rmcampos/tasknote/api:$VERSION"
|
||||
DESTINATION_IMG="us-central1-docker.pkg.dev/$PROJECT_ID/tasknote-repo/api:$VERSION"
|
||||
GHCR_PAT="replace-me"
|
||||
|
||||
# Login
|
||||
echo $GHCR_PAT | docker login ghcr.io -u user --password-stdin
|
||||
echo "Logged in on ghcr.io"
|
||||
|
||||
gcloud auth configure-docker us-central1-docker.pkg.dev
|
||||
docker login us-central1-docker.pkg.dev
|
||||
echo "Logged in on GCP Container Registry"
|
||||
|
||||
crane auth login ghcr.io -u RMCampos -p $GHCR_PAT
|
||||
echo "Logged crane in on ghcr.io"
|
||||
|
||||
# Check if image already exists in destination
|
||||
echo "Checking if $DESTINATION_IMG already exists in destination registry..."
|
||||
if crane manifest "$DESTINATION_IMG" > /dev/null 2>&1; then
|
||||
echo "Image $DESTINATION_IMG already exists. Skipping push."
|
||||
else
|
||||
echo "Image not found in destination. Proceeding with push..."
|
||||
|
||||
docker pull $SOURCE_IMG
|
||||
docker tag $SOURCE_IMG $DESTINATION_IMG
|
||||
docker push $DESTINATION_IMG
|
||||
echo "Pushed image to GCP Container Registry"
|
||||
|
||||
crane copy $SOURCE_IMG $DESTINATION_IMG
|
||||
echo "Copied image to GCP Container Registry with crane"
|
||||
fi
|
||||
|
||||
# Verification
|
||||
echo "Verifying push..."
|
||||
if crane manifest "$DESTINATION_IMG" > /dev/null 2>&1; then
|
||||
echo "SUCCESS: Image $DESTINATION_IMG is properly pushed and available in the registry."
|
||||
gcloud artifacts docker images list us-central1-docker.pkg.dev/project-9f22294e-b6f5-41de-83c/tasknote-repo | grep "api:$VERSION"
|
||||
else
|
||||
echo "FAILURE: Image $DESTINATION_IMG was not found in the destination registry."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: tasknote-external-redirect
|
||||
namespace: default
|
||||
spec:
|
||||
redirectRegex:
|
||||
regex: "^https?://tasknote.darkroasted.vps-kinghost.net/(.*)"
|
||||
replacement: "https://tasknote.cc/${1}"
|
||||
permanent: true
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redirect-tasknote
|
||||
namespace: default
|
||||
spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: tasknote-redirect-ingress
|
||||
namespace: default
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: default-tasknote-external-redirect@kubernetescrd
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web, websecure
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: tasknote.darkroasted.vps-kinghost.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: redirect-tasknote
|
||||
port:
|
||||
number: 80
|
||||
+37
-2
@@ -70,7 +70,7 @@ variable "r2_endpoint" {
|
||||
|
||||
variable "cors_allowed_origins" {
|
||||
type = string
|
||||
default = "https://tasknote.darkroasted.vps-kinghost.net"
|
||||
default = "https://tasknote.darkroasted.vps-kinghost.net,https://tasknote.cc,https://www.tasknote.cc"
|
||||
}
|
||||
|
||||
variable "root_log_level" {
|
||||
@@ -353,7 +353,12 @@ resource "kubernetes_ingress_v1" "tasknote_ingress" {
|
||||
}
|
||||
spec {
|
||||
tls {
|
||||
hosts = ["tasknote.darkroasted.vps-kinghost.net", "tasknoteapi.darkroasted.vps-kinghost.net"]
|
||||
hosts = [
|
||||
"tasknote.darkroasted.vps-kinghost.net",
|
||||
"tasknoteapi.darkroasted.vps-kinghost.net",
|
||||
"tasknote.cc",
|
||||
"www.tasknote.cc"
|
||||
]
|
||||
secret_name = "tasknote-tls-certs"
|
||||
}
|
||||
rule {
|
||||
@@ -386,6 +391,36 @@ resource "kubernetes_ingress_v1" "tasknote_ingress" {
|
||||
}
|
||||
}
|
||||
}
|
||||
rule {
|
||||
host = "tasknote.cc"
|
||||
http {
|
||||
path {
|
||||
path = "/"
|
||||
path_type = "Prefix"
|
||||
backend {
|
||||
service {
|
||||
name = kubernetes_service_v1.tasknote_frontend_svc.metadata[0].name
|
||||
port { number = 5000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rule {
|
||||
host = "www.tasknote.cc"
|
||||
http {
|
||||
path {
|
||||
path = "/"
|
||||
path_type = "Prefix"
|
||||
backend {
|
||||
service {
|
||||
name = kubernetes_service_v1.tasknote_frontend_svc.metadata[0].name
|
||||
port { number = 5000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
NAMESPACE="tasknote"
|
||||
LABEL="app=tasknote-db-backup"
|
||||
|
||||
# Get the name of the most recent backup pod
|
||||
LATEST_POD=$(kubectl get pods -n "$NAMESPACE" -l "$LABEL" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}' 2>/dev/null)
|
||||
|
||||
if [ -z "$LATEST_POD" ]; then
|
||||
echo "Error: No backup pods found in namespace '$NAMESPACE' with label '$LABEL'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Logs for latest backup pod: $LATEST_POD ---"
|
||||
kubectl logs -n "$NAMESPACE" "$LATEST_POD"
|
||||
Reference in New Issue
Block a user