diff --git a/backend/drizzle/0014_concerned_matthew_murdock.sql b/backend/drizzle/0014_concerned_matthew_murdock.sql new file mode 100644 index 0000000..60e503b --- /dev/null +++ b/backend/drizzle/0014_concerned_matthew_murdock.sql @@ -0,0 +1,14 @@ +CREATE TABLE "user_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "user_agent" text, + "ip_address" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "last_seen_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + "revoked_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "user_sessions_active_idx" ON "user_sessions" USING btree ("user_id","expires_at","revoked_at"); diff --git a/backend/drizzle/meta/0014_snapshot.json b/backend/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..492503c --- /dev/null +++ b/backend/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1307 @@ +{ + "id": "f422a08e-136f-47d4-a69e-c78a84182923", + "prevId": "979ff559-fdaa-4fd2-ad0e-00019bbbd23e", + "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.application_events": { + "name": "application_events", + "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 + }, + "saved_job_id": { + "name": "saved_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "to_status": { + "name": "to_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_events_saved_job_id_created_at_idx": { + "name": "application_events_saved_job_id_created_at_idx", + "columns": [ + { + "expression": "saved_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_events_user_id_users_id_fk": { + "name": "application_events_user_id_users_id_fk", + "tableFrom": "application_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_events_saved_job_id_saved_jobs_id_fk": { + "name": "application_events_saved_job_id_saved_jobs_id_fk", + "tableFrom": "application_events", + "tableTo": "saved_jobs", + "columnsFrom": [ + "saved_job_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_notifications": { + "name": "user_notifications", + "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 + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'notification'" + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_notifications_user_created_at_idx": { + "name": "user_notifications_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notifications_user_read_at_idx": { + "name": "user_notifications_user_read_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "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": false + }, + "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 + }, + "career_checklist": { + "name": "career_checklist", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "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.user_sessions": { + "name": "user_sessions", + "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 + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_sessions_user_id_idx": { + "name": "user_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_sessions_active_idx": { + "name": "user_sessions_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_sessions_user_id_users_id_fk": { + "name": "user_sessions_user_id_users_id_fk", + "tableFrom": "user_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "avatar_url_encrypted": { + "name": "avatar_url_encrypted", + "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": "'{}'" + }, + "technologies_encrypted": { + "name": "technologies_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technology_experiences_encrypted": { + "name": "technology_experiences_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "level_encrypted": { + "name": "level_encrypted", + "type": "text", + "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.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "notification", + "message" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "job_saved", + "job_applied", + "job_status_changed", + "high_match", + "mentor", + "system" + ] + }, + "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 9ad8e6c..12d8086 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1786569164248, "tag": "0013_chunky_wonder_man", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1788032615944, + "tag": "0014_concerned_matthew_murdock", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index ca3cfde..ecfb384 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -7,4 +7,5 @@ export * from "./permissionRules"; export * from "./savedJobs"; export * from "./userNotifications"; export * from "./userPreferences"; +export * from "./userSessions"; export * from "./users"; diff --git a/backend/src/db/schema/userSessions.ts b/backend/src/db/schema/userSessions.ts new file mode 100644 index 0000000..4cebbf2 --- /dev/null +++ b/backend/src/db/schema/userSessions.ts @@ -0,0 +1,26 @@ +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { users } from "./users"; + +export const userSessions = pgTable( + "user_sessions", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + userAgent: text("user_agent"), + ipAddress: text("ip_address"), + createdAt: timestamp("created_at").defaultNow().notNull(), + lastSeenAt: timestamp("last_seen_at").defaultNow().notNull(), + expiresAt: timestamp("expires_at").notNull(), + revokedAt: timestamp("revoked_at"), + }, + (table) => [ + index("user_sessions_user_id_idx").on(table.userId), + index("user_sessions_active_idx").on( + table.userId, + table.expiresAt, + table.revokedAt, + ), + ], +); diff --git a/backend/src/lib/session.ts b/backend/src/lib/session.ts index a972a1f..64a5a30 100644 --- a/backend/src/lib/session.ts +++ b/backend/src/lib/session.ts @@ -1,14 +1,24 @@ import type { SessionOptions } from "iron-session"; const isProd = process.env.NODE_ENV === "production"; +const configuredSessionTtlSeconds = Number.parseInt( + process.env.SESSION_TTL_SECONDS ?? String(60 * 60 * 24 * 30), + 10, +); +export const sessionTtlSeconds = + Number.isFinite(configuredSessionTtlSeconds) && configuredSessionTtlSeconds > 60 + ? configuredSessionTtlSeconds + : 60 * 60 * 24 * 30; export const sessionOptions: SessionOptions = { password: process.env.SESSION_SECRET!, cookieName: "vagas_session", + ttl: sessionTtlSeconds, cookieOptions: { secure: isProd, httpOnly: true, sameSite: isProd ? "none" : "lax", + maxAge: sessionTtlSeconds - 60, }, }; diff --git a/backend/src/middleware/withSession.ts b/backend/src/middleware/withSession.ts index d0e1051..6c989f9 100644 --- a/backend/src/middleware/withSession.ts +++ b/backend/src/middleware/withSession.ts @@ -2,10 +2,12 @@ import type { NextFunction, Request, Response } from "express"; import { getIronSession } from "iron-session"; import { UserRole } from "../db/schema/users"; import { sessionOptions } from "../lib/session"; +import { SessionService } from "../modules/auth/session.service"; export interface SessionData { userId?: string; role?: UserRole; + sessionId?: string; } declare module "express-serve-static-core" { @@ -20,5 +22,12 @@ export async function withSession( next: NextFunction, ) { req.session = await getIronSession(req, res, sessionOptions); + if (req.session.userId && req.session.sessionId) { + const isValid = await new SessionService().isActive( + req.session.userId, + req.session.sessionId, + ); + if (!isValid) await req.session.destroy(); + } next(); } diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 45ea5ca..2b52ac9 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -5,9 +5,13 @@ import type { OAuthProvider } from "../types/auth.types.js"; import { AuthCallbackParamsSchema } from "../types/auth.types.js"; import { linkProviderToUser } from "../users/functions/linkProviderToUser.js"; import { AuthService } from "./auth.service.js"; +import { SessionService } from "./session.service.js"; export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly sessions = new SessionService(), + ) {} async getUrl(req: Request, res: Response) { const provider = req.params.provider as OAuthProvider; @@ -86,6 +90,11 @@ export class AuthController { req.session.userId = result.session.userId; req.session.role = result.session.role; + const session = await this.sessions.create(result.session.userId, { + userAgent: req.get?.("user-agent") ?? undefined, + ipAddress: req.ip, + }); + req.session.sessionId = session.id; await req.session.save(); return res.redirect(`${frontendUrl}/auth/callback`); diff --git a/backend/src/modules/auth/credentials.controller.ts b/backend/src/modules/auth/credentials.controller.ts index 6ff68c1..f064228 100644 --- a/backend/src/modules/auth/credentials.controller.ts +++ b/backend/src/modules/auth/credentials.controller.ts @@ -1,9 +1,21 @@ import { Request, Response } from "express"; import { AppError } from "../../lib/errors"; import { CredentialsService } from "./credentials.service"; +import { SessionService } from "./session.service"; export class CredentialsController { - constructor(private readonly service: CredentialsService) {} + constructor( + private readonly service: CredentialsService, + private readonly sessions = new SessionService(), + ) {} + + private async establishSession(req: Request, userId: string) { + const session = await this.sessions.create(userId, { + userAgent: req.get?.("user-agent") ?? undefined, + ipAddress: req.ip, + }); + req.session.sessionId = session.id; + } async register(req: Request, res: Response) { const { user, session: userSession } = await this.service.register( @@ -12,6 +24,7 @@ export class CredentialsController { req.session.userId = userSession.userId; req.session.role = userSession.role; + await this.establishSession(req, userSession.userId); await req.session.save(); return res.status(201).json({ user, session: userSession }); @@ -22,12 +35,16 @@ export class CredentialsController { req.session.userId = userSession.userId; req.session.role = userSession.role; + await this.establishSession(req, userSession.userId); await req.session.save(); return res.json({ user, session: userSession }); } async logout(req: Request, res: Response) { + if (req.session.userId && req.session.sessionId) { + await this.sessions.revoke(req.session.userId, req.session.sessionId); + } await req.session.destroy(); return res.json({ ok: true }); } diff --git a/backend/src/modules/auth/session.service.ts b/backend/src/modules/auth/session.service.ts new file mode 100644 index 0000000..57777fb --- /dev/null +++ b/backend/src/modules/auth/session.service.ts @@ -0,0 +1,129 @@ +import { and, desc, eq, gt, isNull, ne } from "drizzle-orm"; +import { db } from "../../db/client"; +import { userSessions } from "../../db/schema"; +import { sessionTtlSeconds } from "../../lib/session"; + +export type SessionMetadata = { + userAgent?: string; + ipAddress?: string; +}; + +function expirationDate() { + return new Date(Date.now() + sessionTtlSeconds * 1000); +} + +export function describeDevice(userAgent: string | null) { + if (!userAgent) return "Dispositivo desconhecido"; + const browser = /edg\//i.test(userAgent) + ? "Microsoft Edge" + : /firefox\//i.test(userAgent) + ? "Firefox" + : /chrome\//i.test(userAgent) + ? "Google Chrome" + : /safari\//i.test(userAgent) + ? "Safari" + : "Navegador desconhecido"; + const platform = /android/i.test(userAgent) + ? "Android" + : /iphone|ipad|ipod/i.test(userAgent) + ? "iOS" + : /windows/i.test(userAgent) + ? "Windows" + : /mac os/i.test(userAgent) + ? "macOS" + : /linux/i.test(userAgent) + ? "Linux" + : null; + return platform ? `${browser} em ${platform}` : browser; +} + +export class SessionService { + async create(userId: string, metadata: SessionMetadata = {}) { + const [session] = await db + .insert(userSessions) + .values({ + userId, + userAgent: metadata.userAgent?.slice(0, 1024) || null, + ipAddress: metadata.ipAddress?.slice(0, 64) || null, + expiresAt: expirationDate(), + }) + .returning(); + return session; + } + + async isActive(userId: string, sessionId?: string) { + if (!sessionId) return false; + const session = await db.query.userSessions.findFirst({ + where: and( + eq(userSessions.id, sessionId), + eq(userSessions.userId, userId), + isNull(userSessions.revokedAt), + gt(userSessions.expiresAt, new Date()), + ), + }); + if (!session) return false; + + await db + .update(userSessions) + .set({ lastSeenAt: new Date() }) + .where(eq(userSessions.id, session.id)); + return true; + } + + async list(userId: string, currentSessionId?: string) { + const sessions = await db + .select() + .from(userSessions) + .where( + and( + eq(userSessions.userId, userId), + isNull(userSessions.revokedAt), + gt(userSessions.expiresAt, new Date()), + ), + ) + .orderBy(desc(userSessions.lastSeenAt)); + + return sessions.map((session) => ({ + id: session.id, + device: describeDevice(session.userAgent), + userAgent: session.userAgent, + ipAddress: session.ipAddress, + createdAt: session.createdAt, + lastSeenAt: session.lastSeenAt, + expiresAt: session.expiresAt, + isCurrent: session.id === currentSessionId, + })); + } + + async revoke(userId: string, sessionId: string) { + const result = await db + .update(userSessions) + .set({ revokedAt: new Date() }) + .where( + and( + eq(userSessions.id, sessionId), + eq(userSessions.userId, userId), + isNull(userSessions.revokedAt), + ), + ) + .returning({ id: userSessions.id }); + return result.length > 0; + } + + async revokeOthers(userId: string, currentSessionId?: string) { + if (!currentSessionId) return 0; + const revokedSessions = await db + .update(userSessions) + .set({ revokedAt: new Date() }) + .where( + and( + eq(userSessions.userId, userId), + ne(userSessions.id, currentSessionId), + isNull(userSessions.revokedAt), + gt(userSessions.expiresAt, new Date()), + ), + ) + .returning({ id: userSessions.id }); + return revokedSessions.length; + } +} diff --git a/backend/src/modules/auth/sessions.controller.ts b/backend/src/modules/auth/sessions.controller.ts new file mode 100644 index 0000000..99ec58d --- /dev/null +++ b/backend/src/modules/auth/sessions.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from "express"; +import { AppError } from "../../lib/errors"; +import { SessionService } from "./session.service"; + +export class SessionsController { + constructor(private readonly sessions = new SessionService()) {} + + async list(req: Request, res: Response) { + const sessions = await this.sessions.list( + req.session.userId as string, + req.session.sessionId, + ); + return res.json({ sessions }); + } + + async revoke(req: Request, res: Response) { + const userId = req.session.userId as string; + const sessionId = req.params.sessionId; + const revoked = await this.sessions.revoke(userId, sessionId); + if (!revoked) throw AppError.notFound("Sessão não encontrada."); + + const currentSessionRevoked = sessionId === req.session.sessionId; + if (currentSessionRevoked) await req.session.destroy(); + return res.json({ ok: true, currentSessionRevoked }); + } + + async revokeOthers(req: Request, res: Response) { + const revokedCount = await this.sessions.revokeOthers( + req.session.userId as string, + req.session.sessionId, + ); + return res.json({ ok: true, revokedCount }); + } +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 5fabeec..d614769 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -11,6 +11,8 @@ import { AuthService } from "../modules/auth/auth.service"; import { ConnectionsController } from "../modules/auth/connections.controller"; import { CredentialsController } from "../modules/auth/credentials.controller"; import { CredentialsService } from "../modules/auth/credentials.service"; +import { SessionService } from "../modules/auth/session.service"; +import { SessionsController } from "../modules/auth/sessions.controller"; import { OAuthProviderSchema } from "../modules/types/auth.types"; import { LoginSchema, @@ -22,7 +24,12 @@ const router = Router(); const authService = new AuthService(); const authController = new AuthController(authService); const credentialsService = new CredentialsService(); -const credentialsController = new CredentialsController(credentialsService); +const sessionService = new SessionService(); +const credentialsController = new CredentialsController( + credentialsService, + sessionService, +); +const sessionsController = new SessionsController(sessionService); const connectionsController = new ConnectionsController(); const providerParamsSchema = z.object({ @@ -45,6 +52,16 @@ router.get("/:provider/callback", (req, res, next) => { router.get("/connections", requireAuth, (req, res, next) => { connectionsController.list(req, res).catch(next); }); + +router.get("/sessions", requireAuth, (req, res, next) => { + sessionsController.list(req, res).catch(next); +}); +router.delete("/sessions/:sessionId", requireAuth, (req, res, next) => { + sessionsController.revoke(req, res).catch(next); +}); +router.post("/sessions/revoke-others", requireAuth, (req, res, next) => { + sessionsController.revokeOthers(req, res).catch(next); +}); router.delete("/connections/:provider", requireAuth, (req, res, next) => { connectionsController.disconnect(req, res).catch(next); }); diff --git a/backend/tests/integration/routes/auth.routes.test.ts b/backend/tests/integration/routes/auth.routes.test.ts index af213c7..f0692a1 100644 --- a/backend/tests/integration/routes/auth.routes.test.ts +++ b/backend/tests/integration/routes/auth.routes.test.ts @@ -33,6 +33,22 @@ vi.mock("../../../src/modules/auth/credentials.service", () => ({ }, })); +const mockSessionService = vi.hoisted(() => ({ + create: vi.fn(), + isActive: vi.fn(), + list: vi.fn(), + revoke: vi.fn(), + revokeOthers: vi.fn(), +})); + +vi.mock("../../../src/modules/auth/session.service", () => ({ + SessionService: class { + constructor() { + return mockSessionService; + } + }, +})); + // ── iron-session ────────────────────────────────────────────────────────────── // AuthController chama getIronSession diretamente. // CredentialsController usa req.session injetado pelo withSession middleware. @@ -124,6 +140,8 @@ describe("Integration - Auth Routes", () => { }); mockCredentialsService.findById.mockResolvedValue(fixtureUser); + mockSessionService.create.mockResolvedValue({ id: "session-1" }); + mockSessionService.isActive.mockResolvedValue(true); app = createJobsApiApp(); }); diff --git a/backend/tests/unit/modules/auth/auth.controller.test.ts b/backend/tests/unit/modules/auth/auth.controller.test.ts index 7f09a44..f4741aa 100644 --- a/backend/tests/unit/modules/auth/auth.controller.test.ts +++ b/backend/tests/unit/modules/auth/auth.controller.test.ts @@ -8,6 +8,7 @@ describe("AuthController", () => { let reqMock: Partial; let resMock: Partial; let sessionMock: any; + let sessionsMock: any; beforeEach(() => { vi.stubEnv("SESSION_SECRET", "um-password-longo-com-mais-de-32-caracteres"); @@ -23,13 +24,15 @@ describe("AuthController", () => { }), }; - authController = new AuthController(authServiceMock); + sessionsMock = { create: vi.fn().mockResolvedValue({ id: "session_123" }) }; + authController = new AuthController(authServiceMock, sessionsMock); sessionMock = { save: vi.fn().mockResolvedValue(undefined), oauth_state: undefined, userId: undefined, role: undefined, + sessionId: undefined, }; reqMock = { diff --git a/backend/tests/unit/modules/auth/credentials.controller.test.ts b/backend/tests/unit/modules/auth/credentials.controller.test.ts index 0c61d6b..8882389 100644 --- a/backend/tests/unit/modules/auth/credentials.controller.test.ts +++ b/backend/tests/unit/modules/auth/credentials.controller.test.ts @@ -8,6 +8,7 @@ describe("CredentialsController", () => { let controller: CredentialsController; let reqMock: any; let resMock: Partial; + let sessionsMock: any; beforeEach(() => { vi.clearAllMocks(); @@ -28,13 +29,18 @@ describe("CredentialsController", () => { }), }; - controller = new CredentialsController(serviceMock); + sessionsMock = { + create: vi.fn().mockResolvedValue({ id: "session_123" }), + revoke: vi.fn().mockResolvedValue(true), + }; + controller = new CredentialsController(serviceMock, sessionsMock); reqMock = { body: {}, session: { userId: undefined, role: undefined, + sessionId: undefined, save: vi.fn().mockResolvedValue(undefined), destroy: vi.fn().mockResolvedValue(undefined), }, diff --git a/backend/tests/unit/modules/auth/session.service.test.ts b/backend/tests/unit/modules/auth/session.service.test.ts new file mode 100644 index 0000000..4202b52 --- /dev/null +++ b/backend/tests/unit/modules/auth/session.service.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findFirst: vi.fn(), + insertReturning: vi.fn(), + updateReturning: vi.fn(), + selectOrderBy: vi.fn(), + updateSet: vi.fn(), +})); + +vi.mock("../../../../src/db/client", () => ({ + db: { + query: { userSessions: { findFirst: mocks.findFirst } }, + insert: vi.fn(() => ({ + values: vi.fn(() => ({ returning: mocks.insertReturning })), + })), + update: vi.fn(() => ({ + set: (...args: unknown[]) => { + mocks.updateSet(...args); + return { where: vi.fn(() => ({ returning: mocks.updateReturning })) }; + }, + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ orderBy: mocks.selectOrderBy })), + })), + })), + }, +})); + +import { describeDevice, SessionService } from "../../../../src/modules/auth/session.service"; + +describe("SessionService", () => { + const service = new SessionService(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("SESSION_TTL_SECONDS", "3600"); + }); + + it("cria sessão com metadados e expiração", async () => { + mocks.insertReturning.mockResolvedValue([{ id: "session-1" }]); + + await expect( + service.create("user-1", { userAgent: "Chrome", ipAddress: "127.0.0.1" }), + ).resolves.toEqual({ id: "session-1" }); + }); + + it("considera inválida uma sessão sem identificador ou não encontrada", async () => { + await expect(service.isActive("user-1")).resolves.toBe(false); + mocks.findFirst.mockResolvedValue(undefined); + await expect(service.isActive("user-1", "session-1")).resolves.toBe(false); + }); + + it("atualiza o último acesso de uma sessão válida", async () => { + mocks.findFirst.mockResolvedValue({ id: "session-1" }); + await expect(service.isActive("user-1", "session-1")).resolves.toBe(true); + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ lastSeenAt: expect.any(Date) }), + ); + }); + + it("lista apenas as sessões ativas e identifica a atual", async () => { + const now = new Date("2026-08-29T12:00:00.000Z"); + mocks.selectOrderBy.mockResolvedValue([ + { + id: "current", userAgent: "Mozilla/5.0 Chrome/120.0 Windows", ipAddress: "127.0.0.1", + createdAt: now, lastSeenAt: now, expiresAt: now, + }, + ]); + + await expect(service.list("user-1", "current")).resolves.toEqual([ + expect.objectContaining({ id: "current", isCurrent: true, device: "Google Chrome em Windows" }), + ]); + }); + + it("revoga somente sessão ativa do mesmo usuário", async () => { + mocks.updateReturning.mockResolvedValue([{ id: "session-1" }]); + await expect(service.revoke("user-1", "session-1")).resolves.toBe(true); + mocks.updateReturning.mockResolvedValue([]); + await expect(service.revoke("user-1", "other-user-session")).resolves.toBe(false); + }); + + it("revoga todas as outras sessões, preservando a atual", async () => { + mocks.updateReturning.mockResolvedValue([{ id: "other" }]); + + await expect(service.revokeOthers("user-1", "current")).resolves.toBe(1); + }); + + it("não revoga sessões sem identificar a sessão atual", async () => { + await expect(service.revokeOthers("user-1")).resolves.toBe(0); + expect(mocks.updateReturning).not.toHaveBeenCalled(); + }); +}); + +describe("describeDevice", () => { + it("apresenta um nome útil para navegador e plataforma", () => { + expect(describeDevice("Mozilla/5.0 Firefox/120.0 Linux")).toBe("Firefox em Linux"); + expect(describeDevice(null)).toBe("Dispositivo desconhecido"); + }); +}); diff --git a/backend/tests/unit/modules/auth/sessions.controller.test.ts b/backend/tests/unit/modules/auth/sessions.controller.test.ts new file mode 100644 index 0000000..7f113ab --- /dev/null +++ b/backend/tests/unit/modules/auth/sessions.controller.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionsController } from "../../../../src/modules/auth/sessions.controller"; + +describe("SessionsController", () => { + const sessions = { + list: vi.fn(), + revoke: vi.fn(), + revokeOthers: vi.fn(), + }; + const controller = new SessionsController(sessions as any); + const req: any = { + session: { userId: "user-1", sessionId: "current", destroy: vi.fn() }, + params: {}, + }; + const res: any = { json: vi.fn() }; + + beforeEach(() => { + vi.clearAllMocks(); + req.params = {}; + }); + + it("lista as sessões do próprio usuário", async () => { + sessions.list.mockResolvedValue([{ id: "current" }]); + await controller.list(req, res); + expect(sessions.list).toHaveBeenCalledWith("user-1", "current"); + expect(res.json).toHaveBeenCalledWith({ sessions: [{ id: "current" }] }); + }); + + it("encerra a sessão atual e remove o cookie", async () => { + req.params.sessionId = "current"; + sessions.revoke.mockResolvedValue(true); + await controller.revoke(req, res); + expect(req.session.destroy).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ ok: true, currentSessionRevoked: true }); + }); + + it("informa quando a sessão não existe", async () => { + req.params.sessionId = "missing"; + sessions.revoke.mockResolvedValue(false); + await expect(controller.revoke(req, res)).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("revoga as outras sessões", async () => { + sessions.revokeOthers.mockResolvedValue(2); + await controller.revokeOthers(req, res); + expect(res.json).toHaveBeenCalledWith({ ok: true, revokedCount: 2 }); + }); +}); diff --git a/frontend/src/domains/auth/infrastructure/sessionsApi.ts b/frontend/src/domains/auth/infrastructure/sessionsApi.ts new file mode 100644 index 0000000..fec30fe --- /dev/null +++ b/frontend/src/domains/auth/infrastructure/sessionsApi.ts @@ -0,0 +1,31 @@ +import { api } from "@/shared/lib/apiClient"; + +export type ActiveSession = { + id: string; + device: string; + userAgent: string | null; + ipAddress: string | null; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + isCurrent: boolean; +}; + +export async function getActiveSessions() { + const { data } = await api.get<{ sessions: ActiveSession[] }>("/auth/sessions"); + return data.sessions; +} + +export async function revokeSession(sessionId: string) { + const { data } = await api.delete<{ currentSessionRevoked: boolean }>( + `/auth/sessions/${sessionId}`, + ); + return data; +} + +export async function revokeOtherSessions() { + const { data } = await api.post<{ revokedCount: number }>( + "/auth/sessions/revoke-others", + ); + return data; +} diff --git a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx index 4b9fe5b..cf7519e 100644 --- a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx +++ b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx @@ -2,6 +2,7 @@ import type { SearchPreferences, UserProfile } from "../../types"; import { ConnectionsForm } from "./ConnectionsForm"; import { PreferencesForm } from "./PreferencesForm"; import { ProfileForm } from "./ProfileForm"; +import { SessionsForm } from "./SessionsForm"; interface ProfileTabProps { userProfile: UserProfile; @@ -39,6 +40,7 @@ export function ProfileTab({ onSave={onSavePreferences} /> + ); } diff --git a/frontend/src/domains/new_dashboard/components/profile/SessionsForm.tsx b/frontend/src/domains/new_dashboard/components/profile/SessionsForm.tsx new file mode 100644 index 0000000..b01640e --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/profile/SessionsForm.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "@/domains/auth/application/AuthContext"; +import { + getActiveSessions, + revokeOtherSessions, + revokeSession, + type ActiveSession, +} from "@/domains/auth/infrastructure/sessionsApi"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("pt-BR", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export function SessionsForm() { + const { logout } = useAuth(); + const [sessions, setSessions] = useState(null); + const [busyId, setBusyId] = useState(null); + const [isRevokingOthers, setIsRevokingOthers] = useState(false); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(""); + + const refresh = async () => { + try { + setError(""); + setSessions(await getActiveSessions()); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Falha ao carregar sessões.", + ); + } + }; + + useEffect(() => { + let cancelled = false; + getActiveSessions() + .then((result) => { + if (!cancelled) setSessions(result); + }) + .catch((cause) => { + if (!cancelled) { + setError( + cause instanceof Error + ? cause.message + : "Falha ao carregar sessões.", + ); + } + }); + return () => { + cancelled = true; + }; + }, []); + + async function handleRevoke(session: ActiveSession) { + setBusyId(session.id); + setError(""); + setFeedback(""); + try { + const result = await revokeSession(session.id); + if (result.currentSessionRevoked) { + await logout(); + return; + } + setFeedback("Sessão revogada com sucesso."); + await refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Falha ao revogar sessão."); + } finally { + setBusyId(null); + } + } + + async function handleRevokeOthers() { + setIsRevokingOthers(true); + setError(""); + setFeedback(""); + try { + const { revokedCount } = await revokeOtherSessions(); + setFeedback( + revokedCount === 0 + ? "Não há outras sessões ativas." + : `${revokedCount} sessão(ões) revogada(s).`, + ); + await refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Falha ao revogar sessões."); + } finally { + setIsRevokingOthers(false); + } + } + + const hasOtherSessions = sessions?.some((session) => !session.isCurrent) ?? false; + + return ( +
+
+
+

Sessões ativas

+

+ Gerencie os dispositivos que têm acesso à sua conta. +

+
+ +
+ + {feedback ?

{feedback}

: null} + {error ?

{error}

: null} + +
+ {sessions === null ? ( +

Carregando…

+ ) : sessions.length === 0 ? ( +

+ Nenhuma sessão ativa encontrada. +

+ ) : ( +
    + {sessions.map((session) => ( +
  • +
    +

    + {session.device} {session.isCurrent ? "(esta sessão)" : ""} +

    +

    + Último acesso: {formatDate(session.lastSeenAt)} + {session.ipAddress ? ` · IP: ${session.ipAddress}` : ""} +

    +

    + Expira em: {formatDate(session.expiresAt)} +

    +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/tests/unit/new_dashboard/sessionsForm.test.tsx b/frontend/tests/unit/new_dashboard/sessionsForm.test.tsx new file mode 100644 index 0000000..cc85570 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/sessionsForm.test.tsx @@ -0,0 +1,68 @@ +import { SessionsForm } from "@/domains/new_dashboard/components/profile/SessionsForm"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getActiveSessions: vi.fn(), + revokeSession: vi.fn(), + revokeOtherSessions: vi.fn(), + logout: vi.fn(), +})); + +vi.mock("@/domains/auth/application/AuthContext", () => ({ + useAuth: () => ({ logout: mocks.logout }), +})); + +vi.mock("@/domains/auth/infrastructure/sessionsApi", () => ({ + getActiveSessions: mocks.getActiveSessions, + revokeSession: mocks.revokeSession, + revokeOtherSessions: mocks.revokeOtherSessions, +})); + +const sessions = [ + { + id: "current", device: "Google Chrome em Windows", userAgent: null, + ipAddress: "127.0.0.1", createdAt: "2026-08-29T12:00:00.000Z", + lastSeenAt: "2026-08-29T12:00:00.000Z", expiresAt: "2026-09-28T12:00:00.000Z", + isCurrent: true, + }, + { + id: "other", device: "Firefox em Linux", userAgent: null, + ipAddress: null, createdAt: "2026-08-28T12:00:00.000Z", + lastSeenAt: "2026-08-28T12:00:00.000Z", expiresAt: "2026-09-27T12:00:00.000Z", + isCurrent: false, + }, +]; + +describe("SessionsForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getActiveSessions.mockResolvedValue(sessions); + mocks.revokeSession.mockResolvedValue({ currentSessionRevoked: false }); + mocks.revokeOtherSessions.mockResolvedValue({ revokedCount: 1 }); + }); + + it("lista sessões e identifica a sessão atual", async () => { + render(); + expect(screen.getByText(/carregando/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/google chrome/i)).toBeInTheDocument()); + expect(screen.getByText(/google chrome em windows.*esta sessão/i)).toBeInTheDocument(); + expect(screen.getByText(/firefox em linux/i)).toBeInTheDocument(); + }); + + it("revoga as outras sessões e atualiza a lista", async () => { + render(); + await waitFor(() => expect(screen.getByText(/firefox em linux/i)).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: /encerrar outras sessões/i })); + await waitFor(() => expect(mocks.revokeOtherSessions).toHaveBeenCalled()); + expect(screen.getByText("1 sessão(ões) revogada(s).")).toBeInTheDocument(); + }); + + it("encerra o contexto autenticado quando a sessão atual é revogada", async () => { + mocks.revokeSession.mockResolvedValue({ currentSessionRevoked: true }); + render(); + await waitFor(() => expect(screen.getByText(/google chrome/i)).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: /encerrar esta sessão/i })); + await waitFor(() => expect(mocks.logout).toHaveBeenCalled()); + }); +});