diff --git a/.env.example b/.env.example index 9a6d9b6..f337aad 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ GO_SCRAPER_URL=http://localhost:8081 # Use uma string longa e secreta em ambientes reais. SESSION_SECRET=change-me-with-a-long-random-secret +# Segurança / PII +# ENCRYPTION_MASTER_KEY deve ter 32 bytes em hex, ou seja, 64 caracteres hex. +ENCRYPTION_MASTER_KEY= +ENCRYPTION_KEY_ID=default +SEARCH_KEY= + # CORS / frontend access CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174 diff --git a/README.md b/README.md index 84d326a..23faa26 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Objetivo de produto: fornecer uma base robusta para busca, filtragem e gestão d ├─ electron/ # Shell desktop ├─ docker-compose.yml # App stack (frontend + backend + scraper-go) ├─ docker-compose.infra.yml # Infra stack (Postgres + Valkey) +├─ docker-compose.migrate.yml # Migration job do backend └─ .github/workflows/ci.yml # CI ``` @@ -106,25 +107,13 @@ docker network create vagas-net Se a rede já existir, o Docker vai avisar e você pode seguir para o próximo passo. -4. Suba Postgres e Valkey: +4. Suba Postgres, Valkey, scraper, backend e frontend: ```bash -docker compose -f docker-compose.infra.yml up -d -``` - -5. Suba scraper, backend e frontend: - -```bash -docker compose up --build -d +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d ``` -6. Aplique as migrations do backend: - -```bash -docker compose exec backend npm run db:migrate -``` - -7. Acesse os serviços: +5. Acesse os serviços: - Frontend: http://localhost:5173 - Backend health: http://localhost:3001/health @@ -272,31 +261,35 @@ Este projeto separa infraestrutura e aplicação em dois arquivos Compose: - `docker-compose.infra.yml`: Postgres + Valkey. - `docker-compose.yml`: scraper Go + backend + frontend. +- `docker-compose.migrate.yml`: job de migrations do backend. -Subir infraestrutura: +Subir infraestrutura, migrations e aplicação: ```bash -docker compose -f docker-compose.infra.yml up -d +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d ``` -Subir aplicação: +O serviço `migrate` executa `npm run db:migrate` e `npm run security:backfill-user-pii -- --write` depois que o Postgres fica saudável. O backend só inicia depois que esse job termina com sucesso. + +Se quiser subir apenas a infraestrutura: ```bash -docker compose up --build -d +docker compose -f docker-compose.infra.yml up -d ``` -Aplicar migrations: +Se quiser subir a aplicação sem o job de migrations: ```bash -docker compose exec backend npm run db:migrate +docker compose up --build -d ``` Ver logs: ```bash -docker compose logs -f backend -docker compose logs -f frontend -docker compose logs -f scraper-go +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f migrate +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f backend +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f frontend +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f scraper-go ``` Encerrar: @@ -314,7 +307,7 @@ Dentro dos containers, serviços devem usar os nomes da rede Docker: - Valkey: `valkey:6379` - Scraper: `scraper-go:8081` -Por isso o `docker-compose.yml` sobrescreve `DATABASE_URL`, `VALKEY_URL`, `GO_SCRAPER_URL` e `SCRAPER_URL` para os valores internos corretos. +Por isso o `docker-compose.yml` e o `docker-compose.migrate.yml` sobrescrevem variáveis como `DATABASE_URL`, `VALKEY_URL`, `GO_SCRAPER_URL` e `SCRAPER_URL` para os valores internos corretos. Serviços padrão: diff --git a/backend/.env.example b/backend/.env.example index cb1ba0f..ff6615a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,18 +25,15 @@ FRONTEND_URL=http://localhost:5173 # Use uma string longa e secreta em ambientes reais. SESSION_SECRET=change-me-with-a-long-random-secret +# Segurança / PII +# ENCRYPTION_MASTER_KEY deve ter 32 bytes em hex, ou seja, 64 caracteres hex. +ENCRYPTION_MASTER_KEY= +ENCRYPTION_KEY_ID=default +SEARCH_KEY= + # CORS / frontend access CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174 -# Search filters -SEARCH_LOCATION=Brasil -SEARCH_GEO_ID=106057199 -SEARCH_LANGUAGE=pt -REMOTE_ONLY=true -JOB_TYPES=C,F -TIME_FILTER=r604800 -SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner - # Database / cache for backend running locally outside Docker DATABASE_URL=postgresql://vagas:vagas@localhost:5432/vagas VALKEY_URL=redis://localhost:6379/0 diff --git a/backend/drizzle/0008_bent_thor.sql b/backend/drizzle/0008_bent_thor.sql new file mode 100644 index 0000000..1b217f9 --- /dev/null +++ b/backend/drizzle/0008_bent_thor.sql @@ -0,0 +1,11 @@ +ALTER TABLE "credentials" ADD COLUMN "email_hash" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "first_name_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "last_name_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "display_name_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "email_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "email_hash" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "phone_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "cpf_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "cpf_hash" text;--> statement-breakpoint +CREATE UNIQUE INDEX "users_email_hash_unique" ON "users" USING btree ("email_hash");--> statement-breakpoint +ALTER TABLE "credentials" ADD CONSTRAINT "credentials_email_hash_unique" UNIQUE("email_hash"); diff --git a/backend/drizzle/0009_encrypt_remaining_profile_fields.sql b/backend/drizzle/0009_encrypt_remaining_profile_fields.sql new file mode 100644 index 0000000..d97a3c1 --- /dev/null +++ b/backend/drizzle/0009_encrypt_remaining_profile_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" ADD COLUMN "avatar_url_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "technologies_encrypted" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "level_encrypted" text; diff --git a/backend/drizzle/meta/0008_snapshot.json b/backend/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..fde8143 --- /dev/null +++ b/backend/drizzle/meta/0008_snapshot.json @@ -0,0 +1,881 @@ +{ + "id": "e14a6919-eef3-4d88-b1a5-4c11f1c62b6f", + "prevId": "facfeeea-cf12-4191-bc05-188150aaceb9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_unique": { + "name": "accounts_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_role": { + "name": "actor_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credentials_user_id_unique": { + "name": "credentials_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "credentials_email_unique": { + "name": "credentials_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "credentials_email_hash_unique": { + "name": "credentials_email_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "email_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keywords": { + "name": "keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keywords_user_keyword_unique": { + "name": "keywords_user_keyword_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keywords_user_id_users_id_fk": { + "name": "keywords_user_id_users_id_fk", + "tableFrom": "keywords", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_rules": { + "name": "permission_rules", + "schema": "", + "columns": { + "resource": { + "name": "resource", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "min_role": { + "name": "min_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "permission_rules_resource_action_pk": { + "name": "permission_rules_resource_action_pk", + "columns": [ + "resource", + "action" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_jobs": { + "name": "saved_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_link": { + "name": "job_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'saved'" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "saved_jobs_user_id_users_id_fk": { + "name": "saved_jobs_user_id_users_id_fk", + "tableFrom": "saved_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "keywords": { + "name": "keywords", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "search_location": { + "name": "search_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_language": { + "name": "search_language", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "remote_only": { + "name": "remote_only", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "job_types": { + "name": "job_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_unique": { + "name": "user_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name_encrypted": { + "name": "first_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name_encrypted": { + "name": "last_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name_encrypted": { + "name": "display_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_encrypted": { + "name": "email_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "phone_encrypted": { + "name": "phone_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpf": { + "name": "cpf", + "type": "varchar(14)", + "primaryKey": false, + "notNull": false + }, + "cpf_encrypted": { + "name": "cpf_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpf_hash": { + "name": "cpf_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technologies": { + "name": "technologies", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "level": { + "name": "level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_blocked": { + "name": "is_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_hash_unique": { + "name": "users_email_hash_unique", + "columns": [ + { + "expression": "email_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "support", + "admin", + "super_admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 77fd479..2149f1a 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1783623600000, "tag": "0007_permission_rules", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1784034810863, + "tag": "0008_bent_thor", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1784121210863, + "tag": "0009_encrypt_remaining_profile_fields", + "breakpoints": true } ] } diff --git a/backend/package.json b/backend/package.json index f684c2e..199dfe6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", + "security:backfill-user-pii": "tsx src/scripts/backfillUserPii.ts", "clear-cache": "tsx src/cache/clearCache.ts" }, "keywords": [ diff --git a/backend/src/db/schema/credentials.ts b/backend/src/db/schema/credentials.ts index b8421a0..b08a019 100644 --- a/backend/src/db/schema/credentials.ts +++ b/backend/src/db/schema/credentials.ts @@ -11,6 +11,7 @@ export const credentials = pgTable("credentials", { .references(() => users.id, { onDelete: "cascade" }), email: text("email").notNull().unique(), + emailHash: text("email_hash").unique(), passwordHash: text("password_hash").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), diff --git a/backend/src/db/schema/users.ts b/backend/src/db/schema/users.ts index f848ea0..e1aaccc 100644 --- a/backend/src/db/schema/users.ts +++ b/backend/src/db/schema/users.ts @@ -26,19 +26,30 @@ export const users = pgTable( id: uuid("id").defaultRandom().primaryKey(), firstName: text("first_name"), + firstNameEncrypted: text("first_name_encrypted"), lastName: text("last_name"), + lastNameEncrypted: text("last_name_encrypted"), displayName: text("display_name"), + displayNameEncrypted: text("display_name_encrypted"), username: text("username"), email: text("email"), + emailEncrypted: text("email_encrypted"), + emailHash: text("email_hash"), emailVerified: boolean("email_verified").default(false).notNull(), avatarUrl: text("avatar_url"), + avatarUrlEncrypted: text("avatar_url_encrypted"), phone: varchar("phone", { length: 20 }), + phoneEncrypted: text("phone_encrypted"), cpf: varchar("cpf", { length: 14 }), + cpfEncrypted: text("cpf_encrypted"), + cpfHash: text("cpf_hash"), technologies: text("technologies").array().default([]), + technologiesEncrypted: text("technologies_encrypted"), level: varchar("level", { length: 50 }), + levelEncrypted: text("level_encrypted"), role: userRoleEnum("role").default("user").notNull(), isBlocked: boolean("is_blocked").default(false).notNull(), @@ -50,6 +61,9 @@ export const users = pgTable( (table) => ({ usernameUnique: uniqueIndex("users_username_unique").on(table.username), emailUnique: uniqueIndex("users_email_unique").on(table.email), + emailHashUnique: uniqueIndex("users_email_hash_unique").on( + table.emailHash, + ), }), ); diff --git a/backend/src/lib/security/encryption.ts b/backend/src/lib/security/encryption.ts new file mode 100644 index 0000000..9c01321 --- /dev/null +++ b/backend/src/lib/security/encryption.ts @@ -0,0 +1,79 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH_BYTES = 12; +const AUTH_TAG_LENGTH_BYTES = 16; +const KEY_LENGTH_BYTES = 32; +const PAYLOAD_VERSION = "v1"; + +function getMasterKey(): Buffer { + const rawKey = process.env.ENCRYPTION_MASTER_KEY?.trim(); + + if (!rawKey) { + throw new Error("ENCRYPTION_MASTER_KEY is required for encryption"); + } + + if (!/^[a-fA-F0-9]+$/.test(rawKey)) { + throw new Error("ENCRYPTION_MASTER_KEY must be hex encoded"); + } + + const key = Buffer.from(rawKey, "hex"); + + if (key.length !== KEY_LENGTH_BYTES) { + throw new Error("ENCRYPTION_MASTER_KEY must be 32 bytes (64 hex chars)"); + } + + return key; +} + +function getKeyId(): string { + return process.env.ENCRYPTION_KEY_ID?.trim() || "default"; +} + +export function encryptText(value: string): string { + const iv = randomBytes(IV_LENGTH_BYTES); + const cipher = createCipheriv(ALGORITHM, getMasterKey(), iv, { + authTagLength: AUTH_TAG_LENGTH_BYTES, + }); + + const ciphertext = Buffer.concat([ + cipher.update(value, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return [ + PAYLOAD_VERSION, + getKeyId(), + iv.toString("hex"), + authTag.toString("hex"), + ciphertext.toString("hex"), + ].join(":"); +} + +export function decryptText(payload: string): string { + const parts = payload.split(":"); + + if (parts.length !== 5 || parts[0] !== PAYLOAD_VERSION) { + throw new Error("Invalid encrypted payload format"); + } + + const [, , ivHex, authTagHex, ciphertextHex] = parts; + const decipher = createDecipheriv( + ALGORITHM, + getMasterKey(), + Buffer.from(ivHex, "hex"), + { authTagLength: AUTH_TAG_LENGTH_BYTES }, + ); + + decipher.setAuthTag(Buffer.from(authTagHex, "hex")); + + return Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, "hex")), + decipher.final(), + ]).toString("utf8"); +} + +export function isEncryptedPayload(value: string | null | undefined): boolean { + return typeof value === "string" && value.startsWith(`${PAYLOAD_VERSION}:`); +} diff --git a/backend/src/lib/security/normalization.ts b/backend/src/lib/security/normalization.ts new file mode 100644 index 0000000..5245df5 --- /dev/null +++ b/backend/src/lib/security/normalization.ts @@ -0,0 +1,11 @@ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function normalizeCpf(cpf: string): string { + return cpf.replace(/\D/g, ""); +} + +export function normalizeSearchableText(value: string): string { + return value.trim().toLowerCase(); +} diff --git a/backend/src/lib/security/piiPayload.ts b/backend/src/lib/security/piiPayload.ts new file mode 100644 index 0000000..fd6f447 --- /dev/null +++ b/backend/src/lib/security/piiPayload.ts @@ -0,0 +1,50 @@ +import { encryptText } from "./encryption"; +import { normalizeCpf, normalizeEmail } from "./normalization"; +import { generateSearchableHash } from "./searchableHash"; + +export type ProtectedUserPii = { + emailEncrypted?: string | null; + emailHash?: string | null; + firstNameEncrypted?: string | null; + lastNameEncrypted?: string | null; + displayNameEncrypted?: string | null; + phoneEncrypted?: string | null; + cpfEncrypted?: string | null; + cpfHash?: string | null; +}; + +export function protectEmail(email: string | null | undefined) { + if (!email) { + return { + emailEncrypted: null, + emailHash: null, + }; + } + + const normalizedEmail = normalizeEmail(email); + + return { + emailEncrypted: encryptText(normalizedEmail), + emailHash: generateSearchableHash(normalizedEmail), + }; +} + +export function protectCpf(cpf: string | null | undefined) { + if (!cpf) { + return { + cpfEncrypted: null, + cpfHash: null, + }; + } + + const normalizedCpf = normalizeCpf(cpf); + + return { + cpfEncrypted: encryptText(normalizedCpf), + cpfHash: generateSearchableHash(normalizedCpf), + }; +} + +export function protectNullableText(value: string | null | undefined) { + return value ? encryptText(value.trim()) : null; +} diff --git a/backend/src/lib/security/searchableHash.ts b/backend/src/lib/security/searchableHash.ts new file mode 100644 index 0000000..17992a1 --- /dev/null +++ b/backend/src/lib/security/searchableHash.ts @@ -0,0 +1,28 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +const SEARCH_HASH_ALGORITHM = "sha256"; + +function getSearchKey(): string { + const key = process.env.SEARCH_KEY?.trim(); + + if (!key) { + throw new Error("SEARCH_KEY is required for searchable hashes"); + } + + return key; +} + +export function generateSearchableHash(value: string): string { + return createHmac(SEARCH_HASH_ALGORITHM, getSearchKey()) + .update(value) + .digest("hex"); +} + +export function compareSearchableHash(value: string, hash: string): boolean { + const candidate = Buffer.from(generateSearchableHash(value), "hex"); + const expected = Buffer.from(hash, "hex"); + + return ( + candidate.length === expected.length && timingSafeEqual(candidate, expected) + ); +} diff --git a/backend/src/modules/admin/users/adminUsers.repository.ts b/backend/src/modules/admin/users/adminUsers.repository.ts index 74aad17..92a13dc 100644 --- a/backend/src/modules/admin/users/adminUsers.repository.ts +++ b/backend/src/modules/admin/users/adminUsers.repository.ts @@ -1,8 +1,11 @@ import * as argon2 from "argon2"; -import { and, count, eq, ilike, or } from "drizzle-orm"; +import { and, count, eq, ilike, or, type SQL } from "drizzle-orm"; import { db } from "../../../db/client"; import { credentials } from "../../../db/schema/credentials"; import { users } from "../../../db/schema/users"; +import { normalizeEmail } from "../../../lib/security/normalization"; +import { generateSearchableHash } from "../../../lib/security/searchableHash"; +import { toPublicUser } from "../../users/users.mapper"; import type { AdminUserFilters, ChangeRoleInput, @@ -22,19 +25,28 @@ export class AdminUsersRepository { const limit = filters.limit ?? 50; const offset = filters.offset ?? 0; + const searchConditions: SQL[] = []; + + if (filters.search) { + searchConditions.push( + ilike(users.username, `%${filters.search}%`), + ilike(users.displayName, `%${filters.search}%`), + ); + + if (filters.search.includes("@")) { + searchConditions.push( + eq(users.emailHash, generateSearchableHash(normalizeEmail(filters.search))), + ); + } + } + const conditions = [ filters.role ? eq(users.role, filters.role) : undefined, filters.isBlocked !== undefined ? eq(users.isBlocked, filters.isBlocked) : undefined, - filters.search - ? or( - ilike(users.email, `%${filters.search}%`), - ilike(users.username, `%${filters.search}%`), - ilike(users.displayName, `%${filters.search}%`), - ) - : undefined, - ].filter(Boolean) as ReturnType[]; + searchConditions.length > 0 ? or(...searchConditions) : undefined, + ].filter(Boolean) as SQL[]; const where = conditions.length > 0 ? and(...conditions) : undefined; @@ -43,11 +55,12 @@ export class AdminUsersRepository { db.select({ value: count() }).from(users).where(where), ]); - return { data, total, limit, offset }; + return { data: data.map(toPublicUser), total, limit, offset }; } async findById(id: string) { - return db.query.users.findFirst({ where: eq(users.id, id) }) ?? null; + const user = await db.query.users.findFirst({ where: eq(users.id, id) }); + return user ? toPublicUser(user) : null; } async setBlocked(id: string, isBlocked: boolean) { @@ -56,7 +69,7 @@ export class AdminUsersRepository { .set({ isBlocked, updatedAt: new Date() }) .where(eq(users.id, id)) .returning(); - return updated ?? null; + return updated ? toPublicUser(updated) : null; } async changeRole({ userId, newRole }: ChangeRoleInput) { @@ -65,7 +78,7 @@ export class AdminUsersRepository { .set({ role: newRole, updatedAt: new Date() }) .where(eq(users.id, userId)) .returning(); - return updated ?? null; + return updated ? toPublicUser(updated) : null; } async resetPassword({ userId, newPassword }: ResetPasswordInput) { @@ -88,6 +101,6 @@ export class AdminUsersRepository { .delete(users) .where(eq(users.id, id)) .returning(); - return deleted ?? null; + return deleted ? toPublicUser(deleted) : null; } } diff --git a/backend/src/modules/auth/credentials.service.ts b/backend/src/modules/auth/credentials.service.ts index 283c6b7..a46a081 100644 --- a/backend/src/modules/auth/credentials.service.ts +++ b/backend/src/modules/auth/credentials.service.ts @@ -1,11 +1,12 @@ import * as argon2 from "argon2"; -import { eq } from "drizzle-orm"; import { db } from "../../db/client"; import { userPreferences } from "../../db/schema"; import { credentials } from "../../db/schema/credentials"; import type { User } from "../../db/schema/users"; -import { users } from "../../db/schema/users"; import { AppError } from "../../lib/errors"; +import { encryptText } from "../../lib/security/encryption"; +import { normalizeEmail } from "../../lib/security/normalization"; +import { generateSearchableHash } from "../../lib/security/searchableHash"; import { generateUsername } from "../../utils/generateUsername"; import type { Session } from "../types/auth.types"; import { @@ -14,6 +15,7 @@ import { RegisterInput, RegisterSchema, } from "../types/credentials.types"; +import { UsersRepository } from "../users/users.repository"; const argonOptions = { type: argon2.argon2id, @@ -24,9 +26,7 @@ const argonOptions = { export class CredentialsService { async findById(id: string): Promise { - const user = await db.query.users.findFirst({ - where: eq(users.id, id), - }); + const user = await new UsersRepository().findById(id); return user ?? null; } @@ -35,17 +35,17 @@ export class CredentialsService { ): Promise<{ user: User; session: Session }> { const { email, password, name, phone, cpf, technologies, level } = RegisterSchema.parse(input); + const normalizedEmail = normalizeEmail(email); + const emailHash = generateSearchableHash(normalizedEmail); const existingCredential = await db.query.credentials.findFirst({ - where: eq(credentials.email, email), + where: (credential, { eq }) => eq(credential.emailHash, emailHash), }); if (existingCredential) { throw AppError.conflict("Email já cadastrado"); } - const existingUser = await db.query.users.findFirst({ - where: eq(users.email, email), - }); + const existingUser = await new UsersRepository().findByEmail(email); if (existingUser) { throw AppError.conflict("Email já cadastrado"); } @@ -56,23 +56,26 @@ export class CredentialsService { const baseName = name?.trim() || email.split("@")[0]; const username = await generateUsername(baseName, tx); - const [createdUser] = await tx - .insert(users) - .values({ + const createdUser = await new UsersRepository(tx).create( + { email, displayName: name, - username, - emailVerified: false, phone, cpf, technologies, level, - }) - .returning(); + }, + username, + ); await tx .insert(credentials) - .values({ userId: createdUser.id, email, passwordHash }); + .values({ + userId: createdUser.id, + email: encryptText(normalizedEmail), + emailHash, + passwordHash, + }); await tx.insert(userPreferences).values({ userId: createdUser.id }); return createdUser; @@ -83,9 +86,11 @@ export class CredentialsService { async login(input: LoginInput): Promise<{ user: User; session: Session }> { const { email, password } = LoginSchema.parse(input); + const normalizedEmail = normalizeEmail(email); + const emailHash = generateSearchableHash(normalizedEmail); const credential = await db.query.credentials.findFirst({ - where: eq(credentials.email, email), + where: (credential, { eq }) => eq(credential.emailHash, emailHash), }); if (!credential) { throw AppError.unauthorized("Credenciais inválidas"); @@ -96,9 +101,7 @@ export class CredentialsService { throw AppError.unauthorized("Credenciais inválidas"); } - const user = await db.query.users.findFirst({ - where: eq(users.id, credential.userId), - }); + const user = await new UsersRepository().findById(credential.userId); if (!user) { throw AppError.notFound("Usuário não encontrado"); } diff --git a/backend/src/modules/auth/providers/credentials.ts b/backend/src/modules/auth/providers/credentials.ts index 3b3f6af..94fe69c 100644 --- a/backend/src/modules/auth/providers/credentials.ts +++ b/backend/src/modules/auth/providers/credentials.ts @@ -2,13 +2,19 @@ import * as argon2 from "argon2"; import { eq } from "drizzle-orm"; import { db } from "../../../db/client.js"; import { credentials } from "../../../db/schema/credentials.js"; +import { encryptText } from "../../../lib/security/encryption.js"; +import { normalizeEmail } from "../../../lib/security/normalization.js"; +import { generateSearchableHash } from "../../../lib/security/searchableHash.js"; export async function registerWithCredentials( email: string, password: string, ): Promise { + const normalizedEmail = normalizeEmail(email); + const emailHash = generateSearchableHash(normalizedEmail); + const existing = await db.query.credentials.findFirst({ - where: eq(credentials.email, email), + where: eq(credentials.emailHash, emailHash), }); if (existing) { @@ -18,7 +24,8 @@ export async function registerWithCredentials( const passwordHash = await argon2.hash(password); await db.insert(credentials).values({ - email, + email: encryptText(normalizedEmail), + emailHash, passwordHash, userId: "", }); @@ -28,8 +35,10 @@ export async function verifyCredentials( email: string, password: string, ): Promise<{ userId: string }> { + const emailHash = generateSearchableHash(normalizeEmail(email)); + const credential = await db.query.credentials.findFirst({ - where: eq(credentials.email, email), + where: eq(credentials.emailHash, emailHash), }); if (!credential) { diff --git a/backend/src/modules/users/functions/createUser.ts b/backend/src/modules/users/functions/createUser.ts index c9d860d..40ad78f 100644 --- a/backend/src/modules/users/functions/createUser.ts +++ b/backend/src/modules/users/functions/createUser.ts @@ -1,7 +1,7 @@ import { db } from "../../../db/client"; -import { users } from "../../../db/schema/users"; import { DB } from "../../../db/types/types"; import { generateUsername } from "../../../utils/generateUsername"; +import { UsersRepository } from "../users.repository"; import { findUserByEmail } from "./findUsers"; export type CreateUserParams = { @@ -35,23 +35,7 @@ export async function createUser( const username = profile.username ?? (await generateUsername(baseName, tx)); try { - const result = await tx - .insert(users) - .values({ - email: profile.email, - firstName: profile.firstName ?? null, - lastName: profile.lastName ?? null, - displayName: profile.displayName ?? null, - username, - avatarUrl: profile.avatarUrl ?? null, - phone: profile.phone ?? null, - cpf: profile.cpf ?? null, - technologies: profile.technologies ?? undefined, - level: profile.level ?? null, - }) - .returning(); - - return result[0]; + return await new UsersRepository(tx).create(profile, username); } catch (err: any) { if (err.code === "23505" && options.onEmailConflict === "returnExisting") { if (!profile.email) throw err; diff --git a/backend/src/modules/users/functions/findUsers.ts b/backend/src/modules/users/functions/findUsers.ts index da1263e..4f07013 100644 --- a/backend/src/modules/users/functions/findUsers.ts +++ b/backend/src/modules/users/functions/findUsers.ts @@ -1,5 +1,6 @@ import { db } from "../../../db/client"; import { DB } from "../../../db/types/types"; +import { UsersRepository } from "../users.repository"; export async function findUserByProvider( { @@ -24,7 +25,5 @@ export async function findUserByProvider( } export async function findUserByEmail(email: string, tx: DB = db) { - return tx.query.users.findFirst({ - where: (u, { eq }) => eq(u.email, email), - }); + return new UsersRepository(tx).findByEmail(email); } diff --git a/backend/src/modules/users/users.mapper.ts b/backend/src/modules/users/users.mapper.ts new file mode 100644 index 0000000..510e6c1 --- /dev/null +++ b/backend/src/modules/users/users.mapper.ts @@ -0,0 +1,117 @@ +import type { NewUser, User } from "../../db/schema/users"; +import { decryptText } from "../../lib/security/encryption"; +import { + protectCpf, + protectEmail, + protectNullableText, +} from "../../lib/security/piiPayload"; +import type { CreateUserParams } from "./functions/createUser"; +import type { UpdateProfileData } from "../types/user.types"; + +function protectTechnologies(value: string[] | null | undefined) { + return value ? protectNullableText(JSON.stringify(value)) : null; +} + +function parseTechnologies(value: string | null | undefined) { + if (!value) return null; + + try { + const parsed = JSON.parse(decryptText(value)); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function toUserCreateValues(profile: CreateUserParams): Partial { + return { + email: null, + ...protectEmail(profile.email), + firstName: null, + firstNameEncrypted: protectNullableText(profile.firstName), + lastName: null, + lastNameEncrypted: protectNullableText(profile.lastName), + displayName: null, + displayNameEncrypted: protectNullableText(profile.displayName), + avatarUrl: null, + avatarUrlEncrypted: protectNullableText(profile.avatarUrl), + phone: null, + phoneEncrypted: protectNullableText(profile.phone), + cpf: null, + ...protectCpf(profile.cpf), + technologies: null, + technologiesEncrypted: protectTechnologies(profile.technologies ?? []), + level: null, + levelEncrypted: protectNullableText(profile.level), + }; +} + +export function toUserUpdateValues(data: UpdateProfileData): Partial { + const values: Partial = { ...data }; + + if ("firstName" in data) { + values.firstName = null; + values.firstNameEncrypted = protectNullableText(data.firstName); + } + + if ("lastName" in data) { + values.lastName = null; + values.lastNameEncrypted = protectNullableText(data.lastName); + } + + if ("displayName" in data) { + values.displayName = null; + values.displayNameEncrypted = protectNullableText(data.displayName); + } + + if ("phone" in data) { + values.phone = null; + values.phoneEncrypted = protectNullableText(data.phone); + } + + if ("cpf" in data) { + values.cpf = null; + Object.assign(values, protectCpf(data.cpf)); + } + + if ("avatarUrl" in data) { + values.avatarUrl = null; + values.avatarUrlEncrypted = protectNullableText(data.avatarUrl); + } + + if ("technologies" in data) { + values.technologies = null; + values.technologiesEncrypted = protectTechnologies(data.technologies); + } + + if ("level" in data) { + values.level = null; + values.levelEncrypted = protectNullableText(data.level); + } + + return values; +} + +export function toPublicUser(user: User): User { + return { + ...user, + email: user.emailEncrypted ? decryptText(user.emailEncrypted) : user.email, + firstName: user.firstNameEncrypted + ? decryptText(user.firstNameEncrypted) + : user.firstName, + lastName: user.lastNameEncrypted + ? decryptText(user.lastNameEncrypted) + : user.lastName, + displayName: user.displayNameEncrypted + ? decryptText(user.displayNameEncrypted) + : user.displayName, + avatarUrl: user.avatarUrlEncrypted + ? decryptText(user.avatarUrlEncrypted) + : user.avatarUrl, + phone: user.phoneEncrypted ? decryptText(user.phoneEncrypted) : user.phone, + cpf: user.cpfEncrypted ? decryptText(user.cpfEncrypted) : user.cpf, + technologies: + parseTechnologies(user.technologiesEncrypted) ?? user.technologies, + level: user.levelEncrypted ? decryptText(user.levelEncrypted) : user.level, + }; +} diff --git a/backend/src/modules/users/users.repository.ts b/backend/src/modules/users/users.repository.ts new file mode 100644 index 0000000..b7634d0 --- /dev/null +++ b/backend/src/modules/users/users.repository.ts @@ -0,0 +1,58 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db/client"; +import { users } from "../../db/schema/users"; +import type { DB } from "../../db/types/types"; +import { normalizeEmail } from "../../lib/security/normalization"; +import { generateSearchableHash } from "../../lib/security/searchableHash"; +import type { CreateUserParams } from "./functions/createUser"; +import type { UpdateProfileData } from "../types/user.types"; +import { + toPublicUser, + toUserCreateValues, + toUserUpdateValues, +} from "./users.mapper"; + +export class UsersRepository { + constructor(private readonly tx: DB = db) {} + + async create(profile: CreateUserParams, username: string) { + const [created] = await this.tx + .insert(users) + .values({ + ...toUserCreateValues(profile), + username, + }) + .returning(); + + return created ? toPublicUser(created) : created; + } + + async findById(id: string) { + const user = await this.tx.query.users.findFirst({ + where: (u, { eq }) => eq(u.id, id), + }); + + return user ? toPublicUser(user) : undefined; + } + + async findByEmail(email: string) { + const normalizedEmail = normalizeEmail(email); + const emailHash = generateSearchableHash(normalizedEmail); + + const user = await this.tx.query.users.findFirst({ + where: eq(users.emailHash, emailHash), + }); + + return user ? toPublicUser(user) : undefined; + } + + async updateProfile(userId: string, data: UpdateProfileData) { + const [updated] = await this.tx + .update(users) + .set({ ...toUserUpdateValues(data), updatedAt: new Date() }) + .where(eq(users.id, userId)) + .returning(); + + return updated ? toPublicUser(updated) : null; + } +} diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 903520d..67931de 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -5,29 +5,27 @@ import { DB } from "../../db/types/types"; import { AppError } from "../../lib/errors"; import { UpdateProfileData } from "../types/user.types"; import { UpdatePreferencesData } from "./schemas/user.schemas"; +import { UsersRepository } from "./users.repository"; export class UsersService { constructor(private readonly tx: DB = db) {} async getUserById(id: string): Promise { - return this.tx.query.users.findFirst({ - where: (u, { eq }) => eq(u.id, id), - }); + return (await new UsersRepository(this.tx).findById(id)) ?? undefined; } async updateProfile(userId: string, data: UpdateProfileData): Promise { - const result = await this.tx - .update(users) - .set({ ...data, updatedAt: new Date() }) - .where(eq(users.id, userId)) - .returning(); + const updated = await new UsersRepository(this.tx).updateProfile( + userId, + data, + ); - if (!result[0]) { + if (!updated) { throw AppError.notFound("Usuário não encontrado"); } // await this.valkey.del(`user:${userId}`); ← invalidação de cache futura - return result[0]; + return updated; } async getPreferences(userId: string): Promise { diff --git a/backend/src/scripts/backfillUserPii.ts b/backend/src/scripts/backfillUserPii.ts new file mode 100644 index 0000000..bbedf36 --- /dev/null +++ b/backend/src/scripts/backfillUserPii.ts @@ -0,0 +1,160 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { credentials } from "../db/schema/credentials"; +import { users } from "../db/schema/users"; +import { + decryptText, + encryptText, + isEncryptedPayload, +} from "../lib/security/encryption"; +import { normalizeEmail } from "../lib/security/normalization"; +import { + protectCpf, + protectEmail, + protectNullableText, +} from "../lib/security/piiPayload"; +import { generateSearchableHash } from "../lib/security/searchableHash"; + +const shouldWrite = process.argv.includes("--write"); + +async function backfillUsers() { + const rows = await db.select().from(users); + let pending = 0; + + for (const user of rows) { + const nextValues: Partial = {}; + + if (user.email) { + if (!user.emailEncrypted || !user.emailHash) { + Object.assign(nextValues, protectEmail(user.email)); + } + nextValues.email = null; + } + + if (user.firstName) { + if (!user.firstNameEncrypted) { + nextValues.firstNameEncrypted = protectNullableText(user.firstName); + } + nextValues.firstName = null; + } + + if (user.lastName) { + if (!user.lastNameEncrypted) { + nextValues.lastNameEncrypted = protectNullableText(user.lastName); + } + nextValues.lastName = null; + } + + if (user.displayName) { + if (!user.displayNameEncrypted) { + nextValues.displayNameEncrypted = protectNullableText(user.displayName); + } + nextValues.displayName = null; + } + + if (user.avatarUrl) { + if (!user.avatarUrlEncrypted) { + nextValues.avatarUrlEncrypted = protectNullableText(user.avatarUrl); + } + nextValues.avatarUrl = null; + } + + if (user.phone) { + if (!user.phoneEncrypted) { + nextValues.phoneEncrypted = protectNullableText(user.phone); + } + nextValues.phone = null; + } + + if (user.cpf) { + if (!user.cpfEncrypted || !user.cpfHash) { + Object.assign(nextValues, protectCpf(user.cpf)); + } + nextValues.cpf = null; + } + + if (user.technologies) { + if (!user.technologiesEncrypted) { + nextValues.technologiesEncrypted = protectNullableText( + JSON.stringify(user.technologies), + ); + } + nextValues.technologies = null; + } + + if (user.level) { + if (!user.levelEncrypted) { + nextValues.levelEncrypted = protectNullableText(user.level); + } + nextValues.level = null; + } + + if (Object.keys(nextValues).length === 0) continue; + + pending += 1; + + if (shouldWrite) { + await db.update(users).set(nextValues).where(eq(users.id, user.id)); + } + } + + return { scanned: rows.length, pending }; +} + +async function backfillCredentials() { + const rows = await db.select().from(credentials); + let pending = 0; + + for (const credential of rows) { + const email = isEncryptedPayload(credential.email) + ? decryptText(credential.email) + : credential.email; + const normalizedEmail = normalizeEmail(email); + const nextValues: Partial = {}; + + if (!credential.emailHash) { + nextValues.emailHash = generateSearchableHash(normalizedEmail); + } + + if (!isEncryptedPayload(credential.email)) { + nextValues.email = encryptText(normalizedEmail); + } + + if (Object.keys(nextValues).length === 0) continue; + + if (shouldWrite) { + await db + .update(credentials) + .set(nextValues) + .where(eq(credentials.id, credential.id)); + } + + pending += 1; + } + + return { scanned: rows.length, pending }; +} + +async function main() { + const [userResult, credentialResult] = await Promise.all([ + backfillUsers(), + backfillCredentials(), + ]); + + console.log( + JSON.stringify( + { + mode: shouldWrite ? "write" : "dry-run", + users: userResult, + credentials: credentialResult, + }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/backend/tests/setup.js b/backend/tests/setup.js index b9770fd..e4210d9 100644 --- a/backend/tests/setup.js +++ b/backend/tests/setup.js @@ -1,4 +1,9 @@ import { config } from "dotenv"; import { resolve } from "path"; -config({ path: resolve(process.cwd(), ".env.test") }); \ No newline at end of file +config({ path: resolve(process.cwd(), ".env.test") }); + +process.env.SEARCH_KEY ??= "test-search-key"; +process.env.ENCRYPTION_MASTER_KEY ??= + "0000000000000000000000000000000000000000000000000000000000000000"; +process.env.ENCRYPTION_KEY_ID ??= "test"; diff --git a/backend/tests/unit/lib/security.test.ts b/backend/tests/unit/lib/security.test.ts new file mode 100644 index 0000000..e549d7e --- /dev/null +++ b/backend/tests/unit/lib/security.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + decryptText, + encryptText, + isEncryptedPayload, +} from "../../../src/lib/security/encryption"; +import { + normalizeCpf, + normalizeEmail, + normalizeSearchableText, +} from "../../../src/lib/security/normalization"; +import { + protectCpf, + protectEmail, + protectNullableText, +} from "../../../src/lib/security/piiPayload"; +import { + compareSearchableHash, + generateSearchableHash, +} from "../../../src/lib/security/searchableHash"; + +const VALID_MASTER_KEY = + "0000000000000000000000000000000000000000000000000000000000000000"; + +const originalEnv = { + ENCRYPTION_MASTER_KEY: process.env.ENCRYPTION_MASTER_KEY, + ENCRYPTION_KEY_ID: process.env.ENCRYPTION_KEY_ID, + SEARCH_KEY: process.env.SEARCH_KEY, +}; + +function setValidSecurityEnv() { + process.env.ENCRYPTION_MASTER_KEY = VALID_MASTER_KEY; + process.env.ENCRYPTION_KEY_ID = "test-key"; + process.env.SEARCH_KEY = "test-search-key"; +} + +afterEach(() => { + process.env.ENCRYPTION_MASTER_KEY = originalEnv.ENCRYPTION_MASTER_KEY; + process.env.ENCRYPTION_KEY_ID = originalEnv.ENCRYPTION_KEY_ID; + process.env.SEARCH_KEY = originalEnv.SEARCH_KEY; +}); + +describe("security helpers", () => { + it("encrypts and decrypts text payloads", () => { + setValidSecurityEnv(); + + const encrypted = encryptText("secret value"); + + expect(encrypted).toMatch(/^v1:test-key:/); + expect(encrypted).not.toContain("secret value"); + expect(isEncryptedPayload(encrypted)).toBe(true); + expect(decryptText(encrypted)).toBe("secret value"); + }); + + it("uses default key id when ENCRYPTION_KEY_ID is empty", () => { + setValidSecurityEnv(); + process.env.ENCRYPTION_KEY_ID = ""; + + expect(encryptText("secret")).toMatch(/^v1:default:/); + }); + + it("rejects missing, malformed, and short encryption keys", () => { + process.env.ENCRYPTION_MASTER_KEY = ""; + expect(() => encryptText("secret")).toThrow( + "ENCRYPTION_MASTER_KEY is required", + ); + + process.env.ENCRYPTION_MASTER_KEY = "not-hex"; + expect(() => encryptText("secret")).toThrow( + "ENCRYPTION_MASTER_KEY must be hex encoded", + ); + + process.env.ENCRYPTION_MASTER_KEY = "aabbcc"; + expect(() => encryptText("secret")).toThrow( + "ENCRYPTION_MASTER_KEY must be 32 bytes", + ); + }); + + it("rejects invalid encrypted payloads", () => { + setValidSecurityEnv(); + + expect(() => decryptText("not-encrypted")).toThrow( + "Invalid encrypted payload format", + ); + expect(isEncryptedPayload(null)).toBe(false); + expect(isEncryptedPayload(undefined)).toBe(false); + expect(isEncryptedPayload("plain")).toBe(false); + }); + + it("normalizes email, CPF, and generic searchable text", () => { + expect(normalizeEmail(" USER@Example.COM ")).toBe("user@example.com"); + expect(normalizeCpf("123.456.789-01")).toBe("12345678901"); + expect(normalizeSearchableText(" PlEnO ")).toBe("pleno"); + }); + + it("generates and compares searchable hashes", () => { + setValidSecurityEnv(); + + const hash = generateSearchableHash("user@example.com"); + + expect(hash).toHaveLength(64); + expect(compareSearchableHash("user@example.com", hash)).toBe(true); + expect(compareSearchableHash("other@example.com", hash)).toBe(false); + expect(compareSearchableHash("user@example.com", "abcd")).toBe(false); + }); + + it("requires SEARCH_KEY for searchable hashes", () => { + process.env.SEARCH_KEY = ""; + + expect(() => generateSearchableHash("value")).toThrow( + "SEARCH_KEY is required", + ); + }); + + it("builds protected PII payloads and null payloads", () => { + setValidSecurityEnv(); + + const email = protectEmail(" USER@Example.COM "); + const cpf = protectCpf("123.456.789-01"); + + expect(email.emailEncrypted).toMatch(/^v1:test-key:/); + expect(email.emailHash).toHaveLength(64); + expect(cpf.cpfEncrypted).toMatch(/^v1:test-key:/); + expect(cpf.cpfHash).toHaveLength(64); + expect(decryptText(email.emailEncrypted!)).toBe("user@example.com"); + expect(decryptText(cpf.cpfEncrypted!)).toBe("12345678901"); + expect(decryptText(protectNullableText(" Hudson ")!)).toBe("Hudson"); + expect(protectEmail(null)).toEqual({ + emailEncrypted: null, + emailHash: null, + }); + expect(protectEmail(undefined)).toEqual({ + emailEncrypted: null, + emailHash: null, + }); + expect(protectCpf(null)).toEqual({ cpfEncrypted: null, cpfHash: null }); + expect(protectCpf(undefined)).toEqual({ + cpfEncrypted: null, + cpfHash: null, + }); + expect(protectNullableText(null)).toBeNull(); + expect(protectNullableText(undefined)).toBeNull(); + expect(protectNullableText("")).toBeNull(); + }); +}); diff --git a/backend/tests/unit/modules/auth/providers.test.ts b/backend/tests/unit/modules/auth/providers.test.ts index 9de84cd..d032650 100644 --- a/backend/tests/unit/modules/auth/providers.test.ts +++ b/backend/tests/unit/modules/auth/providers.test.ts @@ -251,10 +251,14 @@ describe("credentials provider", () => { expect(mocks.hash).toHaveBeenCalledWith("Senha@123"); expect(mocks.insertValues).toHaveBeenCalledWith({ - email: "user@example.com", + email: expect.stringMatching(/^v1:/), + emailHash: expect.any(String), passwordHash: "hash-value", userId: "", }); + expect(mocks.insertValues.mock.calls[0][0].email).not.toBe( + "user@example.com", + ); }); it("impede registro duplicado", async () => { diff --git a/backend/tests/unit/modules/users/users.mapper.test.ts b/backend/tests/unit/modules/users/users.mapper.test.ts new file mode 100644 index 0000000..a746b0f --- /dev/null +++ b/backend/tests/unit/modules/users/users.mapper.test.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { User } from "../../../../src/db/schema/users"; +import { encryptText } from "../../../../src/lib/security/encryption"; +import { + toPublicUser, + toUserCreateValues, + toUserUpdateValues, +} from "../../../../src/modules/users/users.mapper"; + +const originalEnv = { + ENCRYPTION_MASTER_KEY: process.env.ENCRYPTION_MASTER_KEY, + ENCRYPTION_KEY_ID: process.env.ENCRYPTION_KEY_ID, + SEARCH_KEY: process.env.SEARCH_KEY, +}; + +function setValidSecurityEnv() { + process.env.ENCRYPTION_MASTER_KEY = + "0000000000000000000000000000000000000000000000000000000000000000"; + process.env.ENCRYPTION_KEY_ID = "mapper-test"; + process.env.SEARCH_KEY = "mapper-search-key"; +} + +function baseUser(overrides: Partial = {}): User { + return { + id: "00000000-0000-4000-8000-000000000001", + firstName: null, + firstNameEncrypted: null, + lastName: null, + lastNameEncrypted: null, + displayName: null, + displayNameEncrypted: null, + username: "user.name", + email: null, + emailEncrypted: null, + emailHash: null, + emailVerified: false, + avatarUrl: null, + avatarUrlEncrypted: null, + phone: null, + phoneEncrypted: null, + cpf: null, + cpfEncrypted: null, + cpfHash: null, + technologies: null, + technologiesEncrypted: null, + level: null, + levelEncrypted: null, + role: "user", + isBlocked: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + lastLoginAt: null, + ...overrides, + }; +} + +afterEach(() => { + process.env.ENCRYPTION_MASTER_KEY = originalEnv.ENCRYPTION_MASTER_KEY; + process.env.ENCRYPTION_KEY_ID = originalEnv.ENCRYPTION_KEY_ID; + process.env.SEARCH_KEY = originalEnv.SEARCH_KEY; +}); + +describe("users.mapper", () => { + it("maps create values with encrypted PII and null plain fields", () => { + setValidSecurityEnv(); + + const values = toUserCreateValues({ + email: " USER@Example.COM ", + firstName: "Ada", + lastName: "Lovelace", + displayName: "Ada Lovelace", + avatarUrl: "https://example.com/ada.png", + phone: "+5534999999999", + cpf: "123.456.789-01", + technologies: ["TypeScript", "Node.js"], + level: "pleno", + }); + + expect(values).toMatchObject({ + email: null, + firstName: null, + lastName: null, + displayName: null, + avatarUrl: null, + phone: null, + cpf: null, + technologies: null, + level: null, + }); + expect(values.emailEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.emailHash).toHaveLength(64); + expect(values.firstNameEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.lastNameEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.displayNameEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.avatarUrlEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.phoneEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.cpfEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.cpfHash).toHaveLength(64); + expect(values.technologiesEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.levelEncrypted).toMatch(/^v1:mapper-test:/); + }); + + it("maps update values only for provided fields", () => { + setValidSecurityEnv(); + + const values = toUserUpdateValues({ + firstName: "Grace", + lastName: "Hopper", + displayName: "Grace Hopper", + avatarUrl: "https://example.com/grace.png", + phone: "+5511999999999", + cpf: "987.654.321-00", + technologies: ["Go"], + level: "senior", + }); + + expect(values).toMatchObject({ + firstName: null, + lastName: null, + displayName: null, + avatarUrl: null, + phone: null, + cpf: null, + technologies: null, + level: null, + }); + expect(values.firstNameEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.lastNameEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.displayNameEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.avatarUrlEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.phoneEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.cpfEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.cpfHash).toHaveLength(64); + expect(values.technologiesEncrypted).toMatch(/^v1:mapper-test:/); + expect(values.levelEncrypted).toMatch(/^v1:mapper-test:/); + + expect(toUserUpdateValues({ username: "grace" })).toEqual({ + username: "grace", + }); + }); + + it("returns decrypted public user values when encrypted fields exist", () => { + setValidSecurityEnv(); + + const publicUser = toPublicUser( + baseUser({ + emailEncrypted: encryptText("ada@example.com"), + firstNameEncrypted: encryptText("Ada"), + lastNameEncrypted: encryptText("Lovelace"), + displayNameEncrypted: encryptText("Ada Lovelace"), + avatarUrlEncrypted: encryptText("https://example.com/ada.png"), + phoneEncrypted: encryptText("+5534999999999"), + cpfEncrypted: encryptText("12345678901"), + technologiesEncrypted: encryptText(JSON.stringify(["TypeScript"])), + levelEncrypted: encryptText("pleno"), + }), + ); + + expect(publicUser).toMatchObject({ + email: "ada@example.com", + firstName: "Ada", + lastName: "Lovelace", + displayName: "Ada Lovelace", + avatarUrl: "https://example.com/ada.png", + phone: "+5534999999999", + cpf: "12345678901", + technologies: ["TypeScript"], + level: "pleno", + }); + }); + + it("falls back to legacy plain values when encrypted fields are missing", () => { + const publicUser = toPublicUser( + baseUser({ + email: "legacy@example.com", + firstName: "Legacy", + lastName: "User", + displayName: "Legacy User", + avatarUrl: "https://example.com/legacy.png", + phone: "+5500000000000", + cpf: "00000000000", + technologies: ["React"], + level: "junior", + }), + ); + + expect(publicUser).toMatchObject({ + email: "legacy@example.com", + firstName: "Legacy", + lastName: "User", + displayName: "Legacy User", + avatarUrl: "https://example.com/legacy.png", + phone: "+5500000000000", + cpf: "00000000000", + technologies: ["React"], + level: "junior", + }); + }); + + it("falls back to plain technologies for invalid encrypted technologies", () => { + const publicUser = toPublicUser( + baseUser({ + technologies: ["Fallback"], + technologiesEncrypted: "invalid-payload", + }), + ); + + expect(publicUser.technologies).toEqual(["Fallback"]); + }); + + it("falls back to plain technologies when encrypted JSON is not an array", () => { + setValidSecurityEnv(); + + const publicUser = toPublicUser( + baseUser({ + technologies: ["Fallback"], + technologiesEncrypted: encryptText(JSON.stringify({ value: "React" })), + }), + ); + + expect(publicUser.technologies).toEqual(["Fallback"]); + }); + + it("maps null values to null encrypted values when creating or updating", () => { + setValidSecurityEnv(); + + expect(toUserCreateValues({ email: null })).toMatchObject({ + email: null, + emailEncrypted: null, + emailHash: null, + firstNameEncrypted: null, + lastNameEncrypted: null, + displayNameEncrypted: null, + avatarUrlEncrypted: null, + phoneEncrypted: null, + cpfEncrypted: null, + cpfHash: null, + technologiesEncrypted: expect.stringMatching(/^v1:mapper-test:/), + levelEncrypted: null, + }); + + expect( + toUserUpdateValues({ + phone: null, + cpf: null, + technologies: null, + level: null, + }), + ).toMatchObject({ + phone: null, + phoneEncrypted: null, + cpf: null, + cpfEncrypted: null, + cpfHash: null, + technologies: null, + technologiesEncrypted: null, + level: null, + levelEncrypted: null, + }); + }); +}); diff --git a/docker-compose.migrate.yml b/docker-compose.migrate.yml new file mode 100644 index 0000000..45698e6 --- /dev/null +++ b/docker-compose.migrate.yml @@ -0,0 +1,34 @@ +services: + migrate: + build: + context: . + dockerfile: docker/node.Dockerfile + target: backend + container_name: vagas-migrate + env_file: + - ./.env + - ./backend/.env + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + command: sh -c "npm run db:migrate && npm run security:backfill-user-pii -- --write" + depends_on: + postgres: + condition: service_healthy + networks: + - vagas-net + restart: "no" + + backend: + env_file: + - ./.env + - ./backend/.env + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + VALKEY_URL: redis://valkey:6379/0 + depends_on: + migrate: + condition: service_completed_successfully + +networks: + vagas-net: + external: true diff --git a/front_admin/src/modules/users/UsersPage.tsx b/front_admin/src/modules/users/UsersPage.tsx index 0227bff..3e46c6d 100644 --- a/front_admin/src/modules/users/UsersPage.tsx +++ b/front_admin/src/modules/users/UsersPage.tsx @@ -58,6 +58,7 @@ function mapUser(user: BackendAdminUser): AdminUser { name: user.displayName ?? user.username ?? user.email ?? "Usuario sem nome", email: user.email ?? "sem email", initials: initialsFrom(user), + avatarUrl: user.avatarUrl, role: roleLabel(user.role), rawRole: user.role, isBlocked: user.isBlocked, diff --git a/front_admin/src/modules/users/components/UserListItem.tsx b/front_admin/src/modules/users/components/UserListItem.tsx index aa4b812..fa24a17 100644 --- a/front_admin/src/modules/users/components/UserListItem.tsx +++ b/front_admin/src/modules/users/components/UserListItem.tsx @@ -1,4 +1,5 @@ import { CalendarDays, Edit3, Mail, ShieldCheck } from "lucide-react"; +import { useState } from "react"; import type { AdminUser } from "../types/user.types"; interface UserListItemProps { @@ -39,15 +40,36 @@ function formatDate(value: string | null): string { }).format(date); } +function UserAvatar({ user }: { user: AdminUser }) { + const [imageFailed, setImageFailed] = useState(false); + const showImage = Boolean(user.avatarUrl) && !imageFailed; + + if (showImage) { + return ( + {`Foto setImageFailed(true)} + className="h-9 w-9 shrink-0 rounded-full border border-slate-200 object-cover dark:border-slate-700" + /> + ); + } + + return ( +
+ {user.initials} +
+ ); +} + export function UserListItem({ user, onEdit }: UserListItemProps) { return (
-
- {user.initials} -
+

diff --git a/front_admin/src/modules/users/mocks/users.mock.ts b/front_admin/src/modules/users/mocks/users.mock.ts index 1a97c1a..444bc28 100644 --- a/front_admin/src/modules/users/mocks/users.mock.ts +++ b/front_admin/src/modules/users/mocks/users.mock.ts @@ -6,6 +6,7 @@ export const MOCK_USERS: AdminUser[] = [ name: "Beene Santos", email: "beene.santos@candidate.com.br", initials: "BS", + avatarUrl: null, role: "Super Admin", rawRole: "super_admin", isBlocked: false, @@ -18,6 +19,7 @@ export const MOCK_USERS: AdminUser[] = [ name: "Ana Lúcia Vieira", email: "ana.lucia@candidate.com.br", initials: "AL", + avatarUrl: null, role: "Admin", rawRole: "admin", isBlocked: false, @@ -30,6 +32,7 @@ export const MOCK_USERS: AdminUser[] = [ name: "Matheus Costa", email: "matheus.costa@candidate.com.br", initials: "MC", + avatarUrl: null, role: "Suporte", rawRole: "support", isBlocked: false, diff --git a/front_admin/src/modules/users/types/user.types.ts b/front_admin/src/modules/users/types/user.types.ts index 5c85172..7fd8b9c 100644 --- a/front_admin/src/modules/users/types/user.types.ts +++ b/front_admin/src/modules/users/types/user.types.ts @@ -6,6 +6,7 @@ export interface AdminUser { name: string; email: string; initials: string; + avatarUrl: string | null; role: UserRole; rawRole: BackendUserRole; isBlocked: boolean; diff --git a/front_admin/tests/modules/users/components/UserList.test.tsx b/front_admin/tests/modules/users/components/UserList.test.tsx index a6544d8..5f39218 100644 --- a/front_admin/tests/modules/users/components/UserList.test.tsx +++ b/front_admin/tests/modules/users/components/UserList.test.tsx @@ -8,6 +8,7 @@ export const userFixture: AdminUser = { name: "Ada Lovelace", email: "ada@example.com", initials: "AL", + avatarUrl: null, role: "Admin", rawRole: "admin", isBlocked: false, @@ -40,4 +41,18 @@ describe("UserList", () => { expect.objectContaining({ id: "u1" }), ); }); + + it("renders user avatar image when available", () => { + render( + , + ); + + expect(screen.getByAltText("Foto de Ada Lovelace")).toHaveAttribute( + "src", + "https://example.com/ada.png", + ); + }); }); diff --git a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx index cc2194e..b03da44 100644 --- a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx +++ b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { Image } from "@unpic/react"; import { motion } from "framer-motion"; import { ArrowLeft, Eye, EyeOff } from "lucide-react"; @@ -26,6 +24,16 @@ const STATIC_STARS = Array.from({ length: 40 }).map((_, i) => { }; }); +const LEVEL_OPTIONS = [ + { value: "junior", label: "Júnior" }, + { value: "pleno", label: "Pleno" }, + { value: "senior", label: "Sênior" }, +]; + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + function StarsBackground() { return (

@@ -62,12 +70,14 @@ export default function RegisterSide() { const [telefone, setTelefone] = useState(""); const [password, setPassword] = useState(""); const [cpf, setCpf] = useState(""); + const [level, setLevel] = useState(""); const [nomeError, setNomeError] = useState(""); const [emailError, setEmailError] = useState(""); const [telefoneError, setTelefoneError] = useState(""); const [passwordError, setPasswordError] = useState(""); const [cpfError, setCpfError] = useState(""); + const [levelError, setLevelError] = useState(""); const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState(""); @@ -88,8 +98,8 @@ export default function RegisterSide() { try { const url = await getGithubAuthUrl(); window.location.href = url; - } catch (err: any) { - setApiError(err.message || "Erro ao iniciar login com Github."); + } catch (err: unknown) { + setApiError(getErrorMessage(err, "Erro ao iniciar login com Github.")); setIsLoading(false); } }; @@ -100,8 +110,8 @@ export default function RegisterSide() { try { const url = await getLinkedinAuthUrl(); window.location.href = url; - } catch (err: any) { - setApiError(err.message || "Erro ao iniciar login com LinkedIn."); + } catch (err: unknown) { + setApiError(getErrorMessage(err, "Erro ao iniciar login com LinkedIn.")); setIsLoading(false); } }; @@ -124,6 +134,7 @@ export default function RegisterSide() { setTelefoneError(""); setPasswordError(""); setCpfError(""); + setLevelError(""); setApiError(""); let isValid = true; @@ -166,6 +177,11 @@ export default function RegisterSide() { isValid = false; } + if (!level) { + setLevelError("Selecione seu nível de experiência."); + isValid = false; + } + if (isValid) { setIsLoading(true); try { @@ -173,11 +189,14 @@ export default function RegisterSide() { email: email, password: password, name: nome, + phone: telefone, + cpf: cpf || undefined, + level, }); window.location.href = "/login?registered=true"; - } catch (error: any) { + } catch (error: unknown) { console.error("Erro no cadastro:", error); - setApiError(error.message || "Erro ao cadastrar. Tente novamente."); + setApiError(getErrorMessage(error, "Erro ao cadastrar. Tente novamente.")); } finally { setIsLoading(false); } @@ -357,6 +376,33 @@ export default function RegisterSide() {

)}
+
+ + + {levelError && ( +

+ {levelError} +

+ )} +
({ register: (...args: any[]) => mockRegister(...args), getGoogleAuthUrl: (...args: any[]) => mockGetGoogleAuthUrl(...args), @@ -83,6 +101,7 @@ describe("RegisterSide", () => { expect(await screen.findByText(/campo de e-mail é obrigatório/i)).toBeInTheDocument(); expect(await screen.findByText(/campo de telefone é obrigatório/i)).toBeInTheDocument(); expect(await screen.findByText(/campo de senha é obrigatório/i)).toBeInTheDocument(); + expect(await screen.findByText(/selecione seu nível/i)).toBeInTheDocument(); }); it("valida CPF inválido quando preenchido", async () => { @@ -91,6 +110,7 @@ describe("RegisterSide", () => { fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "teste@email.com" } }); fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { target: { value: "+5534999999999" } }); fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); + fireEvent.change(screen.getByLabelText(/nível de experiência/i), { target: { value: "pleno" } }); fireEvent.change(screen.getByLabelText(/cpf/i), { target: { value: "123" } }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); expect(await screen.findByText(/cpf inválido/i)).toBeInTheDocument(); @@ -99,34 +119,35 @@ describe("RegisterSide", () => { it("envia formulário válido sem CPF", async () => { mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); render(); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: "Bene" } }); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "bene@teste.com" } }); - fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { target: { value: "+5534999999999" } }); - fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); + fillRequiredRegisterFields(); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); await waitFor(() => { expect(mockRegister).toHaveBeenCalledWith({ email: "bene@teste.com", password: "123456", name: "Bene", + phone: "+5534999999999", + cpf: undefined, + level: "pleno", }); }); expect(window.location.href).toBe("/login?registered=true"); }); - it("envia formulário válido como usuário (sem tecnologias/nível)", async () => { + it("envia formulário válido com CPF como usuário (sem tecnologias/nível)", async () => { mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); render(); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: "Bene" } }); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "bene@teste.com" } }); - fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { target: { value: "+5534999999999" } }); - fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); + fillRequiredRegisterFields(); + fireEvent.change(screen.getByLabelText(/cpf/i), { target: { value: "12345678901" } }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); await waitFor(() => { expect(mockRegister).toHaveBeenCalledWith({ email: "bene@teste.com", password: "123456", name: "Bene", + phone: "+5534999999999", + cpf: "123.456.789-01", + level: "pleno", }); }); }); @@ -134,10 +155,7 @@ describe("RegisterSide", () => { it("exibe erro da API", async () => { mockRegister.mockRejectedValueOnce(new Error("Email já cadastrado")); render(); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: "Bene" } }); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "bene@teste.com" } }); - fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { target: { value: "+5534999999999" } }); - fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); + fillRequiredRegisterFields(); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); expect(await screen.findByText(/Email já cadastrado/i)).toBeInTheDocument(); }); @@ -145,10 +163,7 @@ describe("RegisterSide", () => { it("mostra loading durante requisição", async () => { mockRegister.mockImplementation(() => new Promise(() => {})); render(); - fireEvent.change(screen.getByLabelText(/nome/i), { target: { value: "Bene" } }); - fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "bene@teste.com" } }); - fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { target: { value: "+5534999999999" } }); - fireEvent.change(screen.getByLabelText(/senha/i), { target: { value: "123456" } }); + fillRequiredRegisterFields(); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); expect(await screen.findByRole("button", { name: /cadastrando\.\.\./i })).toBeDisabled(); }); @@ -169,16 +184,19 @@ describe("RegisterSide", () => { const emailInput = screen.getByLabelText(/email/i); const telefoneInput = screen.getByPlaceholderText(/\(34\)/i); const passwordInput = screen.getByLabelText(/senha/i); + const levelInput = screen.getByLabelText(/nível de experiência/i); fireEvent.change(nomeInput, { target: { value: "Bene" } }); fireEvent.change(emailInput, { target: { value: "bene@teste.com" } }); fireEvent.change(telefoneInput, { target: { value: "+5534999999999" } }); fireEvent.change(passwordInput, { target: { value: "123456" } }); + fireEvent.change(levelInput, { target: { value: "pleno" } }); fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); await waitFor(() => { expect(nomeInput).toBeDisabled(); expect(emailInput).toBeDisabled(); expect(telefoneInput).toBeDisabled(); expect(passwordInput).toBeDisabled(); + expect(levelInput).toBeDisabled(); }); });